From ee60edea5db872e45c729376a93030cc5a4b63d7 Mon Sep 17 00:00:00 2001 From: Dan Ditomaso Date: Thu, 22 May 2025 20:49:10 -0400 Subject: [PATCH] feat: added internationalization lib, added english labels --- deno.lock | 495 +++++++++++++-- package.json | 5 + src/components/BatteryStatus.tsx | 73 ++- src/components/CommandPalette/index.tsx | 58 +- .../DeleteMessagesDialog.tsx | 13 +- src/components/Dialog/DeviceNameDialog.tsx | 25 +- src/components/Dialog/ImportDialog.tsx | 22 +- .../Dialog/LocationResponseDialog.tsx | 21 +- src/components/Dialog/NewDeviceDialog.tsx | 71 ++- .../NodeDetailsDialog/NodeDetailsDialog.tsx | 96 ++- src/components/Dialog/PKIBackupDialog.tsx | 36 +- src/components/Dialog/PkiRegenerateDialog.tsx | 26 +- src/components/Dialog/QRDialog.tsx | 20 +- src/components/Dialog/RebootDialog.tsx | 11 +- src/components/Dialog/RebootOTADialog.tsx | 29 +- .../RefreshKeysDialog/RefreshKeysDialog.tsx | 27 +- src/components/Dialog/RemoveNodeDialog.tsx | 14 +- src/components/Dialog/ShutdownDialog.tsx | 13 +- .../Dialog/TracerouteResponseDialog.tsx | 13 +- .../UnsafeRolesDialog/UnsafeRolesDialog.tsx | 25 +- src/components/KeyBackupReminder.tsx | 5 +- src/components/PageComponents/Channel.tsx | 156 +++-- .../PageComponents/Config/Bluetooth.tsx | 33 +- .../PageComponents/Config/Device/index.tsx | 52 +- .../PageComponents/Config/Display.tsx | 58 +- src/components/PageComponents/Config/LoRa.tsx | 92 +-- .../PageComponents/Config/Network/index.tsx | 71 +-- .../PageComponents/Config/Position.tsx | 78 +-- .../PageComponents/Config/Power.tsx | 71 ++- .../Config/Security/Security.tsx | 133 ++-- src/components/PageComponents/Connect/BLE.tsx | 6 +- .../PageComponents/Connect/HTTP.tsx | 48 +- .../PageComponents/Connect/Serial.tsx | 23 +- .../PageComponents/Map/NodeDetail.tsx | 76 ++- .../PageComponents/Messages/ChannelChat.tsx | 16 +- .../Messages/MessageActionsMenu.tsx | 10 +- .../PageComponents/Messages/TraceRoute.tsx | 22 +- .../ModuleConfig/AmbientLighting.tsx | 2 +- src/components/Sidebar.tsx | 53 +- src/components/UI/Input.tsx | 22 +- .../generic/Filter/FilterControl.tsx | 98 +-- src/i18n.ts | 28 + src/i18n/locales/en.json | 576 ++++++++++++++++++ src/index.tsx | 7 +- src/pages/Channels.tsx | 15 +- src/pages/Config/DeviceConfig.tsx | 20 +- src/pages/Config/ModuleConfig.tsx | 28 +- src/pages/Config/index.tsx | 36 +- src/pages/Dashboard/index.tsx | 34 +- src/pages/Messages.tsx | 47 +- src/pages/Nodes.tsx | 77 ++- vite.config.ts | 47 +- 52 files changed, 2316 insertions(+), 817 deletions(-) create mode 100644 src/i18n.ts create mode 100644 src/i18n/locales/en.json diff --git a/deno.lock b/deno.lock index 33d1e81a..44abcf4a 100644 --- a/deno.lock +++ b/deno.lock @@ -22,6 +22,7 @@ "npm:@radix-ui/react-switch@^1.2.2": "1.2.2_@types+react@19.1.2_@types+react-dom@19.1.3__@types+react@19.1.2_react@19.1.0_react-dom@19.1.0__react@19.1.0", "npm:@radix-ui/react-tabs@^1.1.9": "1.1.9_@types+react@19.1.2_@types+react-dom@19.1.3__@types+react@19.1.2_react@19.1.0_react-dom@19.1.0__react@19.1.0", "npm:@radix-ui/react-toast@^1.2.11": "1.2.11_@types+react@19.1.2_@types+react-dom@19.1.3__@types+react@19.1.2_react@19.1.0_react-dom@19.1.0__react@19.1.0", + "npm:@radix-ui/react-toggle-group@^1.1.9": "1.1.10_@types+react@19.1.2_@types+react-dom@19.1.3__@types+react@19.1.2_react@19.1.0_react-dom@19.1.0__react@19.1.0", "npm:@radix-ui/react-tooltip@^1.2.4": "1.2.4_@types+react@19.1.2_@types+react-dom@19.1.3__@types+react@19.1.2_react@19.1.0_react-dom@19.1.0__react@19.1.0", "npm:@tailwindcss/postcss@^4.1.5": "4.1.5", "npm:@testing-library/jest-dom@^6.6.3": "6.6.3", @@ -46,6 +47,9 @@ "npm:crypto-random-string@5": "5.0.0", "npm:gzipper@^8.2.1": "8.2.1", "npm:happy-dom@^17.4.6": "17.4.6", + "npm:i18next-browser-languagedetector@^8.1.0": "8.1.0", + "npm:i18next-http-backend@^3.0.2": "3.0.2", + "npm:i18next@^25.2.0": "25.2.0_typescript@5.8.3", "npm:idb-keyval@^6.2.1": "6.2.1", "npm:immer@^10.1.1": "10.1.1", "npm:js-cookie@^3.0.5": "3.0.5", @@ -55,6 +59,7 @@ "npm:react-dom@^19.1.0": "19.1.0_react@19.1.0", "npm:react-error-boundary@6": "6.0.0_react@19.1.0", "npm:react-hook-form@^7.56.2": "7.56.2_react@19.1.0", + "npm:react-i18next@^15.5.1": "15.5.1_i18next@25.2.0__typescript@5.8.3_react@19.1.0_typescript@5.8.3", "npm:react-map-gl@8.0.4": "8.0.4_maplibre-gl@5.4.0_react@19.1.0_react-dom@19.1.0__react@19.1.0", "npm:react-qrcode-logo@3": "3.0.0_react@19.1.0_react-dom@19.1.0__react@19.1.0", "npm:react@^19.1.0": "19.1.0", @@ -66,6 +71,7 @@ "npm:tar@^7.4.3": "7.4.3", "npm:testing-library@^0.0.2": "0.0.2_@angular+common@6.1.10__@angular+core@6.1.10___rxjs@6.6.7___zone.js@0.8.29__rxjs@6.6.7_@angular+core@6.1.10__rxjs@6.6.7__zone.js@0.8.29", "npm:typescript@^5.8.3": "5.8.3", + "npm:vite-plugin-i18n-ally@^6.0.1": "6.0.1_vite@6.3.4__@types+node@22.15.3__picomatch@4.0.2_@types+node@22.15.3", "npm:vite-plugin-node-polyfills@0.23": "0.23.0_vite@6.3.4__@types+node@22.15.3__picomatch@4.0.2_@types+node@22.15.3", "npm:vite-plugin-pwa@1": "1.0.0_vite@6.3.4__@types+node@22.15.3__picomatch@4.0.2_workbox-build@7.3.0__ajv@8.17.1__@babel+core@7.27.1__rollup@2.79.2_workbox-window@7.3.0_@types+node@22.15.3", "npm:vite@^6.3.4": "6.3.4_@types+node@22.15.3_picomatch@4.0.2", @@ -1206,6 +1212,23 @@ "@noble/hashes@1.8.0": { "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==" }, + "@nodelib/fs.scandir@2.1.5": { + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dependencies": [ + "@nodelib/fs.stat", + "run-parallel" + ] + }, + "@nodelib/fs.stat@2.0.5": { + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==" + }, + "@nodelib/fs.walk@1.2.8": { + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dependencies": [ + "@nodelib/fs.scandir", + "fastq" + ] + }, "@radix-ui/number@1.1.1": { "integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==" }, @@ -1217,12 +1240,12 @@ "dependencies": [ "@radix-ui/primitive", "@radix-ui/react-collapsible", - "@radix-ui/react-collection", + "@radix-ui/react-collection@1.1.4_@types+react@19.1.2_@types+react-dom@19.1.3__@types+react@19.1.2_react@19.1.0_react-dom@19.1.0__react@19.1.0", "@radix-ui/react-compose-refs", "@radix-ui/react-context", "@radix-ui/react-direction", "@radix-ui/react-id", - "@radix-ui/react-primitive", + "@radix-ui/react-primitive@2.1.0_@types+react@19.1.2_@types+react-dom@19.1.3__@types+react@19.1.2_react@19.1.0_react-dom@19.1.0__react@19.1.0", "@radix-ui/react-use-controllable-state", "@types/react", "@types/react-dom", @@ -1237,7 +1260,7 @@ "@radix-ui/react-arrow@1.1.4_@types+react@19.1.2_@types+react-dom@19.1.3__@types+react@19.1.2_react@19.1.0_react-dom@19.1.0__react@19.1.0": { "integrity": "sha512-qz+fxrqgNxG0dYew5l7qR3c7wdgRu1XVUHGnGYX7rg5HM4p9SWaRmJwfgR3J0SgyUKayLmzQIun+N6rWRgiRKw==", "dependencies": [ - "@radix-ui/react-primitive", + "@radix-ui/react-primitive@2.1.0_@types+react@19.1.2_@types+react-dom@19.1.3__@types+react@19.1.2_react@19.1.0_react-dom@19.1.0__react@19.1.0", "@types/react", "@types/react-dom", "react", @@ -1255,7 +1278,7 @@ "@radix-ui/react-compose-refs", "@radix-ui/react-context", "@radix-ui/react-presence", - "@radix-ui/react-primitive", + "@radix-ui/react-primitive@2.1.0_@types+react@19.1.2_@types+react-dom@19.1.3__@types+react@19.1.2_react@19.1.0_react-dom@19.1.0__react@19.1.0", "@radix-ui/react-use-controllable-state", "@radix-ui/react-use-previous", "@radix-ui/react-use-size", @@ -1277,7 +1300,7 @@ "@radix-ui/react-context", "@radix-ui/react-id", "@radix-ui/react-presence", - "@radix-ui/react-primitive", + "@radix-ui/react-primitive@2.1.0_@types+react@19.1.2_@types+react-dom@19.1.3__@types+react@19.1.2_react@19.1.0_react-dom@19.1.0__react@19.1.0", "@radix-ui/react-use-controllable-state", "@radix-ui/react-use-layout-effect", "@types/react", @@ -1295,8 +1318,25 @@ "dependencies": [ "@radix-ui/react-compose-refs", "@radix-ui/react-context", - "@radix-ui/react-primitive", - "@radix-ui/react-slot", + "@radix-ui/react-primitive@2.1.0_@types+react@19.1.2_@types+react-dom@19.1.3__@types+react@19.1.2_react@19.1.0_react-dom@19.1.0__react@19.1.0", + "@radix-ui/react-slot@1.2.0_@types+react@19.1.2_react@19.1.0", + "@types/react", + "@types/react-dom", + "react", + "react-dom" + ], + "optionalPeers": [ + "@types/react", + "@types/react-dom" + ] + }, + "@radix-ui/react-collection@1.1.7_@types+react@19.1.2_@types+react-dom@19.1.3__@types+react@19.1.2_react@19.1.0_react-dom@19.1.0__react@19.1.0": { + "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==", + "dependencies": [ + "@radix-ui/react-compose-refs", + "@radix-ui/react-context", + "@radix-ui/react-primitive@2.1.3_@types+react@19.1.2_@types+react-dom@19.1.3__@types+react@19.1.2_react@19.1.0_react-dom@19.1.0__react@19.1.0", + "@radix-ui/react-slot@1.2.3_@types+react@19.1.2_react@19.1.0", "@types/react", "@types/react-dom", "react", @@ -1339,8 +1379,8 @@ "@radix-ui/react-id", "@radix-ui/react-portal", "@radix-ui/react-presence", - "@radix-ui/react-primitive", - "@radix-ui/react-slot", + "@radix-ui/react-primitive@2.1.0_@types+react@19.1.2_@types+react-dom@19.1.3__@types+react@19.1.2_react@19.1.0_react-dom@19.1.0__react@19.1.0", + "@radix-ui/react-slot@1.2.0_@types+react@19.1.2_react@19.1.0", "@radix-ui/react-use-controllable-state", "@types/react", "@types/react-dom", @@ -1369,7 +1409,7 @@ "dependencies": [ "@radix-ui/primitive", "@radix-ui/react-compose-refs", - "@radix-ui/react-primitive", + "@radix-ui/react-primitive@2.1.0_@types+react@19.1.2_@types+react-dom@19.1.3__@types+react@19.1.2_react@19.1.0_react-dom@19.1.0__react@19.1.0", "@radix-ui/react-use-callback-ref", "@radix-ui/react-use-escape-keydown", "@types/react", @@ -1390,7 +1430,7 @@ "@radix-ui/react-context", "@radix-ui/react-id", "@radix-ui/react-menu", - "@radix-ui/react-primitive", + "@radix-ui/react-primitive@2.1.0_@types+react@19.1.2_@types+react-dom@19.1.3__@types+react@19.1.2_react@19.1.0_react-dom@19.1.0__react@19.1.0", "@radix-ui/react-use-controllable-state", "@types/react", "@types/react-dom", @@ -1416,7 +1456,7 @@ "integrity": "sha512-r2annK27lIW5w9Ho5NyQgqs0MmgZSTIKXWpVCJaLC1q2kZrZkcqnmHkCHMEmv8XLvsLlurKMPT+kbKkRkm/xVA==", "dependencies": [ "@radix-ui/react-compose-refs", - "@radix-ui/react-primitive", + "@radix-ui/react-primitive@2.1.0_@types+react@19.1.2_@types+react-dom@19.1.3__@types+react@19.1.2_react@19.1.0_react-dom@19.1.0__react@19.1.0", "@radix-ui/react-use-callback-ref", "@types/react", "@types/react-dom", @@ -1442,7 +1482,7 @@ "@radix-ui/react-label@2.1.4_@types+react@19.1.2_@types+react-dom@19.1.3__@types+react@19.1.2_react@19.1.0_react-dom@19.1.0__react@19.1.0": { "integrity": "sha512-wy3dqizZnZVV4ja0FNnUhIWNwWdoldXrneEyUcVtLYDAt8ovGS4ridtMAOGgXBBIfggL4BOveVWsjXDORdGEQg==", "dependencies": [ - "@radix-ui/react-primitive", + "@radix-ui/react-primitive@2.1.0_@types+react@19.1.2_@types+react-dom@19.1.3__@types+react@19.1.2_react@19.1.0_react-dom@19.1.0__react@19.1.0", "@types/react", "@types/react-dom", "react", @@ -1457,7 +1497,7 @@ "integrity": "sha512-+qYq6LfbiGo97Zz9fioX83HCiIYYFNs8zAsVCMQrIakoNYylIzWuoD/anAD3UzvvR6cnswmfRFJFq/zYYq/k7Q==", "dependencies": [ "@radix-ui/primitive", - "@radix-ui/react-collection", + "@radix-ui/react-collection@1.1.4_@types+react@19.1.2_@types+react-dom@19.1.3__@types+react@19.1.2_react@19.1.0_react-dom@19.1.0__react@19.1.0", "@radix-ui/react-compose-refs", "@radix-ui/react-context", "@radix-ui/react-direction", @@ -1468,9 +1508,9 @@ "@radix-ui/react-popper", "@radix-ui/react-portal", "@radix-ui/react-presence", - "@radix-ui/react-primitive", - "@radix-ui/react-roving-focus", - "@radix-ui/react-slot", + "@radix-ui/react-primitive@2.1.0_@types+react@19.1.2_@types+react-dom@19.1.3__@types+react@19.1.2_react@19.1.0_react-dom@19.1.0__react@19.1.0", + "@radix-ui/react-roving-focus@1.1.7_@types+react@19.1.2_@types+react-dom@19.1.3__@types+react@19.1.2_react@19.1.0_react-dom@19.1.0__react@19.1.0", + "@radix-ui/react-slot@1.2.0_@types+react@19.1.2_react@19.1.0", "@radix-ui/react-use-callback-ref", "@types/react", "@types/react-dom", @@ -1488,14 +1528,14 @@ "integrity": "sha512-bM2vT5nxRqJH/d1vFQ9jLsW4qR70yFQw2ZD1TUPWUNskDsV0eYeMbbNJqxNjGMOVogEkOJaHtu11kzYdTJvVJg==", "dependencies": [ "@radix-ui/primitive", - "@radix-ui/react-collection", + "@radix-ui/react-collection@1.1.4_@types+react@19.1.2_@types+react-dom@19.1.3__@types+react@19.1.2_react@19.1.0_react-dom@19.1.0__react@19.1.0", "@radix-ui/react-compose-refs", "@radix-ui/react-context", "@radix-ui/react-direction", "@radix-ui/react-id", "@radix-ui/react-menu", - "@radix-ui/react-primitive", - "@radix-ui/react-roving-focus", + "@radix-ui/react-primitive@2.1.0_@types+react@19.1.2_@types+react-dom@19.1.3__@types+react@19.1.2_react@19.1.0_react-dom@19.1.0__react@19.1.0", + "@radix-ui/react-roving-focus@1.1.7_@types+react@19.1.2_@types+react-dom@19.1.3__@types+react@19.1.2_react@19.1.0_react-dom@19.1.0__react@19.1.0", "@radix-ui/react-use-controllable-state", "@types/react", "@types/react-dom", @@ -1520,8 +1560,8 @@ "@radix-ui/react-popper", "@radix-ui/react-portal", "@radix-ui/react-presence", - "@radix-ui/react-primitive", - "@radix-ui/react-slot", + "@radix-ui/react-primitive@2.1.0_@types+react@19.1.2_@types+react-dom@19.1.3__@types+react@19.1.2_react@19.1.0_react-dom@19.1.0__react@19.1.0", + "@radix-ui/react-slot@1.2.0_@types+react@19.1.2_react@19.1.0", "@radix-ui/react-use-controllable-state", "@types/react", "@types/react-dom", @@ -1542,7 +1582,7 @@ "@radix-ui/react-arrow", "@radix-ui/react-compose-refs", "@radix-ui/react-context", - "@radix-ui/react-primitive", + "@radix-ui/react-primitive@2.1.0_@types+react@19.1.2_@types+react-dom@19.1.3__@types+react@19.1.2_react@19.1.0_react-dom@19.1.0__react@19.1.0", "@radix-ui/react-use-callback-ref", "@radix-ui/react-use-layout-effect", "@radix-ui/react-use-rect", @@ -1561,7 +1601,7 @@ "@radix-ui/react-portal@1.1.6_@types+react@19.1.2_@types+react-dom@19.1.3__@types+react@19.1.2_react@19.1.0_react-dom@19.1.0__react@19.1.0": { "integrity": "sha512-XmsIl2z1n/TsYFLIdYam2rmFwf9OC/Sh2avkbmVMDuBZIe7hSpM0cYnWPAo7nHOVx8zTuwDZGByfcqLdnzp3Vw==", "dependencies": [ - "@radix-ui/react-primitive", + "@radix-ui/react-primitive@2.1.0_@types+react@19.1.2_@types+react-dom@19.1.3__@types+react@19.1.2_react@19.1.0_react-dom@19.1.0__react@19.1.0", "@radix-ui/react-use-layout-effect", "@types/react", "@types/react-dom", @@ -1591,7 +1631,43 @@ "@radix-ui/react-primitive@2.1.0_@types+react@19.1.2_@types+react-dom@19.1.3__@types+react@19.1.2_react@19.1.0_react-dom@19.1.0__react@19.1.0": { "integrity": "sha512-/J/FhLdK0zVcILOwt5g+dH4KnkonCtkVJsa2G6JmvbbtZfBEI1gMsO3QMjseL4F/SwfAMt1Vc/0XKYKq+xJ1sw==", "dependencies": [ - "@radix-ui/react-slot", + "@radix-ui/react-slot@1.2.0_@types+react@19.1.2_react@19.1.0", + "@types/react", + "@types/react-dom", + "react", + "react-dom" + ], + "optionalPeers": [ + "@types/react", + "@types/react-dom" + ] + }, + "@radix-ui/react-primitive@2.1.3_@types+react@19.1.2_@types+react-dom@19.1.3__@types+react@19.1.2_react@19.1.0_react-dom@19.1.0__react@19.1.0": { + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "dependencies": [ + "@radix-ui/react-slot@1.2.3_@types+react@19.1.2_react@19.1.0", + "@types/react", + "@types/react-dom", + "react", + "react-dom" + ], + "optionalPeers": [ + "@types/react", + "@types/react-dom" + ] + }, + "@radix-ui/react-roving-focus@1.1.10_@types+react@19.1.2_@types+react-dom@19.1.3__@types+react@19.1.2_react@19.1.0_react-dom@19.1.0__react@19.1.0": { + "integrity": "sha512-dT9aOXUen9JSsxnMPv/0VqySQf5eDQ6LCk5Sw28kamz8wSOW2bJdlX2Bg5VUIIcV+6XlHpWTIuTPCf/UNIyq8Q==", + "dependencies": [ + "@radix-ui/primitive", + "@radix-ui/react-collection@1.1.7_@types+react@19.1.2_@types+react-dom@19.1.3__@types+react@19.1.2_react@19.1.0_react-dom@19.1.0__react@19.1.0", + "@radix-ui/react-compose-refs", + "@radix-ui/react-context", + "@radix-ui/react-direction", + "@radix-ui/react-id", + "@radix-ui/react-primitive@2.1.3_@types+react@19.1.2_@types+react-dom@19.1.3__@types+react@19.1.2_react@19.1.0_react-dom@19.1.0__react@19.1.0", + "@radix-ui/react-use-callback-ref", + "@radix-ui/react-use-controllable-state", "@types/react", "@types/react-dom", "react", @@ -1606,12 +1682,12 @@ "integrity": "sha512-C6oAg451/fQT3EGbWHbCQjYTtbyjNO1uzQgMzwyivcHT3GKNEmu1q3UuREhN+HzHAVtv3ivMVK08QlC+PkYw9Q==", "dependencies": [ "@radix-ui/primitive", - "@radix-ui/react-collection", + "@radix-ui/react-collection@1.1.4_@types+react@19.1.2_@types+react-dom@19.1.3__@types+react@19.1.2_react@19.1.0_react-dom@19.1.0__react@19.1.0", "@radix-ui/react-compose-refs", "@radix-ui/react-context", "@radix-ui/react-direction", "@radix-ui/react-id", - "@radix-ui/react-primitive", + "@radix-ui/react-primitive@2.1.0_@types+react@19.1.2_@types+react-dom@19.1.3__@types+react@19.1.2_react@19.1.0_react-dom@19.1.0__react@19.1.0", "@radix-ui/react-use-callback-ref", "@radix-ui/react-use-controllable-state", "@types/react", @@ -1633,7 +1709,7 @@ "@radix-ui/react-context", "@radix-ui/react-direction", "@radix-ui/react-presence", - "@radix-ui/react-primitive", + "@radix-ui/react-primitive@2.1.0_@types+react@19.1.2_@types+react-dom@19.1.3__@types+react@19.1.2_react@19.1.0_react-dom@19.1.0__react@19.1.0", "@radix-ui/react-use-callback-ref", "@radix-ui/react-use-layout-effect", "@types/react", @@ -1651,7 +1727,7 @@ "dependencies": [ "@radix-ui/number", "@radix-ui/primitive", - "@radix-ui/react-collection", + "@radix-ui/react-collection@1.1.4_@types+react@19.1.2_@types+react-dom@19.1.3__@types+react@19.1.2_react@19.1.0_react-dom@19.1.0__react@19.1.0", "@radix-ui/react-compose-refs", "@radix-ui/react-context", "@radix-ui/react-direction", @@ -1661,8 +1737,8 @@ "@radix-ui/react-id", "@radix-ui/react-popper", "@radix-ui/react-portal", - "@radix-ui/react-primitive", - "@radix-ui/react-slot", + "@radix-ui/react-primitive@2.1.0_@types+react@19.1.2_@types+react-dom@19.1.3__@types+react@19.1.2_react@19.1.0_react-dom@19.1.0__react@19.1.0", + "@radix-ui/react-slot@1.2.0_@types+react@19.1.2_react@19.1.0", "@radix-ui/react-use-callback-ref", "@radix-ui/react-use-controllable-state", "@radix-ui/react-use-layout-effect", @@ -1683,7 +1759,7 @@ "@radix-ui/react-separator@1.1.4_@types+react@19.1.2_@types+react-dom@19.1.3__@types+react@19.1.2_react@19.1.0_react-dom@19.1.0__react@19.1.0": { "integrity": "sha512-2fTm6PSiUm8YPq9W0E4reYuv01EE3aFSzt8edBiXqPHshF8N9+Kymt/k0/R+F3dkY5lQyB/zPtrP82phskLi7w==", "dependencies": [ - "@radix-ui/react-primitive", + "@radix-ui/react-primitive@2.1.0_@types+react@19.1.2_@types+react-dom@19.1.3__@types+react@19.1.2_react@19.1.0_react-dom@19.1.0__react@19.1.0", "@types/react", "@types/react-dom", "react", @@ -1699,11 +1775,11 @@ "dependencies": [ "@radix-ui/number", "@radix-ui/primitive", - "@radix-ui/react-collection", + "@radix-ui/react-collection@1.1.4_@types+react@19.1.2_@types+react-dom@19.1.3__@types+react@19.1.2_react@19.1.0_react-dom@19.1.0__react@19.1.0", "@radix-ui/react-compose-refs", "@radix-ui/react-context", "@radix-ui/react-direction", - "@radix-ui/react-primitive", + "@radix-ui/react-primitive@2.1.0_@types+react@19.1.2_@types+react-dom@19.1.3__@types+react@19.1.2_react@19.1.0_react-dom@19.1.0__react@19.1.0", "@radix-ui/react-use-controllable-state", "@radix-ui/react-use-layout-effect", "@radix-ui/react-use-previous", @@ -1729,13 +1805,24 @@ "@types/react" ] }, + "@radix-ui/react-slot@1.2.3_@types+react@19.1.2_react@19.1.0": { + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "dependencies": [ + "@radix-ui/react-compose-refs", + "@types/react", + "react" + ], + "optionalPeers": [ + "@types/react" + ] + }, "@radix-ui/react-switch@1.2.2_@types+react@19.1.2_@types+react-dom@19.1.3__@types+react@19.1.2_react@19.1.0_react-dom@19.1.0__react@19.1.0": { "integrity": "sha512-7Z8n6L+ifMIIYZ83f28qWSceUpkXuslI2FJ34+kDMTiyj91ENdpdQ7VCidrzj5JfwfZTeano/BnGBbu/jqa5rQ==", "dependencies": [ "@radix-ui/primitive", "@radix-ui/react-compose-refs", "@radix-ui/react-context", - "@radix-ui/react-primitive", + "@radix-ui/react-primitive@2.1.0_@types+react@19.1.2_@types+react-dom@19.1.3__@types+react@19.1.2_react@19.1.0_react-dom@19.1.0__react@19.1.0", "@radix-ui/react-use-controllable-state", "@radix-ui/react-use-previous", "@radix-ui/react-use-size", @@ -1757,8 +1844,8 @@ "@radix-ui/react-direction", "@radix-ui/react-id", "@radix-ui/react-presence", - "@radix-ui/react-primitive", - "@radix-ui/react-roving-focus", + "@radix-ui/react-primitive@2.1.0_@types+react@19.1.2_@types+react-dom@19.1.3__@types+react@19.1.2_react@19.1.0_react-dom@19.1.0__react@19.1.0", + "@radix-ui/react-roving-focus@1.1.7_@types+react@19.1.2_@types+react-dom@19.1.3__@types+react@19.1.2_react@19.1.0_react-dom@19.1.0__react@19.1.0", "@radix-ui/react-use-controllable-state", "@types/react", "@types/react-dom", @@ -1774,13 +1861,13 @@ "integrity": "sha512-Ed2mlOmT+tktOsu2NZBK1bCSHh/uqULu1vWOkpQTVq53EoOuZUZw7FInQoDB3uil5wZc2oe0XN9a7uVZB7/6AQ==", "dependencies": [ "@radix-ui/primitive", - "@radix-ui/react-collection", + "@radix-ui/react-collection@1.1.4_@types+react@19.1.2_@types+react-dom@19.1.3__@types+react@19.1.2_react@19.1.0_react-dom@19.1.0__react@19.1.0", "@radix-ui/react-compose-refs", "@radix-ui/react-context", "@radix-ui/react-dismissable-layer", "@radix-ui/react-portal", "@radix-ui/react-presence", - "@radix-ui/react-primitive", + "@radix-ui/react-primitive@2.1.0_@types+react@19.1.2_@types+react-dom@19.1.3__@types+react@19.1.2_react@19.1.0_react-dom@19.1.0__react@19.1.0", "@radix-ui/react-use-callback-ref", "@radix-ui/react-use-controllable-state", "@radix-ui/react-use-layout-effect", @@ -1795,6 +1882,42 @@ "@types/react-dom" ] }, + "@radix-ui/react-toggle-group@1.1.10_@types+react@19.1.2_@types+react-dom@19.1.3__@types+react@19.1.2_react@19.1.0_react-dom@19.1.0__react@19.1.0": { + "integrity": "sha512-kiU694Km3WFLTC75DdqgM/3Jauf3rD9wxeS9XtyWFKsBUeZA337lC+6uUazT7I1DhanZ5gyD5Stf8uf2dbQxOQ==", + "dependencies": [ + "@radix-ui/primitive", + "@radix-ui/react-context", + "@radix-ui/react-direction", + "@radix-ui/react-primitive@2.1.3_@types+react@19.1.2_@types+react-dom@19.1.3__@types+react@19.1.2_react@19.1.0_react-dom@19.1.0__react@19.1.0", + "@radix-ui/react-roving-focus@1.1.10_@types+react@19.1.2_@types+react-dom@19.1.3__@types+react@19.1.2_react@19.1.0_react-dom@19.1.0__react@19.1.0", + "@radix-ui/react-toggle", + "@radix-ui/react-use-controllable-state", + "@types/react", + "@types/react-dom", + "react", + "react-dom" + ], + "optionalPeers": [ + "@types/react", + "@types/react-dom" + ] + }, + "@radix-ui/react-toggle@1.1.9_@types+react@19.1.2_@types+react-dom@19.1.3__@types+react@19.1.2_react@19.1.0_react-dom@19.1.0__react@19.1.0": { + "integrity": "sha512-ZoFkBBz9zv9GWer7wIjvdRxmh2wyc2oKWw6C6CseWd6/yq1DK/l5lJ+wnsmFwJZbBYqr02mrf8A2q/CVCuM3ZA==", + "dependencies": [ + "@radix-ui/primitive", + "@radix-ui/react-primitive@2.1.3_@types+react@19.1.2_@types+react-dom@19.1.3__@types+react@19.1.2_react@19.1.0_react-dom@19.1.0__react@19.1.0", + "@radix-ui/react-use-controllable-state", + "@types/react", + "@types/react-dom", + "react", + "react-dom" + ], + "optionalPeers": [ + "@types/react", + "@types/react-dom" + ] + }, "@radix-ui/react-tooltip@1.2.4_@types+react@19.1.2_@types+react-dom@19.1.3__@types+react@19.1.2_react@19.1.0_react-dom@19.1.0__react@19.1.0": { "integrity": "sha512-DyW8VVeeMSSLFvAmnVnCwvI3H+1tpJFHT50r+tdOoMse9XqYDBCcyux8u3G2y+LOpt7fPQ6KKH0mhs+ce1+Z5w==", "dependencies": [ @@ -1806,8 +1929,8 @@ "@radix-ui/react-popper", "@radix-ui/react-portal", "@radix-ui/react-presence", - "@radix-ui/react-primitive", - "@radix-ui/react-slot", + "@radix-ui/react-primitive@2.1.0_@types+react@19.1.2_@types+react-dom@19.1.3__@types+react@19.1.2_react@19.1.0_react-dom@19.1.0__react@19.1.0", + "@radix-ui/react-slot@1.2.0_@types+react@19.1.2_react@19.1.0", "@radix-ui/react-use-controllable-state", "@radix-ui/react-visually-hidden", "@types/react", @@ -1909,7 +2032,7 @@ "@radix-ui/react-visually-hidden@1.2.0_@types+react@19.1.2_@types+react-dom@19.1.3__@types+react@19.1.2_react@19.1.0_react-dom@19.1.0__react@19.1.0": { "integrity": "sha512-rQj0aAWOpCdCMRbI6pLQm8r7S2BM3YhTa0SzOYD55k+hJA8oo9J+H+9wLM9oMlZWOX/wJWPTzfDfmZkf7LvCfg==", "dependencies": [ - "@radix-ui/react-primitive", + "@radix-ui/react-primitive@2.1.0_@types+react@19.1.2_@types+react-dom@19.1.3__@types+react@19.1.2_react@19.1.0_react-dom@19.1.0__react@19.1.0", "@types/react", "@types/react-dom", "react", @@ -3838,6 +3961,9 @@ "ansi-styles@5.2.0": { "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==" }, + "argparse@2.0.1": { + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" + }, "aria-hidden@1.2.4": { "integrity": "sha512-y+CcFFwelSXpLZk/7fMB2mUbGtX9lKycf1MWJ7CaTIERyitVlyQx6C+sxcROU2BAJ24OiZyK+8wj2i8AlBoS3A==", "dependencies": [ @@ -3979,6 +4105,12 @@ "balanced-match" ] }, + "braces@3.0.3": { + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dependencies": [ + "fill-range" + ] + }, "brorand@1.1.0": { "integrity": "sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w==" }, @@ -4071,6 +4203,13 @@ "builtin-status-codes@3.0.0": { "integrity": "sha512-HpGFw18DgFWlncDfjTa2rcQ4W88O1mC8e8yZ2AvQY5KDaktSTwo+KRf6nHK6FRI5FyRyb/5T6+TSxfP7QyGsmQ==" }, + "bundle-require@5.1.0_esbuild@0.25.3": { + "integrity": "sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==", + "dependencies": [ + "esbuild", + "load-tsconfig" + ] + }, "bytewise-core@1.2.3": { "integrity": "sha512-nZD//kc78OOxeYtRlVk8/zXqTB4gf/nlguL1ggWA8FuchMyOxcyHR4QPQZMUmA7czC+YnaBrPUCubqAWe50DaA==", "dependencies": [ @@ -4173,7 +4312,7 @@ "@radix-ui/react-compose-refs", "@radix-ui/react-dialog", "@radix-ui/react-id", - "@radix-ui/react-primitive", + "@radix-ui/react-primitive@2.1.0_@types+react@19.1.2_@types+react-dom@19.1.3__@types+react@19.1.2_react@19.1.0_react-dom@19.1.0__react@19.1.0", "react", "react-dom" ] @@ -4217,6 +4356,9 @@ "convert-source-map@2.0.0": { "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==" }, + "cookie@1.0.2": { + "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==" + }, "core-js-compat@3.42.0": { "integrity": "sha512-bQasjMfyDGyaeWKBIu33lHh9qlSR0MFE/Nmc6nMjf/iU9b3rSMdAYz1Baxrv4lPdGUsTqZudHA4jIGSJy0SWZQ==", "dependencies": [ @@ -4260,6 +4402,12 @@ "create-require@1.1.1": { "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==" }, + "cross-fetch@4.0.0": { + "integrity": "sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==", + "dependencies": [ + "node-fetch" + ] + }, "crypto-browserify@3.12.1": { "integrity": "sha512-r4ESw/IlusD17lgQi1O20Fa3qNnsckR126TdUuBgAu7GBYSIPvdNyONd3Zrxh0xCwA4+6w/TDArBPsMvhur+KQ==", "dependencies": [ @@ -4337,6 +4485,9 @@ "deep-eql@5.0.2": { "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==" }, + "deep-object-diff@1.1.9": { + "integrity": "sha512-Rn+RuwkmkDwCi2/oXOFS9Gsr5lJZu/yTGpK7wAaAIE75CC+LCGEZHpY6VQJa/RoJcrmaA/docWJZvYohlNkWPA==" + }, "deepmerge@4.3.1": { "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==" }, @@ -4617,12 +4768,28 @@ "fast-deep-equal@3.1.3": { "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" }, + "fast-glob@3.3.3": { + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dependencies": [ + "@nodelib/fs.stat", + "@nodelib/fs.walk", + "glob-parent", + "merge2", + "micromatch" + ] + }, "fast-json-stable-stringify@2.1.0": { "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==" }, "fast-uri@3.0.6": { "integrity": "sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==" }, + "fastq@1.19.1": { + "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "dependencies": [ + "reusify" + ] + }, "fdir@6.4.4_picomatch@4.0.2": { "integrity": "sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==", "dependencies": [ @@ -4638,11 +4805,25 @@ "minimatch@5.1.6" ] }, + "fill-range@7.1.1": { + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dependencies": [ + "to-regex-range" + ] + }, "find-up@5.0.0": { "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", "dependencies": [ - "locate-path", - "path-exists" + "locate-path@6.0.0", + "path-exists@4.0.0" + ] + }, + "find-up@7.0.0": { + "integrity": "sha512-YyZM99iHrqLKjmt4LJDj58KI+fYyufRLBSYcqycxf//KpBk9FoewoGX0450m9nB44qrZnovzC2oeP5hUibxc/g==", + "dependencies": [ + "locate-path@7.2.0", + "path-exists@5.0.0", + "unicorn-magic" ] }, "for-each@0.3.5": { @@ -4745,12 +4926,24 @@ "get-intrinsic" ] }, + "get-tsconfig@4.10.1": { + "integrity": "sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ==", + "dependencies": [ + "resolve-pkg-maps" + ] + }, "get-value@2.0.6": { "integrity": "sha512-Ln0UQDlxH1BapMu3GPtf7CuYNwRZf2gwCuPqbyG6pB8WfmFpzqcy4xtAaAMUhnNqjMKTiCPZG2oMT3YSx8U2NA==" }, "gl-matrix@3.4.3": { "integrity": "sha512-wcCp8vu8FT22BnvKVPjXa/ICBWRq/zjFfdofZy1WSpQZpphblv12/bOQLBC1rMM7SGOFS9ltVmKOHil5+Ml7gA==" }, + "glob-parent@5.1.2": { + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dependencies": [ + "is-glob" + ] + }, "glob@7.2.3": { "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", "dependencies": [ @@ -4858,9 +5051,37 @@ "minimalistic-crypto-utils" ] }, + "html-parse-stringify@3.0.1": { + "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", + "dependencies": [ + "void-elements" + ] + }, "https-browserify@1.0.0": { "integrity": "sha512-J+FkSdyD+0mA0N+81tMotaRMfSL9SGi+xpD3T6YApKsc3bGSXJlfXri3VyFOeYkfLRQisDk1W+jIFFKBeUBbBg==" }, + "i18next-browser-languagedetector@8.1.0": { + "integrity": "sha512-mHZxNx1Lq09xt5kCauZ/4bsXOEA2pfpwSoU11/QTJB+pD94iONFwp+ohqi///PwiFvjFOxe1akYCdHyFo1ng5Q==", + "dependencies": [ + "@babel/runtime" + ] + }, + "i18next-http-backend@3.0.2": { + "integrity": "sha512-PdlvPnvIp4E1sYi46Ik4tBYh/v/NbYfFFgTjkwFl0is8A18s7/bx9aXqsrOax9WUbeNS6mD2oix7Z0yGGf6m5g==", + "dependencies": [ + "cross-fetch" + ] + }, + "i18next@25.2.0_typescript@5.8.3": { + "integrity": "sha512-ERhJICsxkw1vE7G0lhCUYv4ZxdBEs03qblt1myJs94rYRK9loJF3xDj8mgQz3LmCyp0yYrNjbN/1/GWZTZDGCA==", + "dependencies": [ + "@babel/runtime", + "typescript" + ], + "optionalPeers": [ + "typescript" + ] + }, "idb-keyval@6.2.1": { "integrity": "sha512-8Sb3veuYCyrZL+VBt9LJfZjLUPWVvqn8tG28VqYNFCo43KHcKuq+b4EiXGeuaLAQWL2YmyDgMp2aSpH9JHsEQg==" }, @@ -4873,6 +5094,17 @@ "immer@10.1.1": { "integrity": "sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==" }, + "importx@0.5.2_esbuild@0.25.3": { + "integrity": "sha512-YEwlK86Ml5WiTxN/ECUYC5U7jd1CisAVw7ya4i9ZppBoHfFkT2+hChhr3PE2fYxUKLkNyivxEQpa5Ruil1LJBQ==", + "dependencies": [ + "bundle-require", + "debug", + "esbuild", + "jiti", + "pathe", + "tsx" + ] + }, "indent-string@4.0.0": { "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==" }, @@ -4969,6 +5201,9 @@ "is-plain-object" ] }, + "is-extglob@2.1.1": { + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==" + }, "is-finalizationregistry@1.1.1": { "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", "dependencies": [ @@ -4984,6 +5219,12 @@ "safe-regex-test" ] }, + "is-glob@4.0.3": { + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dependencies": [ + "is-extglob" + ] + }, "is-map@2.0.3": { "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==" }, @@ -5004,6 +5245,9 @@ "has-tostringtag" ] }, + "is-number@7.0.0": { + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==" + }, "is-obj@1.0.1": { "integrity": "sha512-l4RyHgRqGN4Y3+9JHVrNqO+tN0rV5My76uW5/nuO4K1b6vw5G8d/cmFjP9tRfEsdhZNt0IFdZuK/c2Vr4Nb+Qg==" }, @@ -5112,6 +5356,13 @@ "js-tokens@4.0.0": { "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" }, + "js-yaml@4.1.0": { + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dependencies": [ + "argparse" + ], + "bin": true + }, "jsesc@3.0.2": { "integrity": "sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==", "bin": true @@ -5157,6 +5408,15 @@ "kind-of@6.0.3": { "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==" }, + "language-subtag-registry@0.3.23": { + "integrity": "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==" + }, + "language-tags@2.1.0": { + "integrity": "sha512-D4CgpyCt+61f6z2jHjJS1OmZPviAWM57iJ9OKdFFWSNgS7Udj9QVWqyGs/cveVNF57XpZmhSvMdVIV5mjLA7Vg==", + "dependencies": [ + "language-subtag-registry" + ] + }, "leven@3.1.0": { "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==" }, @@ -5231,10 +5491,19 @@ "lightningcss-win32-x64-msvc" ] }, + "load-tsconfig@0.2.5": { + "integrity": "sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==" + }, "locate-path@6.0.0": { "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", "dependencies": [ - "p-locate" + "p-locate@5.0.0" + ] + }, + "locate-path@7.2.0": { + "integrity": "sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA==", + "dependencies": [ + "p-locate@6.0.0" ] }, "lodash.debounce@4.0.8": { @@ -5326,6 +5595,16 @@ "safe-buffer@5.2.1" ] }, + "merge2@1.4.1": { + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==" + }, + "micromatch@4.0.8": { + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dependencies": [ + "braces", + "picomatch@2.3.1" + ] + }, "miller-rabin@4.0.1": { "integrity": "sha512-115fLhvZVqWwHPbClyntxEVfVDfl9DLLTuJvq3g2O/Oxi8AiNouAHvDSzHS0viUJc+V5vm3eq91Xwqn9dp4jRA==", "dependencies": [ @@ -5381,6 +5660,15 @@ "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", "bin": true }, + "negotiator@1.0.0": { + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==" + }, + "node-fetch@2.7.0": { + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dependencies": [ + "whatwg-url@5.0.0" + ] + }, "node-releases@2.0.19": { "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==" }, @@ -5463,13 +5751,25 @@ "p-limit@3.1.0": { "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", "dependencies": [ - "yocto-queue" + "yocto-queue@0.1.0" + ] + }, + "p-limit@4.0.0": { + "integrity": "sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==", + "dependencies": [ + "yocto-queue@1.2.1" ] }, "p-locate@5.0.0": { "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", "dependencies": [ - "p-limit" + "p-limit@3.1.0" + ] + }, + "p-locate@6.0.0": { + "integrity": "sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==", + "dependencies": [ + "p-limit@4.0.0" ] }, "pako@1.0.11": { @@ -5492,6 +5792,9 @@ "path-exists@4.0.0": { "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==" }, + "path-exists@5.0.0": { + "integrity": "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==" + }, "path-is-absolute@1.0.1": { "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==" }, @@ -5542,7 +5845,7 @@ "pkg-dir@5.0.0": { "integrity": "sha512-NPE8TDbzl/3YQYY7CSS228s3g2ollTFnc+Qi3tqmqJp9Vg2ovUpixcJEo2HJScN2Ez+kEaal6y70c0ehqJBJeA==", "dependencies": [ - "find-up" + "find-up@5.0.0" ] }, "point-in-polygon-hao@1.2.4": { @@ -5636,6 +5939,9 @@ "querystring-es3@0.2.1": { "integrity": "sha512-773xhDQnZBMFobEiztv8LIl70ch5MSF/jUQVlhwFyBILqq96anmoctVIYz+ZRp0qbCKATTn6ev02M3r7Ga5vqA==" }, + "queue-microtask@1.2.3": { + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==" + }, "quickselect@1.1.1": { "integrity": "sha512-qN0Gqdw4c4KGPsBOQafj6yj/PA6c/L63f6CaZ/DCF/xF4Esu3jVmKLUDYxghFx8Kb/O7y9tI7x2RjTSXwdK1iQ==" }, @@ -5690,6 +5996,19 @@ "react" ] }, + "react-i18next@15.5.1_i18next@25.2.0__typescript@5.8.3_react@19.1.0_typescript@5.8.3": { + "integrity": "sha512-C8RZ7N7H0L+flitiX6ASjq9p5puVJU1Z8VyL3OgM/QOMRf40BMZX+5TkpxzZVcTmOLPX5zlti4InEX5pFyiVeA==", + "dependencies": [ + "@babel/runtime", + "html-parse-stringify", + "i18next", + "react", + "typescript" + ], + "optionalPeers": [ + "typescript" + ] + }, "react-is@17.0.2": { "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==" }, @@ -5844,6 +6163,9 @@ "require-from-string@2.0.2": { "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==" }, + "resolve-pkg-maps@1.0.0": { + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==" + }, "resolve-protobuf-schema@2.1.0": { "integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==", "dependencies": [ @@ -5859,6 +6181,9 @@ ], "bin": true }, + "reusify@1.1.0": { + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==" + }, "rfc4648@1.5.4": { "integrity": "sha512-rRg/6Lb+IGfJqO05HZkN50UtY7K/JhxJag1kP23+zyMfrvoB0B7RWv06MbOzoc79RgCdNTiUaNsTT1AJZ7Z+cg==" }, @@ -5912,6 +6237,12 @@ ], "bin": true }, + "run-parallel@1.2.0": { + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dependencies": [ + "queue-microtask" + ] + }, "rw@1.3.3": { "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==" }, @@ -6105,7 +6436,7 @@ "source-map@0.8.0-beta.0": { "integrity": "sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==", "dependencies": [ - "whatwg-url" + "whatwg-url@7.1.0" ] }, "sourcemap-codec@1.4.8": { @@ -6355,6 +6686,12 @@ "tinyspy@3.0.2": { "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==" }, + "to-regex-range@5.0.1": { + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dependencies": [ + "is-number" + ] + }, "topojson-client@3.1.0": { "integrity": "sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw==", "dependencies": [ @@ -6369,6 +6706,9 @@ ], "bin": true }, + "tr46@0.0.3": { + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" + }, "tr46@1.0.1": { "integrity": "sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==", "dependencies": [ @@ -6384,6 +6724,17 @@ "tslog@4.9.3": { "integrity": "sha512-oDWuGVONxhVEBtschLf2cs/Jy8i7h1T+CpdkTNWQgdAF7DhRo2G8vMCgILKe7ojdEkLhICWgI1LYSSKaJsRgcw==" }, + "tsx@4.19.4": { + "integrity": "sha512-gK5GVzDkJK1SI1zwHf32Mqxf2tSJkNx+eYcNly5+nHvWqXUJYUkWBQtKauoESz3ymezAI++ZwT855x5p5eop+Q==", + "dependencies": [ + "esbuild", + "get-tsconfig" + ], + "optionalDependencies": [ + "fsevents" + ], + "bin": true + }, "tty-browserify@0.0.1": { "integrity": "sha512-C3TaO7K81YvjCgQH9Q1S3R3P3BtN3RIM8n+OvX4il1K1zgE8ZhI0op7kClgkxtutIE8hQrcrHBXvIheqKUUCxw==" }, @@ -6475,6 +6826,9 @@ "unicode-property-aliases-ecmascript@2.1.0": { "integrity": "sha512-6t3foTQI9qne+OZoVQB/8x8rk2k1eVy1gRXhV3oFQ5T6R1dqQ1xtin3XqSlx3+ATBkliTaR/hHyJBm+LVPNM8w==" }, + "unicorn-magic@0.1.0": { + "integrity": "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==" + }, "union-value@1.0.1": { "integrity": "sha512-tJfXmxMeWYnczCVs7XAEvIV7ieppALdyepWMkHkwciRpZraG/xwT+s2JN8+pr1+8jCRf80FFzvr+MpQeeoF4Xg==", "dependencies": [ @@ -6562,6 +6916,23 @@ ], "bin": true }, + "vite-plugin-i18n-ally@6.0.1_vite@6.3.4__@types+node@22.15.3__picomatch@4.0.2_@types+node@22.15.3": { + "integrity": "sha512-BmXlAkrmSRrbaho7iJpBf1d2EPyDK2oqY1AKVxkJEUikGDPducFpLfpmlqTyUNhsWZT01ZLWdjR2uIRnnVJXzw==", + "dependencies": [ + "cookie", + "debug", + "deep-object-diff", + "fast-glob", + "find-up@7.0.0", + "importx", + "js-yaml", + "json5", + "language-tags", + "negotiator", + "picocolors", + "vite" + ] + }, "vite-plugin-node-polyfills@0.23.0_vite@6.3.4__@types+node@22.15.3__picomatch@4.0.2_@types+node@22.15.3": { "integrity": "sha512-4n+Ys+2bKHQohPBKigFlndwWQ5fFKwaGY6muNDMTb0fSQLyBzS+jjUNRZG9sKF0S/Go4ApG6LFnUGopjkILg3w==", "dependencies": [ @@ -6636,6 +7007,9 @@ "vm-browserify@1.1.2": { "integrity": "sha512-2ham8XPWTONajOR0ohOKOHXkm3+gaBmGut3SRuu75xLd/RRaY6vqgh8NBYYk7+RW3u5AtzPQZG8F10LHkl0lAQ==" }, + "void-elements@3.1.0": { + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==" + }, "vt-pbf@3.1.3": { "integrity": "sha512-2LzDFzt0mZKZ9IpVF2r69G9bXaP2Q2sArJCmcCgvfTdCCZzSyz4aCLoQyUilu37Ll56tCblIZrXFIjNUpGIlmA==", "dependencies": [ @@ -6644,6 +7018,9 @@ "pbf" ] }, + "webidl-conversions@3.0.1": { + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" + }, "webidl-conversions@4.0.2": { "integrity": "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==" }, @@ -6653,11 +7030,18 @@ "whatwg-mimetype@3.0.0": { "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==" }, + "whatwg-url@5.0.0": { + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dependencies": [ + "tr46@0.0.3", + "webidl-conversions@3.0.1" + ] + }, "whatwg-url@7.1.0": { "integrity": "sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==", "dependencies": [ "lodash.sortby", - "tr46", + "tr46@1.0.1", "webidl-conversions@4.0.2" ] }, @@ -6880,6 +7264,9 @@ "yocto-queue@0.1.0": { "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==" }, + "yocto-queue@1.2.1": { + "integrity": "sha512-AyeEbWOu/TAXdxlV9wmGcR0+yh2j3vYPGOECcIj2S7MkrLyC7ne+oye2BKTItt0ii2PHk4cDy+95+LshzbXnGg==" + }, "zod@3.24.3": { "integrity": "sha512-HhY1oqzWCQWuUqvBFnsyrtZRhyPeR7SUGv+C4+MsisMuVfSPx8HpwWqH8tRahSlt6M3PiFAcoeFhZAqIXTxoSg==" }, @@ -6924,6 +7311,7 @@ "npm:@radix-ui/react-switch@^1.2.2", "npm:@radix-ui/react-tabs@^1.1.9", "npm:@radix-ui/react-toast@^1.2.11", + "npm:@radix-ui/react-toggle-group@^1.1.9", "npm:@radix-ui/react-tooltip@^1.2.4", "npm:@tailwindcss/postcss@^4.1.5", "npm:@testing-library/jest-dom@^6.6.3", @@ -6948,6 +7336,9 @@ "npm:crypto-random-string@5", "npm:gzipper@^8.2.1", "npm:happy-dom@^17.4.6", + "npm:i18next-browser-languagedetector@^8.1.0", + "npm:i18next-http-backend@^3.0.2", + "npm:i18next@^25.2.0", "npm:idb-keyval@^6.2.1", "npm:immer@^10.1.1", "npm:js-cookie@^3.0.5", @@ -6957,6 +7348,7 @@ "npm:react-dom@^19.1.0", "npm:react-error-boundary@6", "npm:react-hook-form@^7.56.2", + "npm:react-i18next@^15.5.1", "npm:react-map-gl@8.0.4", "npm:react-qrcode-logo@3", "npm:react@^19.1.0", @@ -6968,6 +7360,7 @@ "npm:tar@^7.4.3", "npm:testing-library@^0.0.2", "npm:typescript@^5.8.3", + "npm:vite-plugin-i18n-ally@^6.0.1", "npm:vite-plugin-node-polyfills@0.23", "npm:vite-plugin-pwa@1", "npm:vite@^6.3.4", diff --git a/package.json b/package.json index ccaa07a0..f2516d07 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,9 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "crypto-random-string": "^5.0.0", + "i18next": "^25.2.0", + "i18next-browser-languagedetector": "^8.1.0", + "i18next-http-backend": "^3.0.2", "idb-keyval": "^6.2.1", "immer": "^10.1.1", "js-cookie": "^3.0.5", @@ -73,9 +76,11 @@ "react-dom": "^19.1.0", "react-error-boundary": "^6.0.0", "react-hook-form": "^7.56.2", + "react-i18next": "^15.5.1", "react-map-gl": "8.0.4", "react-qrcode-logo": "^3.0.0", "rfc4648": "^1.5.4", + "vite-plugin-i18n-ally": "^6.0.1", "vite-plugin-node-polyfills": "^0.23.0", "zod": "^3.24.3", "zustand": "5.0.4" diff --git a/src/components/BatteryStatus.tsx b/src/components/BatteryStatus.tsx index c615feff..bc5dcc5d 100644 --- a/src/components/BatteryStatus.tsx +++ b/src/components/BatteryStatus.tsx @@ -5,6 +5,7 @@ import { BatteryMediumIcon, PlugZapIcon, } from "lucide-react"; +import { useTranslation } from "react-i18next"; import { Subtle } from "@components/UI/Typography/Subtle.tsx"; interface DeviceMetrics { @@ -23,34 +24,41 @@ interface BatteryStateConfig { text: (level: number) => string; } -const batteryStates: BatteryStateConfig[] = [ - { - condition: (level) => level > 100, - Icon: PlugZapIcon, - className: "text-gray-500", - text: () => "Plugged in", - }, - { - condition: (level) => level > 80, - Icon: BatteryFullIcon, - className: "text-green-500", - text: (level) => `${level}% charging`, - }, - { - condition: (level) => level > 20, - Icon: BatteryMediumIcon, - className: "text-yellow-500", - text: (level) => `${level}% charging`, - }, - { - condition: () => true, - Icon: BatteryLowIcon, - className: "text-red-500", - text: (level) => `${level}% charging`, - }, -]; +const getBatteryStates = ( + t: (key: string, options?: object) => string, +): BatteryStateConfig[] => { + return [ + { + condition: (level) => level > 100, + Icon: PlugZapIcon, + className: "text-gray-500", + text: () => t("common.batteryStatus.pluggedIn"), + }, + { + condition: (level) => level > 80, + Icon: BatteryFullIcon, + className: "text-green-500", + text: (level) => t("common.batteryStatus.charging", { level }), + }, + { + condition: (level) => level > 20, + Icon: BatteryMediumIcon, + className: "text-yellow-500", + text: (level) => t("common.batteryStatus.charging", { level }), + }, + { + condition: () => true, + Icon: BatteryLowIcon, + className: "text-red-500", + text: (level) => t("common.batteryStatus.charging", { level }), + }, + ]; +}; -const getBatteryState = (level: number) => { +const getBatteryState = ( + level: number, + batteryStates: BatteryStateConfig[], +) => { return batteryStates.find((state) => state.condition(level)); }; @@ -62,15 +70,20 @@ const BatteryStatus: React.FC = ({ deviceMetrics }) => { return null; } + const { t } = useTranslation(); + const batteryStates = getBatteryStates(t); + const { batteryLevel, voltage } = deviceMetrics; - const currentState = getBatteryState(batteryLevel) ?? + const currentState = getBatteryState(batteryLevel, batteryStates) ?? batteryStates[batteryStates.length - 1]; const BatteryIcon = currentState.Icon; const iconClassName = currentState.className; const statusText = currentState.text(batteryLevel); - const voltageTitle = `${voltage?.toPrecision(3) ?? "Unknown"} volts`; + const voltageTitle = `${voltage?.toPrecision(3) ?? t("common.unknown")} ${ + t("common_unit_volts") + }`; return (
= ({ deviceMetrics }) => { title={voltageTitle} > - + {statusText}
diff --git a/src/components/CommandPalette/index.tsx b/src/components/CommandPalette/index.tsx index 9e44fde5..656b9a7e 100644 --- a/src/components/CommandPalette/index.tsx +++ b/src/components/CommandPalette/index.tsx @@ -33,6 +33,7 @@ import { import { useEffect } from "react"; import { Avatar } from "@components/UI/Avatar.tsx"; import { cn } from "@core/utils/cn.ts"; +import { useTranslation } from "react-i18next"; import { usePinnedItems } from "@core/hooks/usePinnedItems.ts"; export interface Group { @@ -65,28 +66,29 @@ export const CommandPalette = () => { const { pinnedItems, togglePinnedItem } = usePinnedItems({ storageName: "pinnedCommandMenuGroups", }); + const { t } = useTranslation(); const groups: Group[] = [ { - label: "Goto", + label: t("command_palette_goto_label"), icon: LinkIcon, commands: [ { - label: "Messages", + label: t("command_palette_goto_command_messages"), icon: MessageSquareIcon, action() { setActivePage("messages"); }, }, { - label: "Map", + label: t("command_palette_goto_command_map"), icon: MapIcon, action() { setActivePage("map"); }, }, { - label: "Config", + label: t("command_palette_goto_command_config"), icon: SettingsIcon, action() { setActivePage("config"); @@ -94,14 +96,14 @@ export const CommandPalette = () => { tags: ["settings"], }, { - label: "Channels", + label: t("command_palette_goto_command_channels"), icon: LayersIcon, action() { setActivePage("channels"); }, }, { - label: "Nodes", + label: t("command_palette_goto_command_nodes"), icon: UsersIcon, action() { setActivePage("nodes"); @@ -110,19 +112,19 @@ export const CommandPalette = () => { ], }, { - label: "Manage", + label: t("command_palette_manage_label"), icon: SmartphoneIcon, commands: [ { - label: "Switch Node", + label: t("command_palette_manage_command_switch_node"), icon: ArrowLeftRightIcon, subItems: getDevices().map((device) => ({ label: getNode(device.hardware.myNodeNum)?.user?.longName ?? - device.hardware.myNodeNum.toString(), + t("common.unknown"), // Or a more specific key for node name icon: ( ), action() { @@ -131,7 +133,7 @@ export const CommandPalette = () => { })), }, { - label: "Connect New Node", + label: t("command_palette_manage_command_connect_new_node"), icon: PlusIcon, action() { setConnectDialogOpen(true); @@ -140,22 +142,22 @@ export const CommandPalette = () => { ], }, { - label: "Contextual", + label: t("command_palette_contextual_label"), icon: BoxSelectIcon, commands: [ { - label: "QR Code", + label: t("command_palette_contextual_command_qr_code"), icon: QrCodeIcon, subItems: [ { - label: "Generator", + label: t("command_palette_contextual_command_qr_generator"), icon: , action() { setDialogOpen("QR", true); }, }, { - label: "Import", + label: t("command_palette_contextual_command_qr_import"), icon: , action() { setDialogOpen("import", true); @@ -164,42 +166,42 @@ export const CommandPalette = () => { ], }, { - label: "Schedule Shutdown", + label: t("command_palette_contextual_command_schedule_shutdown"), icon: PowerIcon, action() { setDialogOpen("shutdown", true); }, }, { - label: "Schedule Reboot", + label: t("command_palette_contextual_command_schedule_reboot"), icon: RefreshCwIcon, action() { setDialogOpen("reboot", true); }, }, { - label: "Reboot To OTA Mode", + label: t("command_palette_contextual_command_reboot_to_ota_mode"), icon: RefreshCwIcon, action() { setDialogOpen("rebootOTA", true); }, }, { - label: "Reset Nodes", + label: t("command_palette_contextual_command_reset_nodes"), icon: TrashIcon, action() { connection?.resetNodes(); }, }, { - label: "Factory Reset Device", + label: t("command_palette_contextual_command_factory_reset_device"), icon: FactoryIcon, action() { connection?.factoryResetDevice(); }, }, { - label: "Factory Reset Config", + label: t("command_palette_contextual_command_factory_reset_config"), icon: FactoryIcon, action() { connection?.factoryResetConfig(); @@ -208,18 +210,18 @@ export const CommandPalette = () => { ], }, { - label: "Debug", + label: t("command_palette_debug_label"), icon: BugIcon, commands: [ { - label: "Reconfigure", + label: t("command_palette_debug_command_reconfigure"), icon: RefreshCwIcon, action() { void connection?.configure(); }, }, { - label: "Clear All Stored Message", + label: t("command_palette_debug_command_clear_all_stored_messages"), icon: EraserIcon, action() { setDialogOpen("deleteMessages", true); @@ -252,9 +254,9 @@ export const CommandPalette = () => { open={commandPaletteOpen} onOpenChange={setCommandPaletteOpen} > - + - No results found. + {t("command_palette_empty_message")} {sortedGroups.map((group) => ( { "transition-all duration-300 scale-100 cursor-pointer p-2 focus:*:data-label:opacity-100", )} aria-description={pinnedItems.includes(group.label) - ? "Unpin command group" - : "Pin command group"} + ? t("command_palette_unpin_group_aria_label") + : t("command_palette_pin_group_aria_label")} > { + const { t } = useTranslation(); const { deleteAllMessages } = useMessageStore(); const handleCloseDialog = () => { onOpenChange(false); @@ -32,19 +34,19 @@ export const DeleteMessagesDialog = ({ - Clear All Messages + {t("dialog_deleteMessages_title")} - This action will clear all message history. This cannot be undone. - Are you sure you want to continue? + {t("dialog_deleteMessages_description")} diff --git a/src/components/Dialog/DeviceNameDialog.tsx b/src/components/Dialog/DeviceNameDialog.tsx index 7fbe310d..bd33bda1 100644 --- a/src/components/Dialog/DeviceNameDialog.tsx +++ b/src/components/Dialog/DeviceNameDialog.tsx @@ -14,6 +14,7 @@ import { Label } from "@components/UI/Label.tsx"; import { Protobuf } from "@meshtastic/core"; import { useForm } from "react-hook-form"; import { GenericInput } from "@components/Form/FormInput.tsx"; +import { useTranslation } from "react-i18next"; import { validateMaxByteLength } from "@core/utils/string.ts"; export interface User { @@ -32,6 +33,7 @@ export const DeviceNameDialog = ({ open, onOpenChange, }: DeviceNameDialogProps) => { + const { t } = useTranslation(); const { hardware, getNode, connection } = useDevice(); const myNode = getNode(hardware.myNodeNum); @@ -74,14 +76,16 @@ export const DeviceNameDialog = ({ - Change Device Name + {t("dialog_deviceName_title")} - The Device will restart once the config is saved. + {t("dialog_deviceName_description")}
- +
- + - - + diff --git a/src/components/Dialog/ImportDialog.tsx b/src/components/Dialog/ImportDialog.tsx index cb6a5389..650f8d8b 100644 --- a/src/components/Dialog/ImportDialog.tsx +++ b/src/components/Dialog/ImportDialog.tsx @@ -17,6 +17,7 @@ import { useDevice } from "@core/stores/deviceStore.ts"; import { Protobuf } from "@meshtastic/core"; import { toByteArray } from "base64-js"; import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; export interface ImportDialogProps { open: boolean; @@ -28,6 +29,7 @@ export const ImportDialog = ({ open, onOpenChange, }: ImportDialogProps) => { + const { t } = useTranslation(); const [importDialogInput, setImportDialogInput] = useState(""); const [channelSet, setChannelSet] = useState(); const [validUrl, setValidUrl] = useState(false); @@ -44,7 +46,7 @@ export const ImportDialog = ({ channelsUrl.pathname !== "/e/") || !channelsUrl.hash ) { - throw "Invalid Meshtastic URL"; + throw t("dialog_import_error_invalidUrl"); } const encodedChannelConfig = channelsUrl.hash.substring(1); @@ -99,13 +101,13 @@ export const ImportDialog = ({ - Import Channel Set + {t("dialog_import_title")} - The current LoRa configuration will be overridden. + {t("dialog_import_description")}
- +
- + - Channels: + {t("dialog_import_label_channels")}
{channelSet?.settings.map((channel) => ( @@ -152,7 +154,9 @@ export const ImportDialog = ({
@@ -162,8 +166,8 @@ export const ImportDialog = ({ )}
- diff --git a/src/components/Dialog/LocationResponseDialog.tsx b/src/components/Dialog/LocationResponseDialog.tsx index a338e865..6899e89a 100644 --- a/src/components/Dialog/LocationResponseDialog.tsx +++ b/src/components/Dialog/LocationResponseDialog.tsx @@ -9,6 +9,7 @@ import { } from "../UI/Dialog.tsx"; import type { Protobuf, Types } from "@meshtastic/core"; import { numberToHexUnpadded } from "@noble/curves/abstract/utils"; +import { useTranslation } from "react-i18next"; export interface LocationResponseDialogProps { location: Types.PacketMetadata | undefined; @@ -21,26 +22,32 @@ export const LocationResponseDialog = ({ open, onOpenChange, }: LocationResponseDialogProps) => { + const { t } = useTranslation(); const { getNode } = useDevice(); const from = getNode(location?.from ?? 0); const longName = from?.user?.longName ?? - (from ? `!${numberToHexUnpadded(from?.num)}` : "Unknown"); + (from ? `!${numberToHexUnpadded(from?.num)}` : t("common.unknown")); const shortName = from?.user?.shortName ?? - (from ? `${numberToHexUnpadded(from?.num).substring(0, 4)}` : "UNK"); + (from + ? `${numberToHexUnpadded(from?.num).substring(0, 4)}` + : t("common.unknown")); return ( - {`Location: ${longName} (${shortName})`} + + {t("dialog_locationResponse_titlePrefix")} + {longName} ({shortName}) + diff --git a/src/components/Dialog/NewDeviceDialog.tsx b/src/components/Dialog/NewDeviceDialog.tsx index dd73632a..871d557f 100644 --- a/src/components/Dialog/NewDeviceDialog.tsx +++ b/src/components/Dialog/NewDeviceDialog.tsx @@ -1,7 +1,7 @@ import { type BrowserFeature, useBrowserFeatureDetection, -} from "../../core/hooks/useBrowserFeatureDetection.ts"; +} from "@core/hooks/useBrowserFeatureDetection.ts"; import { BLE } from "@components/PageComponents/Connect/BLE.tsx"; import { HTTP } from "@components/PageComponents/Connect/HTTP.tsx"; import { Serial } from "@components/PageComponents/Connect/Serial.tsx"; @@ -20,8 +20,9 @@ import { } from "@components/UI/Tabs.tsx"; import { Subtle } from "@components/UI/Typography/Subtle.tsx"; import { AlertCircle } from "lucide-react"; +import { useMemo } from "react"; +import { Trans, useTranslation } from "react-i18next"; import { Link } from "../UI/Typography/Link.tsx"; -import { Fragment } from "react/jsx-runtime"; export interface TabElementProps { closeDialog: () => void; @@ -51,12 +52,18 @@ const links: { [key: string]: string } = { "https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts", }; -const listFormatter = new Intl.ListFormat("en", { - style: "long", - type: "disjunction", -}); - const ErrorMessage = ({ missingFeatures }: FeatureErrorProps) => { + const { t, i18n } = useTranslation(); + + const listFormatter = useMemo( + () => + new Intl.ListFormat(i18n.language, { + style: "long", + type: "disjunction", + }), + [i18n.language], + ); + if (missingFeatures.length === 0) return null; const browserFeatures = missingFeatures.filter( @@ -74,10 +81,12 @@ const ErrorMessage = ({ missingFeatures }: FeatureErrorProps) => { ); } - return {part.value}; + return {part.value}; }); }; + const featureNodes = formatFeatureList(browserFeatures); + return (
@@ -85,20 +94,28 @@ const ErrorMessage = ({ missingFeatures }: FeatureErrorProps) => {

{browserFeatures.length > 0 && ( - <> - This connection type requires{" "} - {formatFeatureList(browserFeatures)}. Please use a supported - browser, like Chrome or Edge. - + {featureNodes}, + }} + /> )} + {browserFeatures.length > 0 && needsSecureContext && " "} {needsSecureContext && ( - <> - {browserFeatures.length > 0 && " Additionally, it"} - {browserFeatures.length === 0 && "This application"} requires a - {" "} - secure context. - Please connect using HTTPS or localhost. - + 0 + ? "dialog_newDeviceDialog_errorMessage_additionallyRequiresSecureContext" + : "dialog_newDeviceDialog_errorMessage_requiresSecureContext"} + components={{ + "0": ( + + ), + }} + /> )}

@@ -111,22 +128,23 @@ export const NewDeviceDialog = ({ open, onOpenChange, }: NewDeviceProps) => { + const { t } = useTranslation(); const { unsupported } = useBrowserFeatureDetection(); const tabs: TabManifest[] = [ { - label: "HTTP", + label: t("dialog_newDeviceDialog_tabLabelHttp"), element: HTTP, isDisabled: false, }, { - label: "Bluetooth", + label: t("dialog_newDeviceDialog_tabLabelBluetooth"), element: BLE, isDisabled: unsupported.includes("Web Bluetooth") || unsupported.includes("Secure Context"), }, { - label: "Serial", + label: t("dialog_newDeviceDialog_tabLabelSerial"), element: Serial, isDisabled: unsupported.includes("Web Serial") || unsupported.includes("Secure Context"), @@ -138,9 +156,9 @@ export const NewDeviceDialog = ({ - Connect New Device + {t("dialog_newDeviceDialog_title")} - + {tabs.map((tab) => ( @@ -151,7 +169,8 @@ export const NewDeviceDialog = ({ {tabs.map((tab) => (
- {(tab.label !== "HTTP" && tab.isDisabled) + {(tab.label !== t("dialog_newDeviceDialog_tabLabelHttp") && + tab.isDisabled) ? : null} { + const { t } = useTranslation(); const { setDialogOpen, connection, setActivePage } = useDevice(); const { setNodeNumToBeRemoved } = useAppStore(); const { setChatType, setActiveChat } = useMessageStore(); @@ -90,11 +92,11 @@ export const NodeDetailsDialog = ({ if (!node) return; toast({ - title: "Requesting position, please wait...", + title: t("toast_requestingPosition"), }); connection?.requestPosition(node.num).then(() => toast({ - title: "Position request sent.", + title: t("toast_positionRequestSent"), }) ); onOpenChange(false); @@ -104,11 +106,11 @@ export const NodeDetailsDialog = ({ if (!node) return; toast({ - title: "Sending Traceroute, please wait...", + title: t("toast_sendingTraceroute"), }); connection?.traceRoute(node.num).then(() => toast({ - title: "Traceroute sent.", + title: t("toast_tracerouteSent"), }) ); onOpenChange(false); @@ -139,25 +141,25 @@ export const NodeDetailsDialog = ({ const deviceMetricsMap = [ { key: "airUtilTx", - label: "Air TX utilization", + label: t("dialog_nodeDetails_label_airTxUtilization"), value: node.deviceMetrics?.airUtilTx, format: (val: number) => `${val.toFixed(2)}%`, }, { key: "channelUtilization", - label: "Channel utilization", + label: t("dialog_nodeDetails_label_channelUtilization"), value: node.deviceMetrics?.channelUtilization, format: (val: number) => `${val.toFixed(2)}%`, }, { key: "batteryLevel", - label: "Battery level", + label: t("dialog_nodeDetails_label_batteryLevel"), value: node.deviceMetrics?.batteryLevel, format: (val: number) => `${val.toFixed(2)}%`, }, { key: "voltage", - label: "Voltage", + label: t("dialog_nodeDetails_label_voltage"), value: node.deviceMetrics?.voltage, format: (val: number) => `${val.toFixed(2)}V`, }, @@ -169,20 +171,29 @@ export const NodeDetailsDialog = ({ - Node Details for {node.user?.longName ?? "UNKNOWN"} ( - {node.user?.shortName ?? "UNK"}) + {t("dialog_nodeDetails_titlePrefix")} + {node.user?.longName ?? t("common.unknown")} ( + {node.user?.shortName ?? t("common.unknown")})
- - - Remove node + {t("dialog_nodeDetails_tooltip_removeNode")} @@ -239,23 +252,30 @@ export const NodeDetailsDialog = ({
-

Details:

-

Node Number: {node.num}

-

Node Hex: !{numberToHexUnpadded(node.num)}

+

+ {t("dialog_nodeDetails_label_details")} +

+

{t("dialog_nodeDetails_label_nodeNumber")}{node.num}

+

+ {t("dialog_nodeDetails_label_nodeHexPrefix")} + {numberToHexUnpadded(node.num)} +

- Role: {Protobuf.Config.Config_DeviceConfig_Role[ + {t("dialog_nodeDetails_label_role")} + {Protobuf.Config.Config_DeviceConfig_Role[ node.user?.role ?? 0 ].replace(/_/g, " ")}

- Last Heard: {node.lastHeard === 0 - ? "Never" + {t("dialog_nodeDetails_label_lastHeard")} + {node.lastHeard === 0 + ? t("nodes_table_lastHeardStatus_never") : }

- Hardware:{" "} + {t("dialog_nodeDetails_label_hardware")} {(Protobuf.Mesh.HardwareModel[node.user?.hwModel ?? 0] ?? - "Unknown") + t("common.unknown")) .replace(/_/g, " ")}

@@ -269,7 +289,9 @@ export const NodeDetailsDialog = ({
{node.deviceMetrics && (

- Device Metrics: + {t("dialog_nodeDetails_label_deviceMetrics")}

{deviceMetricsMap.map( (metric) => @@ -318,7 +348,7 @@ export const NodeDetailsDialog = ({ )} {node.deviceMetrics.uptimeSeconds && (

- Uptime:{" "} + {t("dialog_nodeDetails_label_uptime")}

)} @@ -331,7 +361,7 @@ export const NodeDetailsDialog = ({

- All Raw Metrics: + {t("dialog_nodeDetails_label_allRawMetrics")}

diff --git a/src/components/Dialog/PKIBackupDialog.tsx b/src/components/Dialog/PKIBackupDialog.tsx index 3cd16727..1edefd44 100644 --- a/src/components/Dialog/PKIBackupDialog.tsx +++ b/src/components/Dialog/PKIBackupDialog.tsx @@ -12,6 +12,7 @@ import { import { fromByteArray } from "base64-js"; import { DownloadIcon, PrinterIcon } from "lucide-react"; import React from "react"; +import { useTranslation } from "react-i18next"; export interface PkiBackupDialogProps { open: boolean; @@ -22,6 +23,7 @@ export const PkiBackupDialog = ({ open, onOpenChange, }: PkiBackupDialogProps) => { + const { t } = useTranslation(); const { config, setDialogOpen } = useDevice(); const privateKey = config.security?.privateKey; const publicKey = config.security?.publicKey; @@ -46,7 +48,7 @@ export const PkiBackupDialog = ({ printWindow.document.write(` - === MESHTASTIC KEYS === + ${t("dialog_pkiBackup_print_header")} -

=== MESHTASTIC KEYS ===

+

${t("dialog_pkiBackup_print_header")}


-

Public Key:

+

${t("dialog_pkiBackup_print_label_publicKey")}

${decodeKeyData(publicKey)}

-

Private Key:

+

${t("dialog_pkiBackup_print_label_privateKey")}

${decodeKeyData(privateKey)}


-

=== END OF KEYS ===

+

${t("dialog_pkiBackup_print_footer")}

`); @@ -69,7 +71,7 @@ export const PkiBackupDialog = ({ printWindow.print(); closeDialog(); } - }, [decodeKeyData, privateKey, publicKey, closeDialog]); + }, [decodeKeyData, privateKey, publicKey, closeDialog, t]); const createDownloadKeyFile = React.useCallback(() => { if (!privateKey || !publicKey) return; @@ -78,12 +80,12 @@ export const PkiBackupDialog = ({ const decodedPublicKey = decodeKeyData(publicKey); const formattedContent = [ - "=== MESHTASTIC KEYS ===\n\n", - "Private Key:\n", + `${t("dialog_pkiBackup_print_header")}\n\n`, + `${t("dialog_pkiBackup_print_label_privateKey")}\n`, decodedPrivateKey, - "\n\nPublic Key:\n", + `\n\n${t("dialog_pkiBackup_print_label_publicKey")}\n`, decodedPublicKey, - "\n\n=== END OF KEYS ===", + `\n\n${t("dialog_pkiBackup_print_footer")}`, ].join(""); const blob = new Blob([formattedContent], { type: "text/plain" }); @@ -98,36 +100,36 @@ export const PkiBackupDialog = ({ document.body.removeChild(link); closeDialog(); URL.revokeObjectURL(url); - }, [decodeKeyData, privateKey, publicKey, closeDialog]); + }, [decodeKeyData, privateKey, publicKey, closeDialog, t]); return ( - Backup Keys + {t("dialog_pkiBackup_title")} - Its important to backup your public and private keys and store your - backup securely! + {t("dialog_pkiBackup_description_secureBackup")} - If you lose your keys, you will need to reset your device. + {t("dialog_pkiBackup_description_loseKeysWarning")} diff --git a/src/components/Dialog/PkiRegenerateDialog.tsx b/src/components/Dialog/PkiRegenerateDialog.tsx index 58fb7208..e727f339 100644 --- a/src/components/Dialog/PkiRegenerateDialog.tsx +++ b/src/components/Dialog/PkiRegenerateDialog.tsx @@ -8,6 +8,7 @@ import { DialogHeader, DialogTitle, } from "@components/UI/Dialog.tsx"; +import { useTranslation } from "react-i18next"; export interface PkiRegenerateDialogProps { text: { @@ -22,27 +23,38 @@ export interface PkiRegenerateDialogProps { export const PkiRegenerateDialog = ({ text = { - title: "Regenerate Key Pair", - description: "Are you sure you want to regenerate key pair?", - button: "Regenerate", + title: "", // Default will be set by useTranslation + description: "", // Default will be set by useTranslation + button: "", // Default will be set by useTranslation }, open, onOpenChange, onSubmit, }: PkiRegenerateDialogProps) => { + const { t } = useTranslation(); + const dialogText = { + title: text.title || t("dialog_pkiRegenerate_title_keyPair"), + description: text.description || + t("dialog_pkiRegenerate_description_keyPair"), + button: text.button || t("dialog_pkiRegenerateDialog_buttonRegenerate"), + }; return ( - {text?.title} + {dialogText.title} - {text?.description} + {dialogText.description} - diff --git a/src/components/Dialog/QRDialog.tsx b/src/components/Dialog/QRDialog.tsx index 0dc67fb7..72ba2ed3 100644 --- a/src/components/Dialog/QRDialog.tsx +++ b/src/components/Dialog/QRDialog.tsx @@ -16,6 +16,7 @@ import { fromByteArray } from "base64-js"; import { ClipboardIcon } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; import { QRCode } from "react-qrcode-logo"; +import { useTranslation } from "react-i18next"; export interface QRDialogProps { open: boolean; @@ -30,6 +31,7 @@ export const QRDialog = ({ loraConfig, channels, }: QRDialogProps) => { + const { t } = useTranslation(); const [selectedChannels, setSelectedChannels] = useState([0]); const [qrCodeUrl, setQrCodeUrl] = useState(""); const [qrCodeAdd, setQrCodeAdd] = useState(); @@ -65,9 +67,9 @@ export const QRDialog = ({ - Generate QR Code + {t("dialog_qr_title")} - The current LoRa configuration will also be shared. + {t("dialog_qr_description")}
@@ -79,8 +81,10 @@ export const QRDialog = ({ {channel.settings?.name.length ? channel.settings.name : channel.role === Protobuf.Channel.Channel_Role.PRIMARY - ? "Primary" - : `Channel: ${channel.index}`} + ? t("channel_name_primary") + : `${ + t("dialog_import_label_channelPrefix") + }${channel.index}`} setQrCodeAdd(true)} > - Add Channels + {t("dialog_qr_button_addChannels")}
- + { + const { t } = useTranslation(); const { connection } = useDevice(); const [time, setTime] = useState(5); @@ -30,9 +32,11 @@ export const RebootDialog = ({ - Schedule Reboot + + {t("command_palette_contextual_command_schedule_reboot")} + - Reboot the connected node after x minutes. + {t("dialog_reboot_description")}
@@ -50,12 +54,13 @@ export const RebootDialog = ({ />
diff --git a/src/components/Dialog/RebootOTADialog.tsx b/src/components/Dialog/RebootOTADialog.tsx index b554b28e..843f89a9 100644 --- a/src/components/Dialog/RebootOTADialog.tsx +++ b/src/components/Dialog/RebootOTADialog.tsx @@ -11,6 +11,7 @@ import { } from "@components/UI/Dialog.tsx"; import { Input } from "@components/UI/Input.tsx"; import { useDevice } from "@core/stores/deviceStore.ts"; +import { useTranslation } from "react-i18next"; export interface RebootOTADialogProps { open: boolean; @@ -22,6 +23,7 @@ const DEFAULT_REBOOT_DELAY = 5; // seconds export const RebootOTADialog = ( { open, onOpenChange }: RebootOTADialogProps, ) => { + const { t } = useTranslation(); const { connection } = useDevice(); const [time, setTime] = useState(DEFAULT_REBOOT_DELAY); const [isScheduled, setIsScheduled] = useState(false); @@ -73,10 +75,11 @@ export const RebootOTADialog = ( - Reboot to OTA Mode + + {t("command_palette_contextual_command_reboot_to_ota_mode")} + - Reboot the connected node after a delay into OTA (Over-the-Air) - mode. + {t("dialog_rebootOta_description")} @@ -88,17 +91,27 @@ export const RebootOTADialog = ( className="dark:text-slate-900 appearance-none" value={inputValue} onChange={handleSetTime} - placeholder="Enter delay (sec)" + placeholder={t("dialog_rebootOta_placeholder_enterDelay")} /> -
-
diff --git a/src/components/Dialog/RefreshKeysDialog/RefreshKeysDialog.tsx b/src/components/Dialog/RefreshKeysDialog/RefreshKeysDialog.tsx index 95bc73aa..c2f91131 100644 --- a/src/components/Dialog/RefreshKeysDialog/RefreshKeysDialog.tsx +++ b/src/components/Dialog/RefreshKeysDialog/RefreshKeysDialog.tsx @@ -9,6 +9,7 @@ import { Button } from "@components/UI/Button.tsx"; import { LockKeyholeOpenIcon } from "lucide-react"; import { useRefreshKeysDialog } from "./useRefreshKeysDialog.ts"; import { useDevice } from "@core/stores/deviceStore.ts"; +import { useTranslation } from "react-i18next"; import { useMessageStore } from "../../../core/stores/messageStore/index.ts"; export interface RefreshKeysDialogProps { @@ -19,6 +20,7 @@ export interface RefreshKeysDialogProps { export const RefreshKeysDialog = ( { open, onOpenChange }: RefreshKeysDialogProps, ) => { + const { t } = useTranslation(); const { activeChat } = useMessageStore(); const { nodeErrors, getNode } = useDevice(); const { handleCloseDialog, handleNodeRemove } = useRefreshKeysDialog(); @@ -32,13 +34,16 @@ export const RefreshKeysDialog = ( const nodeWithError = getNode(nodeErrorNum.node); const text = { - title: `Keys Mismatch - ${nodeWithError?.user?.longName ?? ""}`, - description: `Your node is unable to send a direct message to node: ${ + title: `${t("dialog_refreshKeys_titlePrefix")}${ nodeWithError?.user?.longName ?? "" - } (${ - nodeWithError?.user?.shortName ?? "" - }). This is due to the remote node's current public key does not match the previously stored key for this node.`, + }`, + description: `${t("dialog_refreshKeys_description_unableToSendDmPrefix")}${ + nodeWithError?.user?.longName ?? "" + } (${nodeWithError?.user?.shortName ?? ""})${ + t("dialog_refreshKeys_description_keyMismatchReasonSuffix") + }`, }; + return (
-

Accept New Keys

+

+ {t("dialog_refreshKeys_label_acceptNewKeys")} +

- This will remove the node from device and request new keys. + {t("dialog_refreshKeys_description_acceptNewKeys")}

diff --git a/src/components/Dialog/RemoveNodeDialog.tsx b/src/components/Dialog/RemoveNodeDialog.tsx index 66c42dc6..4318ff40 100644 --- a/src/components/Dialog/RemoveNodeDialog.tsx +++ b/src/components/Dialog/RemoveNodeDialog.tsx @@ -11,6 +11,7 @@ import { DialogTitle, } from "@components/UI/Dialog.tsx"; import { Label } from "@components/UI/Label.tsx"; +import { useTranslation } from "react-i18next"; export interface RemoveNodeDialogProps { open: boolean; @@ -21,6 +22,7 @@ export const RemoveNodeDialog = ({ open, onOpenChange, }: RemoveNodeDialogProps) => { + const { t } = useTranslation(); const { connection, getNode, removeNode } = useDevice(); const { nodeNumToBeRemoved } = useAppStore(); @@ -35,9 +37,9 @@ export const RemoveNodeDialog = ({ - Remove Node? + {t("dialog_removeNode_title")} - Are you sure you want to remove this Node? + {t("dialog_removeNode_description")}
@@ -46,8 +48,12 @@ export const RemoveNodeDialog = ({
-
diff --git a/src/components/Dialog/ShutdownDialog.tsx b/src/components/Dialog/ShutdownDialog.tsx index ddc50edb..1ca2e3f4 100644 --- a/src/components/Dialog/ShutdownDialog.tsx +++ b/src/components/Dialog/ShutdownDialog.tsx @@ -10,6 +10,7 @@ import { import { Input } from "@components/UI/Input.tsx"; import { useDevice } from "@core/stores/deviceStore.ts"; import { ClockIcon, PowerIcon } from "lucide-react"; +import { useTranslation } from "react-i18next"; import { useState } from "react"; export interface ShutdownDialogProps { @@ -21,6 +22,7 @@ export const ShutdownDialog = ({ open, onOpenChange, }: ShutdownDialogProps) => { + const { t } = useTranslation(); const { connection } = useDevice(); const [time, setTime] = useState(5); @@ -30,9 +32,11 @@ export const ShutdownDialog = ({ - Schedule Shutdown + + {t("command_palette_contextual_command_schedule_shutdown")} + - Turn off the connected node after x minutes. + {t("dialog_shutdown_description")} @@ -41,7 +45,7 @@ export const ShutdownDialog = ({ type="number" value={time} onChange={(e) => setTime(Number.parseInt(e.target.value))} - suffix="Minutes" + suffix={t("dialog_shutdown_suffix_minutes")} />
diff --git a/src/components/Dialog/TracerouteResponseDialog.tsx b/src/components/Dialog/TracerouteResponseDialog.tsx index 7895c326..d0ae938b 100644 --- a/src/components/Dialog/TracerouteResponseDialog.tsx +++ b/src/components/Dialog/TracerouteResponseDialog.tsx @@ -11,6 +11,7 @@ import type { Protobuf, Types } from "@meshtastic/core"; import { numberToHexUnpadded } from "@noble/curves/abstract/utils"; import { TraceRoute } from "../PageComponents/Messages/TraceRoute.tsx"; +import { useTranslation } from "react-i18next"; export interface TracerouteResponseDialogProps { traceroute: Types.PacketMetadata | undefined; @@ -23,6 +24,7 @@ export const TracerouteResponseDialog = ({ open, onOpenChange, }: TracerouteResponseDialogProps) => { + const { t } = useTranslation(); const { getNode } = useDevice(); const route: number[] = traceroute?.data.route ?? []; const routeBack: number[] = traceroute?.data.routeBack ?? []; @@ -30,16 +32,21 @@ export const TracerouteResponseDialog = ({ const snrBack = (traceroute?.data.snrBack ?? []).map((snr) => snr / 4); const from = getNode(traceroute?.from ?? 0); const longName = from?.user?.longName ?? - (from ? `!${numberToHexUnpadded(from?.num)}` : "Unknown"); + (from ? `!${numberToHexUnpadded(from?.num)}` : t("common.unknown")); const shortName = from?.user?.shortName ?? - (from ? `${numberToHexUnpadded(from?.num).substring(0, 4)}` : "UNK"); + (from + ? `${numberToHexUnpadded(from?.num).substring(0, 4)}` + : t("common.unknown")); const to = getNode(traceroute?.to ?? 0); return ( - {`Traceroute: ${longName} (${shortName})`} + + {t("dialog_tracerouteResponse_titlePrefix")} + {longName} ({shortName}) + { + const { t } = useTranslation(); const [confirmState, setConfirmState] = useState(false); const { setDialogOpen } = useDevice(); @@ -41,26 +43,27 @@ export const UnsafeRolesDialog = ( handleCloseDialog("dismiss")} /> - Are you sure? + {t("dialog_unsafeRoles_title")} - I have read the{" "} + {t("dialog_unsafeRoles_description_preamble")} - Device Role Documentation - {" "} - and the blog post about{" "} + {t("dialog_unsafeRoles_link_deviceRoleDocumentation")} + + {t("dialog_unsafeRoles_description_conjunction")} - Choosing The Right Device Role - {" "} - and understand the implications of changing the role. + {t("dialog_unsafeRoles_link_choosingRightDeviceRole")} + + {t("dialog_unsafeRoles_description_postamble")}
setConfirmState(!confirmState)} + name="confirmUnderstanding" > - Yes, I know what I'm doing + {t("dialog_unsafeRoles_checkbox_confirmUnderstanding")}
@@ -69,7 +72,7 @@ export const UnsafeRolesDialog = ( name="dismiss" onClick={() => handleCloseDialog("dismiss")} > - Dismiss + {t("dialog_button_dismiss")}
diff --git a/src/components/KeyBackupReminder.tsx b/src/components/KeyBackupReminder.tsx index 80894cba..8dc1732e 100644 --- a/src/components/KeyBackupReminder.tsx +++ b/src/components/KeyBackupReminder.tsx @@ -1,12 +1,13 @@ import { useBackupReminder } from "@core/hooks/useKeyBackupReminder.tsx"; import { useDevice } from "@core/stores/deviceStore.ts"; +import { useTranslation } from "react-i18next"; export const KeyBackupReminder = () => { const { setDialogOpen } = useDevice(); + const { t } = useTranslation(); useBackupReminder({ - message: - "We recommend backing up your key data regularly. Would you like to back up now?", + message: t("dialog_pkiBackup_message"), onAccept: () => setDialogOpen("pkiBackup", true), enabled: true, }); diff --git a/src/components/PageComponents/Channel.tsx b/src/components/PageComponents/Channel.tsx index 9968bf02..0ec8ead2 100644 --- a/src/components/PageComponents/Channel.tsx +++ b/src/components/PageComponents/Channel.tsx @@ -7,6 +7,7 @@ import { Protobuf } from "@meshtastic/core"; import { fromByteArray, toByteArray } from "base64-js"; import cryptoRandomString from "crypto-random-string"; import { useState } from "react"; +import { useTranslation } from "react-i18next"; import { PkiRegenerateDialog } from "../Dialog/PkiRegenerateDialog.tsx"; export interface SettingsPanelProps { @@ -15,6 +16,7 @@ export interface SettingsPanelProps { export const Channel = ({ channel }: SettingsPanelProps) => { const { config, connection, addChannel } = useDevice(); + const { t } = useTranslation(); const { toast } = useToast(); const [pass, setPass] = useState( @@ -42,7 +44,9 @@ export const Channel = ({ channel }: SettingsPanelProps) => { }); connection?.setChannel(channel).then(() => { toast({ - title: `Saved Channel: ${channel.settings?.name}`, + title: t("channel_form_toast_saved", { + channelName: channel.settings?.name, + }), }); addChannel(channel); }); @@ -67,7 +71,9 @@ export const Channel = ({ channel }: SettingsPanelProps) => { const validatePass = (input: string, count: number) => { if (input.length % 4 !== 0 || toByteArray(input).length !== count) { - setValidationText(`Please enter a valid ${count * 8} bit PSK.`); + setValidationText( + t("channel_form_validation_psk_invalid", { bits: count * 8 }), + ); } else { setValidationText(undefined); } @@ -110,36 +116,37 @@ export const Channel = ({ channel }: SettingsPanelProps) => { }} fieldGroups={[ { - label: "Channel Settings", - description: "Crypto, MQTT & misc settings", + label: t("channel_form_field_group_settings_label"), + description: t("channel_form_field_group_settings_description"), fields: [ { type: "select", name: "role", - label: "Role", + label: t("channel_form_field_role_label"), disabled: channel.index === 0, - description: - "Device telemetry is sent over PRIMARY. Only one PRIMARY allowed", + description: t("channel_form_field_role_description"), properties: { enumValue: channel.index === 0 - ? { PRIMARY: 1 } - : { DISABLED: 0, SECONDARY: 2 }, + ? { [t("channel_form_field_role_option_primary")]: 1 } + : { + [t("channel_form_field_role_option_disabled")]: 0, + [t("channel_form_field_role_option_secondary")]: 2, + }, }, }, { type: "passwordGenerator", name: "settings.psk", id: "channel-psk", - label: "Pre-Shared Key", - description: - "Supported PSK lengths: 256-bit, 128-bit, 8-bit, Empty (0-bit)", + label: t("channel_form_field_psk_label"), + description: t("channel_form_field_psk_description"), validationText: validationText, devicePSKBitCount: bitCount ?? 0, inputChange: inputChangeEvent, selectChange: selectChangeEvent, actionButtons: [ { - text: "Generate", + text: t("channel_form_field_psk_generate_button"), variant: "success", onClick: preSharedClickEvent, }, @@ -154,57 +161,105 @@ export const Channel = ({ channel }: SettingsPanelProps) => { { type: "text", name: "settings.name", - label: "Name", - description: - "A unique name for the channel <12 bytes, leave blank for default", + label: t("channel_form_field_name_label"), + description: t("channel_form_field_name_description"), }, { type: "toggle", name: "settings.uplinkEnabled", - label: "Uplink Enabled", - description: "Send messages from the local mesh to MQTT", + label: t("channel_form_field_uplink_enabled_label"), + description: t("channel_form_field_uplink_enabled_description"), }, { type: "toggle", name: "settings.downlinkEnabled", - label: "Downlink Enabled", - description: "Send messages from MQTT to the local mesh", + label: t("channel_form_field_downlink_enabled_label"), + description: t( + "channel_form_field_downlink_enabled_description", + ), }, { type: "select", name: "settings.moduleSettings.positionPrecision", - label: "Location", - description: - "The precision of the location to share with the channel. Can be disabled.", + label: t("channel_form_field_position_precision_label"), + description: t( + "channel_form_field_position_precision_description", + ), properties: { enumValue: config.display?.units === 0 ? { - "Do not share location": 0, - "Within 23 kilometers": 10, - "Within 12 kilometers": 11, - "Within 5.8 kilometers": 12, - "Within 2.9 kilometers": 13, - "Within 1.5 kilometers": 14, - "Within 700 meters": 15, - "Within 350 meters": 16, - "Within 200 meters": 17, - "Within 90 meters": 18, - "Within 50 meters": 19, - "Precise Location": 32, + [t("channel_form_field_position_precision_option_none")]: + 0, + [ + t("channel_form_field_position_precision_option_metric_km23") + ]: 10, + [ + t("channel_form_field_position_precision_option_metric_km12") + ]: 11, + [ + t("channel_form_field_position_precision_option_metric_km5_8") + ]: 12, + [ + t("channel_form_field_position_precision_option_metric_km2_9") + ]: 13, + [ + t("channel_form_field_position_precision_option_metric_km1_5") + ]: 14, + [ + t("channel_form_field_position_precision_option_metric_m700") + ]: 15, + [ + t("channel_form_field_position_precision_option_metric_m350") + ]: 16, + [ + t("channel_form_field_position_precision_option_metric_m200") + ]: 17, + [ + t("channel_form_field_position_precision_option_metric_m90") + ]: 18, + [ + t("channel_form_field_position_precision_option_metric_m50") + ]: 19, + [ + t("channel_form_field_position_precision_option_precise") + ]: 32, } : { - "Do not share location": 0, - "Within 15 miles": 10, - "Within 7.3 miles": 11, - "Within 3.6 miles": 12, - "Within 1.8 miles": 13, - "Within 0.9 miles": 14, - "Within 0.5 miles": 15, - "Within 0.2 miles": 16, - "Within 600 feet": 17, - "Within 300 feet": 18, - "Within 150 feet": 19, - "Precise Location": 32, + [t("channel_form_field_position_precision_option_none")]: + 0, + [ + t("channel_form_field_position_precision_option_imperial_mi15") + ]: 10, + [ + t("channel_form_field_position_precision_option_imperial_mi7_3") + ]: 11, + [ + t("channel_form_field_position_precision_option_imperial_mi3_6") + ]: 12, + [ + t("channel_form_field_position_precision_option_imperial_mi1_8") + ]: 13, + [ + t("channel_form_field_position_precision_option_imperial_mi0_9") + ]: 14, + [ + t("channel_form_field_position_precision_option_imperial_mi0_5") + ]: 15, + [ + t("channel_form_field_position_precision_option_imperial_mi0_2") + ]: 16, + [ + t("channel_form_field_position_precision_option_imperial_ft600") + ]: 17, + [ + t("channel_form_field_position_precision_option_imperial_ft300") + ]: 18, + [ + t("channel_form_field_position_precision_option_imperial_ft150") + ]: 19, + [ + t("channel_form_field_position_precision_option_precise") + ]: 32, }, }, }, @@ -214,10 +269,9 @@ export const Channel = ({ channel }: SettingsPanelProps) => { /> setPreSharedDialogOpen(false)} diff --git a/src/components/PageComponents/Config/Bluetooth.tsx b/src/components/PageComponents/Config/Bluetooth.tsx index a20155fd..987babc2 100644 --- a/src/components/PageComponents/Config/Bluetooth.tsx +++ b/src/components/PageComponents/Config/Bluetooth.tsx @@ -5,6 +5,7 @@ import { DynamicForm } from "@components/Form/DynamicForm.tsx"; import { useDevice } from "@core/stores/deviceStore.ts"; import { Protobuf } from "@meshtastic/core"; import { useState } from "react"; +import { useTranslation } from "react-i18next"; export const Bluetooth = () => { const { config, setWorkingConfig } = useDevice(); @@ -16,6 +17,7 @@ export const Bluetooth = () => { removeError, clearErrors, } = useAppStore(); + const { t } = useTranslation(); const [bluetoothPin, setBluetoothPin] = useState( config?.bluetooth?.fixedPin.toString() ?? "", @@ -24,7 +26,7 @@ export const Bluetooth = () => { const validateBluetoothPin = (pin: string) => { // if empty show error they need a pin set if (pin === "") { - return addError("fixedPin", "Bluetooth Pin is required"); + return addError("fixedPin", t("config_bluetooth_validation_pinRequired")); } // clear any existing errors @@ -32,11 +34,17 @@ export const Bluetooth = () => { // if it starts with 0 show error if (pin[0] === "0") { - return addError("fixedPin", "Bluetooth Pin cannot start with 0"); + return addError( + "fixedPin", + t("config_bluetooth_validation_pinCannotStartWithZero"), + ); } // if it's not 6 digits show error if (pin.length < 6) { - return addError("fixedPin", "Pin must be 6 digits"); + return addError( + "fixedPin", + t("config_bluetooth_validation_pinMustBeSixDigits"), + ); } removeError("fixedPin"); @@ -69,22 +77,21 @@ export const Bluetooth = () => { defaultValues={config.bluetooth} fieldGroups={[ { - label: "Bluetooth Settings", - description: "Settings for the Bluetooth module ", - notes: - "Note: Some devices (ESP32) cannot use both Bluetooth and WiFi at the same time.", + label: t("config_bluetooth_groupLabel_bluetoothSettings"), + description: t("config_bluetooth_groupDescription_bluetoothSettings"), + notes: t("config_bluetooth_groupNotes_bluetoothWifiNote"), fields: [ { type: "toggle", name: "enabled", - label: "Enabled", - description: "Enable or disable Bluetooth", + label: t("config_bluetooth_fieldLabel_enabled"), + description: t("config_bluetooth_fieldDescription_enabled"), }, { type: "select", name: "mode", - label: "Pairing mode", - description: "Pin selection behaviour.", + label: t("config_bluetooth_fieldLabel_pairingMode"), + description: t("config_bluetooth_fieldDescription_pairingMode"), selectChange: (e) => { if (e !== "1") { setBluetoothPin(""); @@ -104,8 +111,8 @@ export const Bluetooth = () => { { type: "number", name: "fixedPin", - label: "Pin", - description: "Pin to use when pairing", + label: t("config_bluetooth_fieldLabel_pin"), + description: t("config_bluetooth_fieldDescription_pin"), validationText: hasFieldError("fixedPin") ? getErrorMessage("fixedPin") : "", diff --git a/src/components/PageComponents/Config/Device/index.tsx b/src/components/PageComponents/Config/Device/index.tsx index a04f4c19..1f920c9e 100644 --- a/src/components/PageComponents/Config/Device/index.tsx +++ b/src/components/PageComponents/Config/Device/index.tsx @@ -4,9 +4,11 @@ import { DynamicForm } from "@components/Form/DynamicForm.tsx"; import { useDevice } from "@core/stores/deviceStore.ts"; import { Protobuf } from "@meshtastic/core"; import { useUnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.ts"; +import { useTranslation } from "react-i18next"; export const Device = () => { const { config, setWorkingConfig } = useDevice(); + const { t } = useTranslation(); const { validateRoleSelection } = useUnsafeRolesDialog(); const onSubmit = (data: DeviceValidation) => { @@ -25,14 +27,14 @@ export const Device = () => { defaultValues={config.device} fieldGroups={[ { - label: "Device Settings", - description: "Settings for the device", + label: t("config_device_groupLabel_deviceSettings"), + description: t("config_device_groupDescription_deviceSettings"), fields: [ { type: "select", name: "role", - label: "Role", - description: "What role the device performs on the mesh", + label: t("config_device_fieldLabel_role"), + description: t("config_device_fieldDescription_role"), validate: validateRoleSelection, properties: { enumValue: Protobuf.Config.Config_DeviceConfig_Role, @@ -42,20 +44,20 @@ export const Device = () => { { type: "number", name: "buttonGpio", - label: "Button Pin", - description: "Button pin override", + label: t("config_device_fieldLabel_buttonPin"), + description: t("config_device_fieldDescription_buttonPin"), }, { type: "number", name: "buzzerGpio", - label: "Buzzer Pin", - description: "Buzzer pin override", + label: t("config_device_fieldLabel_buzzerPin"), + description: t("config_device_fieldDescription_buzzerPin"), }, { type: "select", name: "rebroadcastMode", - label: "Rebroadcast Mode", - description: "How to handle rebroadcasting", + label: t("config_device_fieldLabel_rebroadcastMode"), + description: t("config_device_fieldDescription_rebroadcastMode"), properties: { enumValue: Protobuf.Config.Config_DeviceConfig_RebroadcastMode, formatEnumName: true, @@ -64,29 +66,35 @@ export const Device = () => { { type: "number", name: "nodeInfoBroadcastSecs", - label: "Node Info Broadcast Interval", - description: "How often to broadcast node info", + label: t("config_device_fieldLabel_nodeInfoBroadcastInterval"), + description: t( + "config_device_fieldDescription_nodeInfoBroadcastInterval", + ), properties: { - suffix: "Seconds", + suffix: t("common_unit_seconds"), }, }, { type: "toggle", name: "doubleTapAsButtonPress", - label: "Double Tap as Button Press", - description: "Treat double tap as button press", + label: t("config_device_fieldLabel_doubleTapAsButtonPress"), + description: t( + "config_device_fieldDescription_doubleTapAsButtonPress", + ), }, { type: "toggle", name: "disableTripleClick", - label: "Disable Triple Click", - description: "Disable triple click", + label: t("config_device_fieldLabel_disableTripleClick"), + description: t( + "config_device_fieldDescription_disableTripleClick", + ), }, { type: "text", name: "tzdef", - label: "POSIX Timezone", - description: "The POSIX timezone string for the device", + label: t("config_device_fieldLabel_posixTimezone"), + description: t("config_device_fieldDescription_posixTimezone"), properties: { fieldLength: { max: 64, @@ -98,8 +106,10 @@ export const Device = () => { { type: "toggle", name: "ledHeartbeatDisabled", - label: "LED Heartbeat Disabled", - description: "Disable default blinking LED", + label: t("config_device_fieldLabel_ledHeartbeatDisabled"), + description: t( + "config_device_fieldDescription_ledHeartbeatDisabled", + ), }, ], }, diff --git a/src/components/PageComponents/Config/Display.tsx b/src/components/PageComponents/Config/Display.tsx index af82e4fd..31b773c8 100644 --- a/src/components/PageComponents/Config/Display.tsx +++ b/src/components/PageComponents/Config/Display.tsx @@ -1,11 +1,13 @@ -import type { DisplayValidation } from "@app/validation/config/display.tsx"; +import type { DisplayValidation } from "@app/validation/config/display.ts"; import { create } from "@bufbuild/protobuf"; import { DynamicForm } from "@components/Form/DynamicForm.tsx"; import { useDevice } from "@core/stores/deviceStore.ts"; import { Protobuf } from "@meshtastic/core"; +import { useTranslation } from "react-i18next"; export const Display = () => { const { config, setWorkingConfig } = useDevice(); + const { t } = useTranslation(); const onSubmit = (data: DisplayValidation) => { setWorkingConfig( @@ -24,23 +26,23 @@ export const Display = () => { defaultValues={config.display} fieldGroups={[ { - label: "Display Settings", - description: "Settings for the device display", + label: t("config_display_groupLabel_displaySettings"), + description: t("config_display_groupDescription_displaySettings"), fields: [ { type: "number", name: "screenOnSecs", - label: "Screen Timeout", - description: "Turn off the display after this long", + label: t("config_display_fieldLabel_screenTimeout"), + description: t("config_display_fieldDescription_screenTimeout"), properties: { - suffix: "Seconds", + suffix: t("common_unit_seconds"), }, }, { type: "select", name: "gpsFormat", - label: "GPS Display Units", - description: "Coordinate display format", + label: t("config_display_fieldLabel_gpsDisplayUnits"), + description: t("config_display_fieldDescription_gpsDisplayUnits"), properties: { enumValue: Protobuf.Config.Config_DisplayConfig_GpsCoordinateFormat, @@ -49,35 +51,35 @@ export const Display = () => { { type: "number", name: "autoScreenCarouselSecs", - label: "Carousel Delay", - description: "How fast to cycle through windows", + label: t("config_display_fieldLabel_carouselDelay"), + description: t("config_display_fieldDescription_carouselDelay"), properties: { - suffix: "Seconds", + suffix: t("common_unit_seconds"), }, }, { type: "toggle", name: "compassNorthTop", - label: "Compass North Top", - description: "Fix north to the top of compass", + label: t("config_display_fieldLabel_compassNorthTop"), + description: t("config_display_fieldDescription_compassNorthTop"), }, { type: "toggle", name: "use12hClock", - label: "12-Hour Clock", - description: "Use 12-hour clock format", + label: t("config_display_fieldLabel_twelveHourClock"), + description: t("config_display_fieldDescription_twelveHourClock"), }, { type: "toggle", name: "flipScreen", - label: "Flip Screen", - description: "Flip display 180 degrees", + label: t("config_display_fieldLabel_flipScreen"), + description: t("config_display_fieldDescription_flipScreen"), }, { type: "select", name: "units", - label: "Display Units", - description: "Display metric or imperial units", + label: t("config_display_fieldLabel_displayUnits"), + description: t("config_display_fieldDescription_displayUnits"), properties: { enumValue: Protobuf.Config.Config_DisplayConfig_DisplayUnits, formatEnumName: true, @@ -86,8 +88,8 @@ export const Display = () => { { type: "select", name: "oled", - label: "OLED Type", - description: "Type of OLED screen attached to the device", + label: t("config_display_fieldLabel_oledType"), + description: t("config_display_fieldDescription_oledType"), properties: { enumValue: Protobuf.Config.Config_DisplayConfig_OledType, }, @@ -95,8 +97,8 @@ export const Display = () => { { type: "select", name: "displaymode", - label: "Display Mode", - description: "Screen layout variant", + label: t("config_display_fieldLabel_displayMode"), + description: t("config_display_fieldDescription_displayMode"), properties: { enumValue: Protobuf.Config.Config_DisplayConfig_DisplayMode, formatEnumName: true, @@ -105,14 +107,16 @@ export const Display = () => { { type: "toggle", name: "headingBold", - label: "Bold Heading", - description: "Bolden the heading text", + label: t("config_display_fieldLabel_boldHeading"), + description: t("config_display_fieldDescription_boldHeading"), }, { type: "toggle", name: "wakeOnTapOrMotion", - label: "Wake on Tap or Motion", - description: "Wake the device on tap or motion", + label: t("config_display_fieldLabel_wakeOnTapOrMotion"), + description: t( + "config_display_fieldDescription_wakeOnTapOrMotion", + ), }, ], }, diff --git a/src/components/PageComponents/Config/LoRa.tsx b/src/components/PageComponents/Config/LoRa.tsx index 671af617..cb3cdbe3 100644 --- a/src/components/PageComponents/Config/LoRa.tsx +++ b/src/components/PageComponents/Config/LoRa.tsx @@ -1,11 +1,13 @@ -import type { LoRaValidation } from "@app/validation/config/lora.tsx"; +import type { LoRaValidation } from "@app/validation/config/lora.ts"; import { create } from "@bufbuild/protobuf"; import { DynamicForm } from "@components/Form/DynamicForm.tsx"; import { useDevice } from "@core/stores/deviceStore.ts"; import { Protobuf } from "@meshtastic/core"; +import { useTranslation } from "react-i18next"; export const LoRa = () => { const { config, setWorkingConfig } = useDevice(); + const { t } = useTranslation(); const onSubmit = (data: LoRaValidation) => { setWorkingConfig( @@ -24,14 +26,14 @@ export const LoRa = () => { defaultValues={config.lora} fieldGroups={[ { - label: "Mesh Settings", - description: "Settings for the LoRa mesh", + label: t("config_lora_groupLabel_meshSettings"), + description: t("config_lora_groupDescription_meshSettings"), fields: [ { type: "select", name: "region", - label: "Region", - description: "Sets the region for your node", + label: t("config_lora_fieldLabel_region"), + description: t("config_lora_fieldDescription_region"), properties: { enumValue: Protobuf.Config.Config_LoRaConfig_RegionCode, }, @@ -39,8 +41,8 @@ export const LoRa = () => { { type: "select", name: "hopLimit", - label: "Hop Limit", - description: "Maximum number of hops", + label: t("config_lora_fieldLabel_hopLimit"), + description: t("config_lora_fieldDescription_hopLimit"), properties: { enumValue: { 1: 1, 2: 2, 3: 3, 4: 4, 5: 5, 6: 6, 7: 7 }, }, @@ -48,39 +50,38 @@ export const LoRa = () => { { type: "number", name: "channelNum", - label: "Frequency Slot", - description: "LoRa frequency channel number", + label: t("config_lora_fieldLabel_frequencySlot"), + description: t("config_lora_fieldDescription_frequencySlot"), }, { type: "toggle", name: "ignoreMqtt", - label: "Ignore MQTT", - description: "Don't forward MQTT messages over the mesh", + label: t("config_lora_fieldLabel_ignoreMqtt"), + description: t("config_lora_fieldDescription_ignoreMqtt"), }, { type: "toggle", name: "configOkToMqtt", - label: "OK to MQTT", - description: - "When set to true, this configuration indicates that the user approves the packet to be uploaded to MQTT. If set to false, remote nodes are requested not to forward packets to MQTT", + label: t("config_lora_fieldLabel_okToMqtt"), + description: t("config_lora_fieldDescription_okToMqtt"), }, ], }, { - label: "Waveform Settings", - description: "Settings for the LoRa waveform", + label: t("config_lora_groupLabel_waveformSettings"), + description: t("config_lora_groupDescription_waveformSettings"), fields: [ { type: "toggle", name: "usePreset", - label: "Use Preset", - description: "Use one of the predefined modem presets", + label: t("config_lora_fieldLabel_usePreset"), + description: t("config_lora_fieldDescription_usePreset"), }, { type: "select", name: "modemPreset", - label: "Modem Preset", - description: "Modem preset to use", + label: t("config_lora_fieldLabel_modemPreset"), + description: t("config_lora_fieldDescription_modemPreset"), disabledBy: [ { fieldName: "usePreset", @@ -94,8 +95,8 @@ export const LoRa = () => { { type: "number", name: "bandwidth", - label: "Bandwidth", - description: "Channel bandwidth in MHz", + label: t("config_lora_fieldLabel_bandwidth"), + description: t("config_lora_fieldDescription_bandwidth"), disabledBy: [ { fieldName: "usePreset", @@ -103,14 +104,14 @@ export const LoRa = () => { }, ], properties: { - suffix: "MHz", + suffix: t("common_unit_megahertz"), }, }, { type: "number", name: "spreadFactor", - label: "Spreading Factor", - description: "Indicates the number of chirps per symbol", + label: t("config_lora_fieldLabel_spreadingFactor"), + description: t("config_lora_fieldDescription_spreadingFactor"), disabledBy: [ { @@ -119,14 +120,14 @@ export const LoRa = () => { }, ], properties: { - suffix: "CPS", + suffix: t("common_unit_cps"), }, }, { type: "number", name: "codingRate", - label: "Coding Rate", - description: "The denominator of the coding rate", + label: t("config_lora_fieldLabel_codingRate"), + description: t("config_lora_fieldDescription_codingRate"), disabledBy: [ { fieldName: "usePreset", @@ -137,53 +138,52 @@ export const LoRa = () => { ], }, { - label: "Radio Settings", - description: "Settings for the LoRa radio", + label: t("config_lora_groupLabel_radioSettings"), + description: t("config_lora_groupDescription_radioSettings"), fields: [ { type: "toggle", name: "txEnabled", - label: "Transmit Enabled", - description: "Enable/Disable transmit (TX) from the LoRa radio", + label: t("config_lora_fieldLabel_transmitEnabled"), + description: t("config_lora_fieldDescription_transmitEnabled"), }, { type: "number", name: "txPower", - label: "Transmit Power", - description: "Max transmit power", + label: t("config_lora_fieldLabel_transmitPower"), + description: t("config_lora_fieldDescription_transmitPower"), properties: { - suffix: "dBm", + suffix: t("common_unit_dbm"), }, }, { type: "toggle", name: "overrideDutyCycle", - label: "Override Duty Cycle", - description: "Override Duty Cycle", + label: t("config_lora_fieldLabel_overrideDutyCycle"), + description: t("config_lora_fieldDescription_overrideDutyCycle"), }, { type: "number", name: "frequencyOffset", - label: "Frequency Offset", - description: - "Frequency offset to correct for crystal calibration errors", + label: t("config_lora_fieldLabel_frequencyOffset"), + description: t("config_lora_fieldDescription_frequencyOffset"), properties: { - suffix: "Hz", + suffix: t("common_unit_hertz"), }, }, { type: "toggle", name: "sx126xRxBoostedGain", - label: "Boosted RX Gain", - description: "Boosted RX gain", + label: t("config_lora_fieldLabel_boostedRxGain"), + description: t("config_lora_fieldDescription_boostedRxGain"), }, { type: "number", name: "overrideFrequency", - label: "Override Frequency", - description: "Override frequency", + label: t("config_lora_fieldLabel_overrideFrequency"), + description: t("config_lora_fieldDescription_overrideFrequency"), properties: { - suffix: "MHz", + suffix: t("common_unit_megahertz"), step: 0.001, }, }, diff --git a/src/components/PageComponents/Config/Network/index.tsx b/src/components/PageComponents/Config/Network/index.tsx index defbba97..88d5943f 100644 --- a/src/components/PageComponents/Config/Network/index.tsx +++ b/src/components/PageComponents/Config/Network/index.tsx @@ -11,9 +11,11 @@ import { } from "@core/utils/ip.ts"; import { Protobuf } from "@meshtastic/core"; import { validateSchema } from "@app/validation/validate.ts"; +import { useTranslation } from "react-i18next"; export const Network = () => { const { config, setWorkingConfig } = useDevice(); + const { t } = useTranslation(); const onSubmit = (data: NetworkValidation) => { const result = validateSchema(NetworkValidationSchema, data); @@ -63,22 +65,21 @@ export const Network = () => { }} fieldGroups={[ { - label: "WiFi Config", - description: "WiFi radio configuration", - notes: - "Note: Some devices (ESP32) cannot use both Bluetooth and WiFi at the same time.", + label: t("config_network_groupLabel_wifiConfig"), + description: t("config_network_groupDescription_wifiConfig"), + notes: t("config_network_groupNotes_wifiBluetoothNote"), fields: [ { type: "toggle", name: "wifiEnabled", - label: "Enabled", - description: "Enable or disable the WiFi radio", + label: t("config_network_fieldLabel_wifiEnabled"), + description: t("config_network_fieldDescription_wifiEnabled"), }, { type: "text", name: "wifiSsid", - label: "SSID", - description: "Network name", + label: t("config_network_fieldLabel_ssid"), + description: t("config_network_fieldDescription_ssid"), disabledBy: [ { fieldName: "wifiEnabled", @@ -88,8 +89,8 @@ export const Network = () => { { type: "password", name: "wifiPsk", - label: "PSK", - description: "Network password", + label: t("config_network_fieldLabel_psk"), + description: t("config_network_fieldDescription_psk"), disabledBy: [ { fieldName: "wifiEnabled", @@ -99,26 +100,26 @@ export const Network = () => { ], }, { - label: "Ethernet Config", - description: "Ethernet port configuration", + label: t("config_network_groupLabel_ethernetConfig"), + description: t("config_network_groupDescription_ethernetConfig"), fields: [ { type: "toggle", name: "ethEnabled", - label: "Enabled", - description: "Enable or disable the Ethernet port", + label: t("config_network_fieldLabel_ethernetEnabled"), + description: t("config_network_fieldDescription_ethernetEnabled"), }, ], }, { - label: "IP Config", - description: "IP configuration", + label: t("config_network_groupLabel_ipConfig"), + description: t("config_network_groupDescription_ipConfig"), fields: [ { type: "select", name: "addressMode", - label: "Address Mode", - description: "Address assignment selection", + label: t("config_network_fieldLabel_addressMode"), + description: t("config_network_fieldDescription_addressMode"), properties: { enumValue: Protobuf.Config.Config_NetworkConfig_AddressMode, }, @@ -126,8 +127,8 @@ export const Network = () => { { type: "text", name: "ipv4Config.ip", - label: "IP", - description: "IP Address", + label: t("config_network_fieldLabel_ip"), + description: t("config_network_fieldDescription_ip"), disabledBy: [ { fieldName: "addressMode", @@ -139,8 +140,8 @@ export const Network = () => { { type: "text", name: "ipv4Config.gateway", - label: "Gateway", - description: "Default Gateway", + label: t("config_network_fieldLabel_gateway"), + description: t("config_network_fieldDescription_gateway"), disabledBy: [ { fieldName: "addressMode", @@ -152,8 +153,8 @@ export const Network = () => { { type: "text", name: "ipv4Config.subnet", - label: "Subnet", - description: "Subnet Mask", + label: t("config_network_fieldLabel_subnet"), + description: t("config_network_fieldDescription_subnet"), disabledBy: [ { fieldName: "addressMode", @@ -165,8 +166,8 @@ export const Network = () => { { type: "text", name: "ipv4Config.dns", - label: "DNS", - description: "DNS Server", + label: t("config_network_fieldLabel_dns"), + description: t("config_network_fieldDescription_dns"), disabledBy: [ { fieldName: "addressMode", @@ -178,13 +179,13 @@ export const Network = () => { ], }, { - label: "UDP Config", - description: "UDP over Mesh configuration", + label: t("config_network_groupLabel_udpConfig"), + description: t("config_network_groupDescription_udpConfig"), fields: [ { type: "select", name: "enabledProtocols", - label: "Mesh via UDP", + label: t("config_network_fieldLabel_meshViaUdp"), properties: { enumValue: Protobuf.Config.Config_NetworkConfig_ProtocolFlags, formatEnumName: true, @@ -193,24 +194,24 @@ export const Network = () => { ], }, { - label: "NTP Config", - description: "NTP configuration", + label: t("config_network_groupLabel_ntpConfig"), + description: t("config_network_groupDescription_ntpConfig"), fields: [ { type: "text", name: "ntpServer", - label: "NTP Server", + label: t("config_network_fieldLabel_ntpServer"), }, ], }, { - label: "Rsyslog Config", - description: "Rsyslog configuration", + label: t("config_network_groupLabel_rsyslogConfig"), + description: t("config_network_groupDescription_rsyslogConfig"), fields: [ { type: "text", name: "rsyslogServer", - label: "Rsyslog Server", + label: t("config_network_fieldLabel_rsyslogServer"), }, ], }, diff --git a/src/components/PageComponents/Config/Position.tsx b/src/components/PageComponents/Config/Position.tsx index 3792b9e8..c1d24e77 100644 --- a/src/components/PageComponents/Config/Position.tsx +++ b/src/components/PageComponents/Config/Position.tsx @@ -8,12 +8,14 @@ import { DynamicForm } from "@components/Form/DynamicForm.tsx"; import { useDevice } from "@core/stores/deviceStore.ts"; import { Protobuf } from "@meshtastic/core"; import { useCallback } from "react"; +import { useTranslation } from "react-i18next"; export const Position = () => { const { config, setWorkingConfig } = useDevice(); const { flagsValue, activeFlags, toggleFlag, getAllFlags } = usePositionFlags( config?.position?.positionFlags ?? 0, ); + const { t } = useTranslation(); const onSubmit = (data: PositionValidation) => { return setWorkingConfig( @@ -42,22 +44,22 @@ export const Position = () => { defaultValues={config.position} fieldGroups={[ { - label: "Position Settings", - description: "Settings for the position module", + label: t("config_position_groupLabel_positionSettings"), + description: t("config_position_groupDescription_positionSettings"), fields: [ { type: "toggle", name: "positionBroadcastSmartEnabled", - label: "Enable Smart Position", - description: - "Only send position when there has been a meaningful change in location", + label: t("config_position_fieldLabel_smartPositionEnabled"), + description: t( + "config_position_fieldDescription_smartPositionEnabled", + ), }, { type: "select", name: "gpsMode", - label: "GPS Mode", - description: - "Configure whether device GPS is Enabled, Disabled, or Not Present", + label: t("config_position_fieldLabel_gpsMode"), + description: t("config_position_fieldDescription_gpsMode"), properties: { enumValue: Protobuf.Config.Config_PositionConfig_GpsMode, }, @@ -65,9 +67,8 @@ export const Position = () => { { type: "toggle", name: "fixedPosition", - label: "Fixed Position", - description: - "Don't report GPS position, but a manually-specified one", + label: t("config_position_fieldLabel_fixedPosition"), + description: t("config_position_fieldDescription_fixedPosition"), }, { type: "multiSelect", @@ -76,10 +77,11 @@ export const Position = () => { isChecked: (name: string) => activeFlags?.includes(name as FlagName) ?? false, onValueChange: onPositonFlagChange, - label: "Position Flags", - placeholder: "Select position flags...", - description: - "Optional fields to include when assembling position messages. The more fields are selected, the larger the message will be leading to longer airtime usage and a higher risk of packet loss.", + label: t("config_position_fieldLabel_positionFlags"), + placeholder: t( + "config_position_fieldPlaceholder_selectPositionFlags", + ), + description: t("config_position_fieldDescription_positionFlags"), properties: { enumValue: getAllFlags(), }, @@ -87,51 +89,56 @@ export const Position = () => { { type: "number", name: "rxGpio", - label: "Receive Pin", - description: "GPS module RX pin override", + label: t("config_position_fieldLabel_receivePin"), + description: t("config_position_fieldDescription_receivePin"), }, { type: "number", name: "txGpio", - label: "Transmit Pin", - description: "GPS module TX pin override", + label: t("config_position_fieldLabel_transmitPin"), + description: t("config_position_fieldDescription_transmitPin"), }, { type: "number", name: "gpsEnGpio", - label: "Enable Pin", - description: "GPS module enable pin override", + label: t("config_position_fieldLabel_enablePin"), + description: t("config_position_fieldDescription_enablePin"), }, ], }, { - label: "Intervals", - description: "How often to send position updates", + label: t("config_position_groupLabel_intervals"), + description: t("config_position_groupDescription_intervals"), fields: [ { type: "number", name: "positionBroadcastSecs", - label: "Broadcast Interval", - description: "How often your position is sent out over the mesh", + label: t("config_position_fieldLabel_broadcastInterval"), + description: t( + "config_position_fieldDescription_broadcastInterval", + ), properties: { - suffix: "Seconds", + suffix: t("common_unit_seconds"), }, }, { type: "number", name: "gpsUpdateInterval", - label: "GPS Update Interval", - description: "How often a GPS fix should be acquired", + label: t("config_position_fieldLabel_gpsUpdateInterval"), + description: t( + "config_position_fieldDescription_gpsUpdateInterval", + ), properties: { - suffix: "Seconds", + suffix: t("common_unit_seconds"), }, }, { type: "number", name: "broadcastSmartMinimumDistance", - label: "Smart Position Minimum Distance", - description: - "Minimum distance (in meters) that must be traveled before a position update is sent", + label: t("config_position_fieldLabel_smartPositionMinDistance"), + description: t( + "config_position_fieldDescription_smartPositionMinDistance", + ), disabledBy: [ { fieldName: "positionBroadcastSmartEnabled", @@ -141,9 +148,10 @@ export const Position = () => { { type: "number", name: "broadcastSmartMinimumIntervalSecs", - label: "Smart Position Minimum Interval", - description: - "Minimum interval (in seconds) that must pass before a position update is sent", + label: t("config_position_fieldLabel_smartPositionMinInterval"), + description: t( + "config_position_fieldDescription_smartPositionMinInterval", + ), disabledBy: [ { fieldName: "positionBroadcastSmartEnabled", diff --git a/src/components/PageComponents/Config/Power.tsx b/src/components/PageComponents/Config/Power.tsx index cd88eafb..34747553 100644 --- a/src/components/PageComponents/Config/Power.tsx +++ b/src/components/PageComponents/Config/Power.tsx @@ -1,11 +1,13 @@ -import type { PowerValidation } from "@app/validation/config/power.tsx"; +import type { PowerValidation } from "@app/validation/config/power.ts"; import { create } from "@bufbuild/protobuf"; import { DynamicForm } from "@components/Form/DynamicForm.tsx"; import { useDevice } from "@core/stores/deviceStore.ts"; import { Protobuf } from "@meshtastic/core"; +import { useTranslation } from "react-i18next"; export const Power = () => { const { config, setWorkingConfig } = useDevice(); + const { t } = useTranslation(); const onSubmit = (data: PowerValidation) => { setWorkingConfig( @@ -24,31 +26,35 @@ export const Power = () => { defaultValues={config.power} fieldGroups={[ { - label: "Power Config", - description: "Settings for the power module", + label: t("config_power_groupLabel_powerConfig"), + description: t("config_power_groupDescription_powerConfig"), fields: [ { type: "toggle", name: "isPowerSaving", - label: "Enable power saving mode", - description: - "Select if powered from a low-current source (i.e. solar), to minimize power consumption as much as possible.", + label: t("config_power_fieldLabel_powerSavingEnabled"), + description: t( + "config_power_fieldDescription_powerSavingEnabled", + ), }, { type: "number", name: "onBatteryShutdownAfterSecs", - label: "Shutdown on battery delay", - description: - "Automatically shutdown node after this long when on battery, 0 for indefinite", + label: t("config_power_fieldLabel_shutdownOnBatteryDelay"), + description: t( + "config_power_fieldDescription_shutdownOnBatteryDelay", + ), properties: { - suffix: "Seconds", + suffix: t("common_unit_seconds"), }, }, { type: "number", name: "adcMultiplierOverride", - label: "ADC Multiplier Override ratio", - description: "Used for tweaking battery voltage reading", + label: t("config_power_fieldLabel_adcMultiplierOverride"), + description: t( + "config_power_fieldDescription_adcMultiplierOverride", + ), properties: { step: 0.0001, }, @@ -56,52 +62,55 @@ export const Power = () => { { type: "number", name: "waitBluetoothSecs", - label: "No Connection Bluetooth Disabled", - description: - "If the device does not receive a Bluetooth connection, the BLE radio will be disabled after this long", + label: t("config_power_fieldLabel_noConnectionBluetoothDisabled"), + description: t( + "config_power_fieldDescription_noConnectionBluetoothDisabled", + ), properties: { - suffix: "Seconds", + suffix: t("common_unit_seconds"), }, }, { type: "number", name: "deviceBatteryInaAddress", - label: "INA219 Address", - description: "Address of the INA219 battery monitor", + label: t("config_power_fieldLabel_ina219Address"), + description: t("config_power_fieldDescription_ina219Address"), }, ], }, { - label: "Sleep Settings", - description: "Sleep settings for the power module", + label: t("config_power_groupLabel_sleepSettings"), + description: t("config_power_groupDescription_sleepSettings"), fields: [ { type: "number", name: "sdsSecs", - label: "Super Deep Sleep Duration", - description: - "How long the device will be in super deep sleep for", + label: t("config_power_fieldLabel_superDeepSleepDuration"), + description: t( + "config_power_fieldDescription_superDeepSleepDuration", + ), properties: { - suffix: "Seconds", + suffix: t("common_unit_seconds"), }, }, { type: "number", name: "lsSecs", - label: "Light Sleep Duration", - description: "How long the device will be in light sleep for", + label: t("config_power_fieldLabel_lightSleepDuration"), + description: t( + "config_power_fieldDescription_lightSleepDuration", + ), properties: { - suffix: "Seconds", + suffix: t("common_unit_seconds"), }, }, { type: "number", name: "minWakeSecs", - label: "Minimum Wake Time", - description: - "Minimum amount of time the device will stay awake for after receiving a packet", + label: t("config_power_fieldLabel_minimumWakeTime"), + description: t("config_power_fieldDescription_minimumWakeTime"), properties: { - suffix: "Seconds", + suffix: t("common_unit_seconds"), }, }, ], diff --git a/src/components/PageComponents/Config/Security/Security.tsx b/src/components/PageComponents/Config/Security/Security.tsx index 263da557..47dcd85a 100644 --- a/src/components/PageComponents/Config/Security/Security.tsx +++ b/src/components/PageComponents/Config/Security/Security.tsx @@ -10,6 +10,7 @@ import { fromByteArray, toByteArray } from "base64-js"; import { useReducer } from "react"; import { securityReducer } from "@components/PageComponents/Config/Security/securityReducer.tsx"; import type { SecurityConfigInit } from "./types.ts"; +import { useTranslation } from "react-i18next"; export const Security = () => { const { config, setWorkingConfig, setDialogOpen } = useDevice(); @@ -21,6 +22,7 @@ export const Security = () => { removeError, clearErrors, } = useAppStore(); + const { t } = useTranslation(); const [state, dispatch] = useReducer(securityReducer, { privateKey: fromByteArray(config.security?.privateKey ?? new Uint8Array(0)), @@ -50,7 +52,10 @@ export const Security = () => { try { removeError(fieldNameKey); if (fieldName === "privateKey" && input === "") { - addError(fieldNameKey, "Private Key is required"); + addError( + fieldNameKey, + t("config_security_validation_privateKeyRequired"), + ); return; } @@ -62,7 +67,7 @@ export const Security = () => { ) { addError( "adminKey0", - "At least one admin key is requred if the node is managed.", + t("config_security_validation_adminKeyRequiredWhenManaged"), ); } @@ -72,25 +77,30 @@ export const Security = () => { if (input.length % 4 !== 0) { addError( fieldNameKey, - `${ - fieldName === "privateKey" ? "Private" : "Admin" - } Key is required to be a 256 bit pre-shared key (PSK)`, + fieldName === "privateKey" + ? t("config_security_validation_privateKeyMustBe256BitPsk") + : t("config_security_validation_adminKeyMustBe256BitPsk"), ); return; } const decoded = toByteArray(input); if (decoded.length !== count) { - addError(fieldNameKey, `Please enter a valid ${count * 8} bit PSK`); + addError( + fieldNameKey, + t("config_security_validation_enterValid256BitPsk", { + bits: count * 8, + }), + ); return; } } catch (e) { console.error(e); addError( fieldNameKey, - `Invalid ${ - fieldName === "privateKey" ? "Private" : "Admin" - } Key format`, + fieldName === "privateKey" + ? t("config_security_validation_invalidPrivateKeyFormat") + : t("config_security_validation_invalidAdminKeyFormat"), ); } }; @@ -207,10 +217,10 @@ export const Security = () => { if ( field === "isManaged" && state.adminKey.every((s) => s === "") ) { - if (next) { + if (next) { // If enabling 'managed' and no admin keys are set addError( "adminKey0", - "At least one admin key is requred if the node is managed.", + t("config_security_validation_adminKeyRequiredWhenManaged"), ); } else { removeError("adminKey0"); @@ -252,16 +262,20 @@ export const Security = () => { }} fieldGroups={[ { - label: "Security Settings", - description: "Settings for the Security configuration", + label: t("config_security_groupLabel_securitySettings"), + description: t("config_security_groupDescription_securitySettings"), fields: [ { type: "passwordGenerator", id: "pskInput", name: "privateKey", - label: "Private Key", - description: "Used to create a shared key with a remote device", - bits: [{ text: "256 bit", value: "32", key: "bit256" }], + label: t("config_security_fieldLabel_privateKey"), + description: t("config_security_fieldDescription_privateKey"), + bits: [{ + text: t("config_security_option_256bit"), + value: "32", + key: "bit256", + }], validationText: hasFieldError("privateKey") ? getErrorMessage("privateKey") : "", @@ -271,7 +285,7 @@ export const Security = () => { hide: !state.privateKeyVisible, actionButtons: [ { - text: "Generate", + text: t("common_button_generate"), onClick: () => dispatch({ type: "SHOW_PRIVATE_KEY_DIALOG", @@ -280,7 +294,7 @@ export const Security = () => { variant: "success", }, { - text: "Backup Key", + text: t("config_security_button_backupKey"), onClick: () => setDialogOpen("pkiBackup", true), variant: "subtle", }, @@ -294,10 +308,9 @@ export const Security = () => { { type: "text", name: "publicKey", - label: "Public Key", + label: t("config_security_fieldLabel_publicKey"), disabled: true, - description: - "Sent out to other nodes on the mesh to allow them to compute a shared secret key", + description: t("config_security_fieldDescription_publicKey"), properties: { value: state.publicKey, showCopyButton: true, @@ -306,22 +319,27 @@ export const Security = () => { ], }, { - label: "Admin Settings", - description: "Settings for Admin", + label: t("config_security_groupLabel_adminSettings"), + description: t("config_security_groupDescription_adminSettings"), fields: [ { type: "passwordGenerator", name: "adminKey.0", id: "adminKey0Input", - label: "Primary Admin Key", - description: - "The primary public key authorized to send admin messages to this node", + label: t("config_security_fieldLabel_primaryAdminKey"), + description: t( + "config_security_fieldDescription_primaryAdminKey", + ), validationText: hasFieldError("adminKey0") ? getErrorMessage("adminKey0") : "", inputChange: (e) => adminKeyInputChangeEvent(e, 0), selectChange: () => {}, - bits: [{ text: "256 bit", value: "32", key: "bit256" }], + bits: [{ + text: t("config_security_option_256bit"), + value: "32", + key: "bit256", + }], devicePSKBitCount: state.privateKeyBitCount, hide: !state.adminKeyVisible[0], actionButtons: [], @@ -338,15 +356,20 @@ export const Security = () => { type: "passwordGenerator", name: "adminKey.1", id: "adminKey1Input", - label: "Secondary Admin Key", - description: - "The secondary public key authorized to send admin messages to this node", + label: t("config_security_fieldLabel_secondaryAdminKey"), + description: t( + "config_security_fieldDescription_secondaryAdminKey", + ), validationText: hasFieldError("adminKey1") ? getErrorMessage("adminKey1") : "", inputChange: (e) => adminKeyInputChangeEvent(e, 1), selectChange: () => {}, - bits: [{ text: "256 bit", value: "32", key: "bit256" }], + bits: [{ + text: t("config_security_option_256bit"), + value: "32", + key: "bit256", + }], devicePSKBitCount: state.privateKeyBitCount, hide: !state.adminKeyVisible[1], actionButtons: [], @@ -363,15 +386,20 @@ export const Security = () => { type: "passwordGenerator", name: "adminKey.2", id: "adminKey2Input", - label: "Tertiary Admin Key", - description: - "The tertiary public key authorized to send admin messages to this node", + label: t("config_security_fieldLabel_tertiaryAdminKey"), + description: t( + "config_security_fieldDescription_tertiaryAdminKey", + ), validationText: hasFieldError("adminKey2") ? getErrorMessage("adminKey2") : "", inputChange: (e) => adminKeyInputChangeEvent(e, 2), selectChange: () => {}, - bits: [{ text: "256 bit", value: "32", key: "bit256" }], + bits: [{ + text: t("config_security_option_256bit"), + value: "32", + key: "bit256", + }], devicePSKBitCount: state.privateKeyBitCount, hide: !state.adminKeyVisible[2], actionButtons: [], @@ -387,9 +415,8 @@ export const Security = () => { { type: "toggle", name: "isManaged", - label: "Managed", - description: - "If enabled, device configuration options are only able to be changed remotely by a Remote Admin node via admin messages. Do not enable this option unless at least one suitable Remote Admin node has been setup, and the public key is stored in one of the fields above.", + label: t("config_security_fieldLabel_managed"), + description: t("config_security_fieldDescription_managed"), inputChange: (e: boolean) => onToggleChange("isManaged", e), properties: { checked: state.isManaged, @@ -404,9 +431,10 @@ export const Security = () => { { type: "toggle", name: "adminChannelEnabled", - label: "Allow Legacy Admin", - description: - "Allow incoming device control over the insecure legacy admin channel", + label: t("config_security_fieldLabel_allowLegacyAdmin"), + description: t( + "config_security_fieldDescription_adminChannelEnabled", + ), inputChange: (e: boolean) => onToggleChange("adminChannelEnabled", e), properties: { @@ -416,15 +444,16 @@ export const Security = () => { ], }, { - label: "Logging Settings", - description: "Settings for Logging", + label: t("config_security_groupLabel_loggingSettings"), + description: t("config_security_groupDescription_loggingSettings"), fields: [ { type: "toggle", name: "debugLogApiEnabled", - label: "Enable Debug Log API", - description: - "Output live debug logging over serial, view and export position-redacted device logs over Bluetooth", + label: t("config_security_fieldLabel_enableDebugLogApi"), + description: t( + "config_security_fieldDescription_enableDebugLogApi", + ), inputChange: (e: boolean) => onToggleChange("debugLogApiEnabled", e), properties: { @@ -434,8 +463,10 @@ export const Security = () => { { type: "toggle", name: "serialEnabled", - label: "Serial Output Enabled", - description: "Serial Console over the Stream API", + label: t("config_security_fieldLabel_serialOutputEnabled"), + description: t( + "config_security_fieldDescription_serialOutputEnabled", + ), inputChange: (e: boolean) => onToggleChange("serialEnabled", e), properties: { checked: state.serialEnabled, @@ -447,9 +478,9 @@ export const Security = () => { /> diff --git a/src/components/PageComponents/Connect/BLE.tsx b/src/components/PageComponents/Connect/BLE.tsx index 5abd907d..59b91322 100644 --- a/src/components/PageComponents/Connect/BLE.tsx +++ b/src/components/PageComponents/Connect/BLE.tsx @@ -8,6 +8,7 @@ import { randId } from "@core/utils/randId.ts"; import { BleConnection, ServiceUuid } from "@meshtastic/js"; import { useCallback, useEffect, useState } from "react"; import { useMessageStore } from "../../../core/stores/messageStore/index.ts"; +import { useTranslation } from "react-i18next"; export const BLE = ( { closeDialog }: TabElementProps, @@ -17,6 +18,7 @@ export const BLE = ( const { addDevice } = useDeviceStore(); const messageStore = useMessageStore(); const { setSelectedDevice } = useAppStore(); + const { t } = useTranslation(); const updateBleDeviceList = useCallback(async (): Promise => { setBleDevices(await navigator.bluetooth.getDevices()); @@ -59,7 +61,9 @@ export const BLE = ( ))} {bleDevices.length === 0 && ( - No devices paired yet. + + {t("bluetoothConnection.noDevicesPaired")} + )}
); diff --git a/src/components/PageComponents/Connect/Serial.tsx b/src/components/PageComponents/Connect/Serial.tsx index f388870a..adc1919b 100644 --- a/src/components/PageComponents/Connect/Serial.tsx +++ b/src/components/PageComponents/Connect/Serial.tsx @@ -8,6 +8,7 @@ import { randId } from "@core/utils/randId.ts"; import { MeshDevice } from "@meshtastic/core"; import { TransportWebSerial } from "@meshtastic/transport-web-serial"; import { useCallback, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; import { useMessageStore } from "../../../core/stores/messageStore/index.ts"; export const Serial = ( @@ -18,6 +19,7 @@ export const Serial = ( const { addDevice } = useDeviceStore(); const messageStore = useMessageStore(); const { setSelectedDevice } = useAppStore(); + const { t } = useTranslation(); const updateSerialPortList = useCallback(async () => { setSerialPorts(await navigator?.serial.getPorts()); @@ -54,24 +56,31 @@ export const Serial = (
{serialPorts.map((port, index) => { const { usbProductId, usbVendorId } = port.getInfo(); + const vendor = usbVendorId ?? t("common.unknown"); + const product = usbProductId ?? t("common.unknown"); return ( ); })} {serialPorts.length === 0 && ( - No devices paired yet. + + {t("serialConnection.noDevicesPaired")} + )}
); diff --git a/src/components/PageComponents/Map/NodeDetail.tsx b/src/components/PageComponents/Map/NodeDetail.tsx index 91efd128..36fd60e8 100644 --- a/src/components/PageComponents/Map/NodeDetail.tsx +++ b/src/components/PageComponents/Map/NodeDetail.tsx @@ -26,8 +26,9 @@ import { useDevice } from "@core/stores/deviceStore.ts"; import { MessageType, useMessageStore, -} from "../../../core/stores/messageStore/index.ts"; +} from "@core/stores/messageStore/index.ts"; import BatteryStatus from "@components/BatteryStatus.tsx"; +import { useTranslation } from "react-i18next"; export interface NodeDetailProps { node: ProtobufType.Mesh.NodeInfo; @@ -35,13 +36,19 @@ export interface NodeDetailProps { export const NodeDetail = ({ node }: NodeDetailProps) => { const { setChatType, setActiveChat } = useMessageStore(); + const { t } = useTranslation(); const { setActivePage } = useDevice(); - const name = node.user?.longName ?? `UNK`; - const shortName = node.user?.shortName ?? "UNK"; + const name = node.user?.longName ?? t("common.unknown"); + const shortName = node.user?.shortName ?? t("common.unknown"); const hwModel = node.user?.hwModel ?? 0; - const hardwareType = - Protobuf.Mesh.HardwareModel[hwModel]?.replaceAll("_", " ") ?? `${hwModel}`; - + const rawHardwareType = Protobuf.Mesh.HardwareModel[hwModel] as + | keyof typeof Protobuf.Mesh.HardwareModel + | undefined; + const hardwareType = rawHardwareType + ? rawHardwareType === "UNSET" + ? t("common.unset") + : rawHardwareType.replaceAll("_", " ") + : `${hwModel}`; function handleDirectMessage() { setChatType(MessageType.Direct); setActiveChat(node.num); @@ -66,7 +73,7 @@ export const NodeDetail = ({ node }: NodeDetailProps) => { className="text-green-600 mb-1.5" size={12} strokeWidth={3} - aria-label="Public Key Enabled" + aria-label={t("node_detail_public_key_enabled_aria_label")} /> ) : ( @@ -74,7 +81,7 @@ export const NodeDetail = ({ node }: NodeDetailProps) => { className="text-yellow-500 mb-1.5" size={12} strokeWidth={3} - aria-label="No Public Key" + aria-label={t("node_detail_no_public_key_aria_label")} /> )} @@ -94,7 +101,9 @@ export const NodeDetail = ({ node }: NodeDetailProps) => { align="center" sideOffset={5} > - Direct Message {shortName} + {t("node_detail_direct_message_tooltip", { + shortName, + })} @@ -103,22 +112,30 @@ export const NodeDetail = ({ node }: NodeDetailProps) => {
{name} - - {hardwareType !== "UNSET" && {hardwareType}} + {hardwareType !== t("common.unset") && {hardwareType} + } {!!node.deviceMetrics?.batteryLevel && ( )}
- {node.user?.shortName &&
"{node.user?.shortName}"
} + {node.user?.shortName && ( +
+ {t("node_detail_short_name_display_format", { + name: node.user?.shortName, + })} +
+ )} {node.user?.id &&
{node.user?.id}
}
@@ -131,13 +148,14 @@ export const NodeDetail = ({ node }: NodeDetailProps) => {
{node.lastHeard > 0 && (
- Heard + {t("node_detail_status_heard")}{" "} +
)}
{node.viaMqtt && (
- MQTT + {t("node_detail_status_mqtt")}
)}
@@ -149,21 +167,27 @@ export const NodeDetail = ({ node }: NodeDetailProps) => {
- {Number.isNaN(node.hopsAway) ? "?" : node.hopsAway} + {Number.isNaN(node.hopsAway) + ? t("node_detail_hops_unknown") + : node.hopsAway} +
+
+ {node.hopsAway === 1 + ? t("node_detail_hops_label_one") + : t("node_detail_hops_label_other")}
-
{node.hopsAway === 1 ? "Hop" : "Hops"}
{node.position?.altitude && (
{formatQuantity(node.position?.altitude, { - one: "meter", - other: "meters", + one: t("node_detail_altitude_unit_one"), + other: t("node_detail_altitude_unit_other"), })}
@@ -173,7 +197,7 @@ export const NodeDetail = ({ node }: NodeDetailProps) => {
{!!node.deviceMetrics?.channelUtilization && (
-
Channel Util
+
{t("node_detail_channel_util_label")}
{node.deviceMetrics?.channelUtilization.toPrecision(3)}% @@ -181,7 +205,7 @@ export const NodeDetail = ({ node }: NodeDetailProps) => { )} {!!node.deviceMetrics?.airUtilTx && (
-
Airtime Util
+
{t("node_detail_airtime_util_label")}
{node.deviceMetrics?.airUtilTx.toPrecision(3)}% @@ -191,13 +215,15 @@ export const NodeDetail = ({ node }: NodeDetailProps) => { {node.snr !== 0 && (
-
SNR
+
{t("node_detail_snr_label")}
- {node.snr}db + {node.snr} + {t("common.dbUnit")} {Math.min(Math.max((node.snr + 10) * 5, 0), 100)}% - {(node.snr + 10) * 5}raw + {(node.snr + 10) * 5} + {t("common.rawUnit")}
)} diff --git a/src/components/PageComponents/Messages/ChannelChat.tsx b/src/components/PageComponents/Messages/ChannelChat.tsx index a929e166..58a75473 100644 --- a/src/components/PageComponents/Messages/ChannelChat.tsx +++ b/src/components/PageComponents/Messages/ChannelChat.tsx @@ -1,17 +1,21 @@ import { MessageItem } from "@components/PageComponents/Messages/MessageItem.tsx"; import { InboxIcon } from "lucide-react"; import { Message } from "@core/stores/messageStore/types.ts"; +import { useTranslation } from "react-i18next"; export interface ChannelChatProps { messages?: Message[]; } -const EmptyState = () => ( -
- - No Messages -
-); +const EmptyState = () => { + const { t } = useTranslation(); + return ( +
+ + {t("messages.empty")} +
+ ); +}; export const ChannelChat = ({ messages = [] }: ChannelChatProps) => { if (!messages?.length) { diff --git a/src/components/PageComponents/Messages/MessageActionsMenu.tsx b/src/components/PageComponents/Messages/MessageActionsMenu.tsx index 777128aa..ac949090 100644 --- a/src/components/PageComponents/Messages/MessageActionsMenu.tsx +++ b/src/components/PageComponents/Messages/MessageActionsMenu.tsx @@ -7,6 +7,7 @@ import { } from "@components/UI/Tooltip.tsx"; import { cn } from "@core/utils/cn.ts"; import { Reply, SmilePlus } from "lucide-react"; +import { useTranslation } from "react-i18next"; interface MessageActionsMenuProps { onAddReaction?: () => void; @@ -17,6 +18,7 @@ export const MessageActionsMenu = ({ onAddReaction, onReply, }: MessageActionsMenuProps) => { + const { t } = useTranslation(); const hoverIconBarClass = cn( "absolute top-2 right-2", "flex items-center gap-x-1", @@ -48,7 +50,7 @@ export const MessageActionsMenu = ({ - Add Reaction + {t("messages.actionsMenu.addReactionLabel")} @@ -70,7 +72,7 @@ export const MessageActionsMenu = ({ - Reply + {t("messages.actionsMenu.replyLabel")} diff --git a/src/components/PageComponents/Messages/TraceRoute.tsx b/src/components/PageComponents/Messages/TraceRoute.tsx index e7578247..d5a9f4af 100644 --- a/src/components/PageComponents/Messages/TraceRoute.tsx +++ b/src/components/PageComponents/Messages/TraceRoute.tsx @@ -1,6 +1,7 @@ import { useDevice } from "@core/stores/deviceStore.ts"; import type { Protobuf } from "@meshtastic/core"; import { numberToHexUnpadded } from "@noble/curves/abstract/utils"; +import { useTranslation } from "react-i18next"; export interface TraceRouteProps { from?: Protobuf.Mesh.NodeInfo; @@ -23,6 +24,7 @@ const RoutePath = ( { title, startNode, endNode, path, snr }: RoutePathProps, ) => { const { getNode } = useDevice(); + const { t } = useTranslation(); return (

{title}

{startNode?.user?.longName}

-

↓ {snr?.[0] ?? "??"}dB

+

+ ↓ {snr?.[0] ?? t("traceRoute_snrUnknown")} + {t("common.dbUnit")} +

{path.map((hop, i) => (

- {getNode(hop)?.user?.longName ?? `!${numberToHexUnpadded(hop)}`} + {getNode(hop)?.user?.longName ?? + `${t("traceRoute_nodeUnknownPrefix")}${numberToHexUnpadded(hop)}`} +

+

+ ↓ {snr?.[i + 1] ?? t("traceRoute_snrUnknown")} + {t("common.dbUnit")}

-

↓ {snr?.[i + 1] ?? "??"}dB

))}

{endNode?.user?.longName}

@@ -53,18 +62,19 @@ export const TraceRoute = ({ snrTowards, snrBack, }: TraceRouteProps) => { + const { t } = useTranslation(); return (
- {routeBack && ( + {routeBack && routeBack.length > 0 && ( { const { isCollapsed, toggleSidebar } = useSidebar(); - const buttonLabel = isCollapsed ? "Open sidebar" : "Close sidebar"; + const { t } = useTranslation(); + const buttonLabel = isCollapsed + ? t("sidebar.collapseToggle.button_open") + : t("sidebar.collapseToggle.button_close"); return (
- + {pages.map((link) => ( { isCollapsed ? "opacity-0 invisible" : "opacity-100 visible", )} > - Loading... + {t("common.loading")}
) @@ -221,9 +236,10 @@ export const Sidebar = ({ children }: SidebarProps) => { className="text-gray-500 dark:text-gray-400 w-4 flex-shrink-0" /> - {myNode.deviceMetrics?.voltage?.toPrecision(3) ?? "UNK"} - {" "} - volts + {t("sidebar.deviceInfo.volts", { + voltage: myNode.deviceMetrics?.voltage?.toPrecision(3) ?? + t("common.unknown"), + })}
@@ -231,7 +247,12 @@ export const Sidebar = ({ children }: SidebarProps) => { size={18} className="text-gray-500 dark:text-gray-400 w-4 flex-shrink-0" /> - v{myMetadata?.firmwareVersion ?? "UNK"} + + {t("sidebar.deviceInfo.firmwareVersion", { + version: myMetadata?.firmwareVersion ?? + t("common.unknown"), + })} +
{ > @@ -145,60 +147,69 @@ export function FilterControl({
- + {(parameters?.showTextSearch ?? true) && (
)} - Number of hops: {localFilterState.hopsAway[0] === 0 - ? "Direct" - : localFilterState.hopsAway[0]} + {t("filter_control_slider_hops_labelText", { + value: localFilterState.hopsAway[0] === 0 + ? t("filter_control_slider_hops_directLabel") + : localFilterState.hopsAway[0], + })} {localFilterState.hopsAway[0] !== localFilterState.hopsAway[1] - ? " — " + localFilterState.hopsAway[1] + ? ` — ${localFilterState.hopsAway[1]}` : ""} } /> - Last heard:
- {localFilterState.lastHeard[0] === 0 ? "Now" : ( - <> - {localFilterState.lastHeard[0] === - defaultFilterValues.lastHeard[1] && ">"} - {formatTS(localFilterState.lastHeard[0])} - - )} + {t("filter_control_slider_lastHeard_labelText", { + value: "", + })} +
+ {localFilterState.lastHeard[0] === 0 + ? t("filter_control_slider_lastHeard_nowLabel") + : ( + <> + {localFilterState.lastHeard[0] === + defaultFilterValues.lastHeard[1] && ">"} + {formatTS(localFilterState.lastHeard[0])} + + )} {localFilterState.lastHeard[0] !== localFilterState.lastHeard[1] && ( <> @@ -212,60 +223,70 @@ export function FilterControl({ } />
- + - Battery level (%): {localFilterState.batteryLevel[0] === 101 - ? "Plugged in" - : localFilterState.batteryLevel[0]} + {t("filter_control_slider_batteryLevel_labelText", { + value: localFilterState.batteryLevel[0] === 101 + ? t("common.batteryStatus.pluggedIn") + : localFilterState.batteryLevel[0], + })} {localFilterState.batteryLevel[0] !== localFilterState.batteryLevel[1] && ( <> {" – "} {localFilterState.batteryLevel[1] === 101 - ? "Plugged in" + ? t("common.batteryStatus.pluggedIn") : localFilterState.batteryLevel[1]} )} @@ -273,15 +294,16 @@ export function FilterControl({ } /> - - + - + - Reset Filters + {t("filter_control_button_resetFilters_label")} {children && (
diff --git a/src/i18n.ts b/src/i18n.ts new file mode 100644 index 00000000..501ae388 --- /dev/null +++ b/src/i18n.ts @@ -0,0 +1,28 @@ +import i18next from "i18next"; +import { initReactI18next } from "react-i18next"; +import Backend from "i18next-http-backend"; +import LanguageDetector from "i18next-browser-languagedetector"; + +export const supportedLngs = { + en: "English", +}; + +i18next + .use(Backend) + .use(initReactI18next) + .use(LanguageDetector) + .init({ + backend: { + // this will lazy load resources from the i8n folder + loadPath: "/src/i18n/locales/{{lng}}.json", + }, + react: { + useSuspense: true, + }, + fallbackLng: "en", + debug: !!import.meta.env.DEV, + supportedLngs: Object.keys(supportedLngs), + detection: { + order: ["navigator", "localStorage"], + }, + }); diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json new file mode 100644 index 00000000..784bf181 --- /dev/null +++ b/src/i18n/locales/en.json @@ -0,0 +1,576 @@ +{ + "common_title": "Meshtastic Web", + "common_header": "Meshtastic", + "common_description": "Meshtastic Web Client", + "common_button_close": "Close", + "common_button_ok": "OK", + "common_button_cancel": "Cancel", + "common_button_delete": "Delete", + "common_button_import": "Import", + "common_button_export": "Export", + "common_button_scanQr": "Scan QR Code", + "common_button_generate": "Generate", + "common_button_save": "Save", + "common_label_primary": "Primary", + "common_loading": "Loading...", + "common_unknown": "UNK", + "common_unset": "UNSET", + + "common_unit_volts": "Volts", + "common_unit_cps": "CPS", + "common_unit_dbm": "dBm", + "common_unit_hertz": "Hz", + "common_unit_megahertz": "MHz", + "common_unit_seconds": "Seconds", + "common_unit_raw": "raw", + "common_batteryStatus_title": "Battery", + "common_batteryStatus_pluggedIn": "Plugged in", + "common_batteryStatus_charging": "{{level}}% charging", + + "navigation_title": "Navigation", + "navigation_title_messages": "Messages", + "navigation_title_map": "Map", + "navigation_title_radioConfig": "Radio Config", + "navigation_title_moduleConfig": "Module Config", + "navigation_title_channels": "Channels", + "navigation_title_nodes": "Nodes", + + "dialog_button_apply": "Apply", + "dialog_button_clearMessages": "Clear Messages", + "dialog_button_confirm": "Confirm", + "dialog_button_dismiss": "Dismiss", + "dialog_button_download": "Download", + "dialog_button_message": "Message", + "dialog_button_now": "Now", + "dialog_button_print": "Print", + "dialog_button_rebootOtaNow": "Reboot to OTA Mode Now", + "dialog_button_remove": "Remove", + "dialog_button_requestNewKeys": "Request New Keys", + "dialog_button_requestPosition": "Request Position", + "dialog_button_reset": "Reset", + "dialog_button_traceRoute": "Trace Route", + "dialog_deleteMessages_description": "This action will clear all message history. This cannot be undone. Are you sure you want to continue?", + "dialog_deleteMessages_title": "Clear All Messages", + "dialog_deviceName_description": "The Device will restart once the config is saved.", + "dialog_deviceName_label_longName": "Long Name", + "dialog_deviceName_label_shortName": "Short Name", + "dialog_deviceName_title": "Change Device Name", + "dialog_import_description": "The current LoRa configuration will be overridden.", + "dialog_import_error_invalidUrl": "Invalid Meshtastic URL", + "dialog_import_label_channelPrefix": "Channel: ", + "dialog_import_label_channelSetUrl": "Channel Set/QR Code URL", + "dialog_import_label_channels": "Channels:", + "dialog_import_label_usePreset": "Use Preset?", + "dialog_import_title": "Import Channel Set", + "dialog_locationResponse_label_altitude": "Altitude: ", + "dialog_locationResponse_label_coordinates": "Coordinates: ", + "dialog_locationResponse_titlePrefix": "Location: ", + "dialog_locationResponse_unit_meter": "m", + "dialog_pkiRegenerateDialog_title": "Regenerate Pre-Shared Key?", + "dialog_pkiRegenerateDialog_description": "Are you sure you want to regenerate the pre-shared key?", + "dialog_pkiRegenerateDialog_buttonRegenerate": "Regenerate", + "dialog_newDeviceDialog_title": "Connect New Device", + "dialog_newDeviceDialog_tabLabelHttp": "HTTP", + "dialog_newDeviceDialog_tabLabelBluetooth": "Bluetooth", + "dialog_newDeviceDialog_tabLabelSerial": "Serial", + "dialog_newDeviceDialog_errorMessage_requiresFeatures": "This connection type requires <0>. Please use a supported browser, like Chrome or Edge.", + "dialog_newDeviceDialog_errorMessage_requiresSecureContext": "This application requires a <0>secure context. Please connect using HTTPS or localhost.", + "dialog_newDeviceDialog_errorMessage_additionallyRequiresSecureContext": "Additionally, it requires a <0>secure context. Please connect using HTTPS or localhost.", + "dialog_nodeDetails_button_message": "Message", + "dialog_nodeDetails_button_requestPosition": "Request Position", + "dialog_nodeDetails_button_traceRoute": "Trace Route", + "dialog_nodeDetails_label_airTxUtilization": "Air TX utilization", + "dialog_nodeDetails_label_allRawMetrics": "All Raw Metrics:", + "dialog_nodeDetails_label_batteryLevel": "Battery level", + "dialog_nodeDetails_label_channelUtilization": "Channel utilization", + "dialog_nodeDetails_label_details": "Details:", + "dialog_nodeDetails_label_deviceMetrics": "Device Metrics:", + "dialog_nodeDetails_label_hardware": "Hardware: ", + "dialog_nodeDetails_label_lastHeard": "Last Heard: ", + "dialog_nodeDetails_label_nodeHexPrefix": "Node Hex: !", + "dialog_nodeDetails_label_nodeNumber": "Node Number: ", + "dialog_nodeDetails_label_position": "Position:", + "dialog_nodeDetails_label_role": "Role: ", + "dialog_nodeDetails_label_uptime": "Uptime: ", + "dialog_nodeDetails_label_voltage": "Voltage", + "dialog_nodeDetails_titlePrefix": "Node Details for ", + "dialog_nodeDetails_tooltip_ignoreNode": "Ignore node", + "dialog_nodeDetails_tooltip_removeNode": "Remove node", + "dialog_nodeDetails_tooltip_unignoreNode": "Unignore node", + "dialog_pkiBackup_message": "We recommend backing up your key data regularly. Would you like to back up now?", + "dialog_pkiBackup_description_loseKeysWarning": "If you lose your keys, you will need to reset your device.", + "dialog_pkiBackup_description_secureBackup": "Its important to backup your public and private keys and store your backup securely!", + "dialog_pkiBackup_print_footer": "=== END OF KEYS ===", + "dialog_pkiBackup_print_header": "=== MESHTASTIC KEYS ===", + "dialog_pkiBackup_print_label_privateKey": "Private Key:", + "dialog_pkiBackup_print_label_publicKey": "Public Key:", + "dialog_pkiBackup_title": "Backup Keys", + "dialog_pkiRegenerate_description_keyPair": "Are you sure you want to regenerate key pair?", + "dialog_pkiRegenerate_title_keyPair": "Regenerate Key Pair", + "dialog_qr_button_addChannels": "Add Channels", + "dialog_qr_button_replaceChannels": "Replace Channels", + "dialog_qr_description": "The current LoRa configuration will also be shared.", + "dialog_qr_label_sharableUrl": "Sharable URL", + "dialog_qr_title": "Generate QR Code", + "dialog_rebootOta_button_now": "Reboot to OTA Mode Now", + "dialog_rebootOta_description": "Reboot the connected node after a delay into OTA (Over-the-Air) mode.", + "dialog_rebootOta_placeholder_enterDelay": "Enter delay (sec)", + "dialog_rebootOta_status_scheduled": "Reboot has been scheduled", + "dialog_reboot_description": "Reboot the connected node after x minutes.", + "dialog_refreshKeys_button_requestNewKeys": "Request New Keys", + "dialog_refreshKeys_description_acceptNewKeys": "This will remove the node from device and request new keys.", + "dialog_refreshKeys_description_keyMismatchReasonSuffix": ". This is due to the remote node's current public key does not match the previously stored key for this node.", + "dialog_refreshKeys_description_unableToSendDmPrefix": "Your node is unable to send a direct message to node: ", + "dialog_refreshKeys_label_acceptNewKeys": "Accept New Keys", + "dialog_refreshKeys_titlePrefix": "Keys Mismatch - ", + "dialog_removeNode_description": "Are you sure you want to remove this Node?", + "dialog_removeNode_title": "Remove Node?", + "dialog_shutdown_description": "Turn off the connected node after x minutes.", + "dialog_shutdown_suffix_minutes": "Minutes", + "dialog_tracerouteResponse_titlePrefix": "Traceroute: ", + "dialog_unsafeRoles_checkbox_confirmUnderstanding": "Yes, I know what I'm doing", + "dialog_unsafeRoles_description_conjunction": " and the blog post about ", + "dialog_unsafeRoles_description_postamble": " and understand the implications of changing the role.", + "dialog_unsafeRoles_description_preamble": "I have read the ", + "dialog_unsafeRoles_link_choosingRightDeviceRole": "Choosing The Right Device Role", + "dialog_unsafeRoles_link_deviceRoleDocumentation": "Device Role Documentation", + "dialog_unsafeRoles_title": "Are you sure?", + + "dashboard_connectedDevicesSection_title": "Connected Devices", + "dashboard_connectedDevicesSection_description": "Manage your connected Meshtastic devices.", + "dashboard_connectedDevicesSection_connectionType_ble": "BLE", + "dashboard_connectedDevicesSection_connectionType_serial": "Serial", + "dashboard_connectedDevicesSection_connectionType_network": "Network", + "dashboard_connectedDevicesSection_noDevicesTitle": "No devices connected", + "dashboard_connectedDevicesSection_noDevicesDescription": "Connect a new device to get started.", + "dashboard_connectedDevicesSection_button_newConnection": "New Connection", + + "sidebar_collapseToggle_button_open": "Open sidebar", + "sidebar_collapseToggle_button_close": "Close sidebar", + "sidebar_deviceInfo_volts": "{{voltage}} volts", + "sidebar_deviceInfo_firmwareVersion": "v{{version}}", + "sidebar_deviceInfo_button_editDeviceName": "Edit device name", + + "messages_empty": "No messages yet.", + "messages_selectChatPrompt": "Select a channel or node to start messaging.", + "messages_channelsSection_title": "Channels", + "messages_channelsSection_primaryChannelName": "Primary", + "messages_channelsSection_channelName": "Ch {{index}}", + "messages_nodesSection_searchPlaceholder": "Search nodes...", + "messages_title": "Messages: {{chatName}}", + "messages_title_default": "Select a Chat", + "messages_sendMessagePrompt": "Select a chat to send a message.", + "messages_actionsMenu_addReactionLabel": "Add Reaction", + "messages_actionsMenu_replyLabel": "Reply", + + "channel_page_title": "Channel: {{channelName}}", + "channel_name_primary": "Primary", + "channel_name_prefix": "Ch {{index}}", + "channel_form_toast_saved": "Saved Channel: {{channelName}}", + "channel_form_validation_psk_invalid": "Please enter a valid {{bits}} bit PSK.", + "channel_form_field_group_settings_label": "Channel Settings", + "channel_form_field_group_settings_description": "Crypto, MQTT & misc settings", + "channel_form_field_role_label": "Role", + "channel_form_field_role_description": "Device telemetry is sent over PRIMARY. Only one PRIMARY allowed", + "channel_form_field_role_option_primary": "PRIMARY", + "channel_form_field_role_option_disabled": "DISABLED", + "channel_form_field_role_option_secondary": "SECONDARY", + "channel_form_field_psk_label": "Pre-Shared Key", + "channel_form_field_psk_description": "Supported PSK lengths: 256-bit, 128-bit, 8-bit, Empty (0-bit)", + "channel_form_field_psk_generate_button": "Generate", + "channel_form_field_name_label": "Name", + "channel_form_field_name_description": "A unique name for the channel <12 bytes, leave blank for default", + "channel_form_field_uplink_enabled_label": "Uplink Enabled", + "channel_form_field_uplink_enabled_description": "Send messages from the local mesh to MQTT", + "channel_form_field_downlink_enabled_label": "Downlink Enabled", + "channel_form_field_downlink_enabled_description": "Send messages from MQTT to the local mesh", + "channel_form_field_position_precision_label": "Location", + "channel_form_field_position_precision_description": "The precision of the location to share with the channel. Can be disabled.", + "channel_form_field_position_precision_option_none": "Do not share location", + "channel_form_field_position_precision_option_precise": "Precise Location", + "channel_form_field_position_precision_option_metric_km23": "Within 23 kilometers", + "channel_form_field_position_precision_option_metric_km12": "Within 12 kilometers", + "channel_form_field_position_precision_option_metric_km5_8": "Within 5.8 kilometers", + "channel_form_field_position_precision_option_metric_km2_9": "Within 2.9 kilometers", + "channel_form_field_position_precision_option_metric_km1_5": "Within 1.5 kilometers", + "channel_form_field_position_precision_option_metric_m700": "Within 700 meters", + "channel_form_field_position_precision_option_metric_m350": "Within 350 meters", + "channel_form_field_position_precision_option_metric_m200": "Within 200 meters", + "channel_form_field_position_precision_option_metric_m90": "Within 90 meters", + "channel_form_field_position_precision_option_metric_m50": "Within 50 meters", + "channel_form_field_position_precision_option_imperial_mi15": "Within 15 miles", + "channel_form_field_position_precision_option_imperial_mi7_3": "Within 7.3 miles", + "channel_form_field_position_precision_option_imperial_mi3_6": "Within 3.6 miles", + "channel_form_field_position_precision_option_imperial_mi1_8": "Within 1.8 miles", + "channel_form_field_position_precision_option_imperial_mi0_9": "Within 0.9 miles", + "channel_form_field_position_precision_option_imperial_mi0_5": "Within 0.5 miles", + "channel_form_field_position_precision_option_imperial_mi0_2": "Within 0.2 miles", + "channel_form_field_position_precision_option_imperial_ft600": "Within 600 feet", + "channel_form_field_position_precision_option_imperial_ft300": "Within 300 feet", + "channel_form_field_position_precision_option_imperial_ft150": "Within 150 feet", + + "httpConnection_ipAddressLabel": "IP Address/Hostname", + "httpConnection_field_ipAddress_placeholder": "000.000.000.000 / meshtastic.local", + "httpConnection_label_useHttps": "Use HTTPS", + "httpConnection_button_connecting": "Connecting...", + "httpConnection_button_connect": "Connect", + "httpConnection_http": "http", + "httpConnection_https": "https", + "httpConnection_connectionFailedAlert_title": "Connection Failed", + "httpConnection_connectionFailedAlert_descriptionPrefix": "Could not connect to the device. ", + "httpConnection_connectionFailedAlert_httpsHint": "If using HTTPS, you may need to accept a self-signed certificate first. ", + "httpConnection_connectionFailedAlert_openLinkPrefix": "Please open ", + "httpConnection_connectionFailedAlert_openLinkSuffix": " in a new tab", + "httpConnection_connectionFailedAlert_acceptTlsWarningSuffix": ", accept any TLS warnings if prompted, then try again", + "httpConnection_connectionFailedAlert_learnMoreLink": "Learn more", + + "serialConnection_noDevicesPaired": "No devices paired yet.", + "serialConnection_newDeviceButton": "New device", + "serialConnection_deviceIdentifier": "# {{index}} - {{vendorId}} - {{productId}}", + "serialConnection_connecting": "Connecting...", + + "bluetoothConnection_noDevicesPaired": "No devices paired yet.", + "bluetoothConnection_newDeviceButton": "New device", + + "toast_messages_pkiEncryption": "Chat is using PKI encryption.", + "toast_messages_pskEncryption": "Chat is using PSK encryption.", + "toast_positionRequestSent": "Position request sent.", + "toast_requestingPosition": "Requesting position, please wait...", + "toast_sendingTraceroute": "Sending Traceroute, please wait...", + "toast_tracerouteSent": "Traceroute sent.", + + "node_detail_public_key_enabled_aria_label": "Public Key Enabled", + "node_detail_no_public_key_aria_label": "No Public Key", + "node_detail_direct_message_tooltip": "Direct Message {{shortName}}", + "node_detail_favorite_aria_label": "Favorite", + "node_detail_not_favorite_aria_label": "Not a Favorite", + "node_detail_status_heard": "Heard", + "node_detail_status_mqtt": "MQTT", + "node_detail_hops_label_one": "Hop", + "node_detail_hops_label_other": "Hops", + "node_detail_hops_unknown": "?", + "node_detail_elevation_aria_label": "Elevation", + "node_detail_altitude_unit_one": "meter", + "node_detail_altitude_unit_other": "meters", + "node_detail_channel_util_label": "Channel Util", + "node_detail_airtime_util_label": "Airtime Util", + "node_detail_snr_label": "SNR", + "node_detail_short_name_display_format": "\"{name}\"", + + "traceRoute_routeToDestinationTitle": "Route to destination:", + "traceRoute_routeBackTitle": "Route back:", + "traceRoute_snrUnknown": "??", + "traceRoute_nodeUnknownPrefix": "!", + + "nodes_searchPlaceholder": "Search nodes...", + "nodes_table_headings_longName": "Long Name", + "nodes_table_headings_connection": "Connection", + "nodes_table_headings_lastHeard": "Last Heard", + "nodes_table_headings_encryption": "Encryption", + "nodes_table_headings_model": "Model", + "nodes_table_headings_macAddress": "MAC Address", + "nodes_table_connectionStatus_direct": "Direct", + "nodes_table_connectionStatus_hopsAway_one": "{{count}} hop away", + "nodes_table_connectionStatus_hopsAway_other": "{{count}} hops away", + "nodes_table_lastHeardStatus_never": "Never", + "nodes_table_connectionStatus_away": "away", + "nodes_table_connectionStatus_hops_one": "hop", + "nodes_table_connectionStatus_hops_other": "hops", + "nodes_table_connectionStatus_unknown": "-", + "nodes_table_connectionStatus_viaMqtt": ", via MQTT", + + "command_palette_input_placeholder": "Type a command or search...", + "command_palette_empty_message": "No results found.", + "command_palette_pin_group_aria_label": "Pin command group", + "command_palette_unpin_group_aria_label": "Unpin command group", + "command_palette_goto_label": "Goto", + "command_palette_goto_command_messages": "Messages", + "command_palette_goto_command_map": "Map", + "command_palette_goto_command_config": "Config", + "command_palette_goto_command_channels": "Channels", + "command_palette_goto_command_nodes": "Nodes", + "command_palette_manage_label": "Manage", + "command_palette_manage_command_switch_node": "Switch Node", + "command_palette_manage_command_connect_new_node": "Connect New Node", + "command_palette_contextual_label": "Contextual", + "command_palette_contextual_command_qr_code": "QR Code", + "command_palette_contextual_command_qr_generator": "Generator", + "command_palette_contextual_command_qr_import": "Import", + "command_palette_contextual_command_schedule_shutdown": "Schedule Shutdown", + "command_palette_contextual_command_schedule_reboot": "Schedule Reboot", + "command_palette_contextual_command_reboot_to_ota_mode": "Reboot To OTA Mode", + "command_palette_contextual_command_reset_nodes": "Reset Nodes", + "command_palette_contextual_command_factory_reset_device": "Factory Reset Device", + "command_palette_contextual_command_factory_reset_config": "Factory Reset Config", + "command_palette_debug_label": "Debug", + "command_palette_debug_command_reconfigure": "Reconfigure", + "command_palette_debug_command_clear_all_stored_messages": "Clear All Stored Message", + + "config_page_title_configuration": "Configuration", + "config_device_tab_bluetooth": "Bluetooth", + "config_device_tab_device": "Device", + "config_device_tab_display": "Display", + "config_device_tab_lora": "LoRa", + "config_device_tab_network": "Network", + "config_device_tab_position": "Position", + "config_device_tab_power": "Power", + "config_device_tab_security": "Security", + "config_module_tab_ambientLighting": "Ambient Lighting", + "config_module_tab_audio": "Audio", + "config_module_tab_cannedMessage": "Canned", + "config_module_tab_detectionSensor": "Detection Sensor", + "config_module_tab_externalNotification": "Ext Notif", + "config_module_tab_mqtt": "MQTT", + "config_module_tab_neighborInfo": "Neighbor Info", + "config_module_tab_paxcounter": "Paxcounter", + "config_module_tab_rangeTest": "Range Test", + "config_module_tab_serial": "Serial", + "config_module_tab_storeAndForward": "S&F", + "config_module_tab_telemetry": "Telemetry", + "config_sidebar_section_modules_label": "Modules", + "config_toast_errorSaving_description": "An error occurred while saving the configuration.", + "config_toast_errorSaving_title": "Error Saving Config", + "config_toast_errorsExist_description": "Please fix the configuration errors before saving.", + "config_toast_errorsExist_title": "Config Errors Exist", + "config_toast_saving_description": "The configuration change {{case}} has been saved.", + "config_toast_saving_title": "Saving Config", + "config_bluetooth_fieldDescription_enabled": "Enable or disable Bluetooth", + "config_bluetooth_fieldDescription_pairingMode": "Pin selection behaviour.", + "config_bluetooth_fieldDescription_pin": "Pin to use when pairing", + "config_bluetooth_fieldLabel_enabled": "Enabled", + "config_bluetooth_fieldLabel_pairingMode": "Pairing mode", + "config_bluetooth_fieldLabel_pin": "Pin", + "config_bluetooth_groupDescription_bluetoothSettings": "Settings for the Bluetooth module ", + "config_bluetooth_groupLabel_bluetoothSettings": "Bluetooth Settings", + "config_bluetooth_groupNotes_bluetoothWifiNote": "Note: Some devices (ESP32) cannot use both Bluetooth and WiFi at the same time.", + "config_bluetooth_validation_pinCannotStartWithZero": "Bluetooth Pin cannot start with 0", + "config_bluetooth_validation_pinMustBeSixDigits": "Pin must be 6 digits", + "config_bluetooth_validation_pinRequired": "Bluetooth Pin is required", + "config_device_fieldDescription_buttonPin": "Button pin override", + "config_device_fieldDescription_buzzerPin": "Buzzer pin override", + "config_device_fieldDescription_disableTripleClick": "Disable triple click", + "config_device_fieldDescription_doubleTapAsButtonPress": "Treat double tap as button press", + "config_device_fieldDescription_ledHeartbeatDisabled": "Disable default blinking LED", + "config_device_fieldDescription_nodeInfoBroadcastInterval": "How often to broadcast node info", + "config_device_fieldDescription_posixTimezone": "The POSIX timezone string for the device", + "config_device_fieldDescription_rebroadcastMode": "How to handle rebroadcasting", + "config_device_fieldDescription_role": "What role the device performs on the mesh", + "config_device_fieldLabel_buttonPin": "Button Pin", + "config_device_fieldLabel_buzzerPin": "Buzzer Pin", + "config_device_fieldLabel_disableTripleClick": "Disable Triple Click", + "config_device_fieldLabel_doubleTapAsButtonPress": "Double Tap as Button Press", + "config_device_fieldLabel_ledHeartbeatDisabled": "LED Heartbeat Disabled", + "config_device_fieldLabel_nodeInfoBroadcastInterval": "Node Info Broadcast Interval", + "config_device_fieldLabel_posixTimezone": "POSIX Timezone", + "config_device_fieldLabel_rebroadcastMode": "Rebroadcast Mode", + "config_device_fieldLabel_role": "Role", + "config_device_groupDescription_deviceSettings": "Settings for the device", + "config_device_groupLabel_deviceSettings": "Device Settings", + "config_display_fieldDescription_boldHeading": "Bolden the heading text", + "config_display_fieldDescription_carouselDelay": "How fast to cycle through windows", + "config_display_fieldDescription_compassNorthTop": "Fix north to the top of compass", + "config_display_fieldDescription_displayMode": "Screen layout variant", + "config_display_fieldDescription_displayUnits": "Display metric or imperial units", + "config_display_fieldDescription_flipScreen": "Flip display 180 degrees", + "config_display_fieldDescription_gpsDisplayUnits": "Coordinate display format", + "config_display_fieldDescription_oledType": "Type of OLED screen attached to the device", + "config_display_fieldDescription_screenTimeout": "Turn off the display after this long", + "config_display_fieldDescription_twelveHourClock": "Use 12-hour clock format", + "config_display_fieldDescription_wakeOnTapOrMotion": "Wake the device on tap or motion", + "config_display_fieldLabel_boldHeading": "Bold Heading", + "config_display_fieldLabel_carouselDelay": "Carousel Delay", + "config_display_fieldLabel_compassNorthTop": "Compass North Top", + "config_display_fieldLabel_displayMode": "Display Mode", + "config_display_fieldLabel_displayUnits": "Display Units", + "config_display_fieldLabel_flipScreen": "Flip Screen", + "config_display_fieldLabel_gpsDisplayUnits": "GPS Display Units", + "config_display_fieldLabel_oledType": "OLED Type", + "config_display_fieldLabel_screenTimeout": "Screen Timeout", + "config_display_fieldLabel_twelveHourClock": "12-Hour Clock", + "config_display_fieldLabel_wakeOnTapOrMotion": "Wake on Tap or Motion", + "config_display_groupDescription_displaySettings": "Settings for the device display", + "config_display_groupLabel_displaySettings": "Display Settings", + "config_lora_fieldDescription_bandwidth": "Channel bandwidth in MHz", + "config_lora_fieldDescription_boostedRxGain": "Boosted RX gain", + "config_lora_fieldDescription_codingRate": "The denominator of the coding rate", + "config_lora_fieldDescription_frequencyOffset": "Frequency offset to correct for crystal calibration errors", + "config_lora_fieldDescription_frequencySlot": "LoRa frequency channel number", + "config_lora_fieldDescription_hopLimit": "Maximum number of hops", + "config_lora_fieldDescription_ignoreMqtt": "Don't forward MQTT messages over the mesh", + "config_lora_fieldDescription_modemPreset": "Modem preset to use", + "config_lora_fieldDescription_okToMqtt": "When set to true, this configuration indicates that the user approves the packet to be uploaded to MQTT. If set to false, remote nodes are requested not to forward packets to MQTT", + "config_lora_fieldDescription_overrideDutyCycle": "Override Duty Cycle", + "config_lora_fieldDescription_overrideFrequency": "Override frequency", + "config_lora_fieldDescription_region": "Sets the region for your node", + "config_lora_fieldDescription_spreadingFactor": "Indicates the number of chirps per symbol", + "config_lora_fieldDescription_transmitEnabled": "Enable/Disable transmit (TX) from the LoRa radio", + "config_lora_fieldDescription_transmitPower": "Max transmit power", + "config_lora_fieldDescription_usePreset": "Use one of the predefined modem presets", + "config_lora_fieldLabel_bandwidth": "Bandwidth", + "config_lora_fieldLabel_boostedRxGain": "Boosted RX Gain", + "config_lora_fieldLabel_codingRate": "Coding Rate", + "config_lora_fieldLabel_frequencyOffset": "Frequency Offset", + "config_lora_fieldLabel_frequencySlot": "Frequency Slot", + "config_lora_fieldLabel_hopLimit": "Hop Limit", + "config_lora_fieldLabel_ignoreMqtt": "Ignore MQTT", + "config_lora_fieldLabel_modemPreset": "Modem Preset", + "config_lora_fieldLabel_okToMqtt": "OK to MQTT", + "config_lora_fieldLabel_overrideDutyCycle": "Override Duty Cycle", + "config_lora_fieldLabel_overrideFrequency": "Override Frequency", + "config_lora_fieldLabel_region": "Region", + "config_lora_fieldLabel_spreadingFactor": "Spreading Factor", + "config_lora_fieldLabel_transmitEnabled": "Transmit Enabled", + "config_lora_fieldLabel_transmitPower": "Transmit Power", + "config_lora_fieldLabel_usePreset": "Use Preset", + "config_lora_groupDescription_meshSettings": "Settings for the LoRa mesh", + "config_lora_groupDescription_radioSettings": "Settings for the LoRa radio", + "config_lora_groupDescription_waveformSettings": "Settings for the LoRa waveform", + "config_lora_groupLabel_meshSettings": "Mesh Settings", + "config_lora_groupLabel_radioSettings": "Radio Settings", + "config_lora_groupLabel_waveformSettings": "Waveform Settings", + "config_network_fieldDescription_addressMode": "Address assignment selection", + "config_network_fieldDescription_dns": "DNS Server", + "config_network_fieldDescription_ethernetEnabled": "Enable or disable the Ethernet port", + "config_network_fieldDescription_gateway": "Default Gateway", + "config_network_fieldDescription_ip": "IP Address", + "config_network_fieldDescription_psk": "Network password", + "config_network_fieldDescription_ssid": "Network name", + "config_network_fieldDescription_subnet": "Subnet Mask", + "config_network_fieldDescription_wifiEnabled": "Enable or disable the WiFi radio", + "config_network_fieldLabel_addressMode": "Address Mode", + "config_network_fieldLabel_dns": "DNS", + "config_network_fieldLabel_ethernetEnabled": "Enabled", + "config_network_fieldLabel_gateway": "Gateway", + "config_network_fieldLabel_ip": "IP", + "config_network_fieldLabel_meshViaUdp": "Mesh via UDP", + "config_network_fieldLabel_ntpServer": "NTP Server", + "config_network_fieldLabel_psk": "PSK", + "config_network_fieldLabel_rsyslogServer": "Rsyslog Server", + "config_network_fieldLabel_ssid": "SSID", + "config_network_fieldLabel_subnet": "Subnet", + "config_network_fieldLabel_wifiEnabled": "Enabled", + "config_network_groupDescription_ethernetConfig": "Ethernet port configuration", + "config_network_groupDescription_ipConfig": "IP configuration", + "config_network_groupDescription_ntpConfig": "NTP configuration", + "config_network_groupDescription_rsyslogConfig": "Rsyslog configuration", + "config_network_groupDescription_udpConfig": "UDP over Mesh configuration", + "config_network_groupDescription_wifiConfig": "WiFi radio configuration", + "config_network_groupLabel_ethernetConfig": "Ethernet Config", + "config_network_groupLabel_ipConfig": "IP Config", + "config_network_groupLabel_ntpConfig": "NTP Config", + "config_network_groupLabel_rsyslogConfig": "Rsyslog Config", + "config_network_groupLabel_udpConfig": "UDP Config", + "config_network_groupLabel_wifiConfig": "WiFi Config", + "config_network_groupNotes_wifiBluetoothNote": "Note: Some devices (ESP32) cannot use both Bluetooth and WiFi at the same time.", + "config_position_fieldDescription_broadcastInterval": "How often your position is sent out over the mesh", + "config_position_fieldDescription_enablePin": "GPS module enable pin override", + "config_position_fieldDescription_fixedPosition": "Don't report GPS position, but a manually-specified one", + "config_position_fieldDescription_gpsMode": "Configure whether device GPS is Enabled, Disabled, or Not Present", + "config_position_fieldDescription_gpsUpdateInterval": "How often a GPS fix should be acquired", + "config_position_fieldDescription_positionFlags": "Optional fields to include when assembling position messages. The more fields are selected, the larger the message will be leading to longer airtime usage and a higher risk of packet loss.", + "config_position_fieldDescription_receivePin": "GPS module RX pin override", + "config_position_fieldDescription_smartPositionEnabled": "Only send position when there has been a meaningful change in location", + "config_position_fieldDescription_smartPositionMinDistance": "Minimum distance (in meters) that must be traveled before a position update is sent", + "config_position_fieldDescription_smartPositionMinInterval": "Minimum interval (in seconds) that must pass before a position update is sent", + "config_position_fieldDescription_transmitPin": "GPS module TX pin override", + "config_position_fieldLabel_broadcastInterval": "Broadcast Interval", + "config_position_fieldLabel_enablePin": "Enable Pin", + "config_position_fieldLabel_fixedPosition": "Fixed Position", + "config_position_fieldLabel_gpsMode": "GPS Mode", + "config_position_fieldLabel_gpsUpdateInterval": "GPS Update Interval", + "config_position_fieldLabel_positionFlags": "Position Flags", + "config_position_fieldLabel_receivePin": "Receive Pin", + "config_position_fieldLabel_smartPositionEnabled": "Enable Smart Position", + "config_position_fieldLabel_smartPositionMinDistance": "Smart Position Minimum Distance", + "config_position_fieldLabel_smartPositionMinInterval": "Smart Position Minimum Interval", + "config_position_fieldLabel_transmitPin": "Transmit Pin", + "config_position_fieldPlaceholder_selectPositionFlags": "Select position flags...", + "config_position_groupDescription_intervals": "How often to send position updates", + "config_position_groupDescription_positionSettings": "Settings for the position module", + "config_position_groupLabel_intervals": "Intervals", + "config_position_groupLabel_positionSettings": "Position Settings", + "config_power_fieldDescription_adcMultiplierOverride": "Used for tweaking battery voltage reading", + "config_power_fieldDescription_ina219Address": "Address of the INA219 battery monitor", + "config_power_fieldDescription_lightSleepDuration": "How long the device will be in light sleep for", + "config_power_fieldDescription_minimumWakeTime": "Minimum amount of time the device will stay awake for after receiving a packet", + "config_power_fieldDescription_noConnectionBluetoothDisabled": "If the device does not receive a Bluetooth connection, the BLE radio will be disabled after this long", + "config_power_fieldDescription_powerSavingEnabled": "Select if powered from a low-current source (i.e. solar), to minimize power consumption as much as possible.", + "config_power_fieldDescription_shutdownOnBatteryDelay": "Automatically shutdown node after this long when on battery, 0 for indefinite", + "config_power_fieldDescription_superDeepSleepDuration": "How long the device will be in super deep sleep for", + "config_power_fieldLabel_adcMultiplierOverride": "ADC Multiplier Override ratio", + "config_power_fieldLabel_ina219Address": "INA219 Address", + "config_power_fieldLabel_lightSleepDuration": "Light Sleep Duration", + "config_power_fieldLabel_minimumWakeTime": "Minimum Wake Time", + "config_power_fieldLabel_noConnectionBluetoothDisabled": "No Connection Bluetooth Disabled", + "config_power_fieldLabel_powerSavingEnabled": "Enable power saving mode", + "config_power_fieldLabel_shutdownOnBatteryDelay": "Shutdown on battery delay", + "config_power_fieldLabel_superDeepSleepDuration": "Super Deep Sleep Duration", + "config_power_groupDescription_powerConfig": "Settings for the power module", + "config_power_groupDescription_sleepSettings": "Sleep settings for the power module", + "config_power_groupLabel_powerConfig": "Power Config", + "config_power_groupLabel_sleepSettings": "Sleep Settings", + "config_security_button_backupKey": "Backup Key", + "config_security_fieldDescription_adminChannelEnabled": "Allow incoming device control over the insecure legacy admin channel", + "config_security_fieldDescription_enableDebugLogApi": "Output live debug logging over serial, view and export position-redacted device logs over Bluetooth", + "config_security_fieldDescription_managed": "If enabled, device configuration options are only able to be changed remotely by a Remote Admin node via admin messages. Do not enable this option unless at least one suitable Remote Admin node has been setup, and the public key is stored in one of the fields above.", + "config_security_fieldDescription_primaryAdminKey": "The primary public key authorized to send admin messages to this node", + "config_security_fieldDescription_privateKey": "Used to create a shared key with a remote device", + "config_security_fieldDescription_publicKey": "Sent out to other nodes on the mesh to allow them to compute a shared secret key", + "config_security_fieldDescription_secondaryAdminKey": "The secondary public key authorized to send admin messages to this node", + "config_security_fieldDescription_serialOutputEnabled": "Serial Console over the Stream API", + "config_security_fieldDescription_tertiaryAdminKey": "The tertiary public key authorized to send admin messages to this node", + "config_security_fieldLabel_adminChannelEnabled": "Allow Legacy Admin", + "config_security_fieldLabel_enableDebugLogApi": "Enable Debug Log API", + "config_security_fieldLabel_managed": "Managed", + "config_security_fieldLabel_primaryAdminKey": "Primary Admin Key", + "config_security_fieldLabel_privateKey": "Private Key", + "config_security_fieldLabel_publicKey": "Public Key", + "config_security_fieldLabel_secondaryAdminKey": "Secondary Admin Key", + "config_security_fieldLabel_serialOutputEnabled": "Serial Output Enabled", + "config_security_fieldLabel_tertiaryAdminKey": "Tertiary Admin Key", + "config_security_groupDescription_adminSettings": "Settings for Admin", + "config_security_groupDescription_loggingSettings": "Settings for Logging", + "config_security_groupDescription_securitySettings": "Settings for the Security configuration", + "config_security_groupLabel_adminSettings": "Admin Settings", + "config_security_groupLabel_loggingSettings": "Logging Settings", + "config_security_groupLabel_securitySettings": "Security Settings", + "config_security_option_256bit": "256 bit", + "config_security_validation_adminKeyMustBe256BitPsk": "Admin Key is required to be a 256 bit pre-shared key (PSK)", + "config_security_validation_adminKeyRequiredWhenManaged": "At least one admin key is requred if the node is managed.", + "config_security_validation_enterValid256BitPsk": "Please enter a valid 256 bit PSK", + "config_security_validation_invalidAdminKeyFormat": "Invalid Admin Key format", + "config_security_validation_invalidPrivateKeyFormat": "Invalid Private Key format", + "config_security_validation_privateKeyMustBe256BitPsk": "Private Key is required to be a 256 bit pre-shared key (PSK)", + "config_security_validation_privateKeyRequired": "Private Key is required", + + "input_action_clearInput_label": "Clear input", + "input_action_copied_label": "Copied!", + "input_action_copyToClipboard_label": "Copy to clipboard", + "input_action_hidePassword_label": "Hide password", + "input_action_showPassword_label": "Show password", + + "filter_control_accordion_general_label": "General", + "filter_control_accordion_hardware_label": "Hardware", + "filter_control_accordion_metrics_label": "Metrics", + "filter_control_accordion_role_label": "Role", + "filter_control_button_filter_ariaLabel": "Filter", + "filter_control_button_resetFilters_label": "Reset Filters", + "filter_control_input_nodeName_label": "Node name/number", + "filter_control_input_nodeName_placeholder": "Meshtastic 1234", + "filter_control_slider_airtimeUtilization_label": "Airtime Utilization (%)", + "filter_control_slider_batteryLevel_label": "Battery level (%)", + "filter_control_slider_batteryLevel_labelText": "Battery level (%): {{value}}", + "filter_control_slider_batteryVoltage_label": "Battery voltage (V)", + "filter_control_slider_channelUtilization_label": "Channel Utilization (%)", + "filter_control_slider_hops_directLabel": "Direct", + "filter_control_slider_hops_label": "Number of hops", + "filter_control_slider_hops_labelText": "Number of hops: {{value}}", + "filter_control_slider_lastHeard_label": "Last heard", + "filter_control_slider_lastHeard_labelText": "Last heard: {{value}}", + "filter_control_slider_lastHeard_nowLabel": "Now", + "filter_control_slider_snr_label": "SNR (db)", + "filter_control_toggle_favorites_label": "Favorites", + "filter_control_toggle_hide_label": "Hide", + "filter_control_toggle_showOnly_label": "Show Only", + "filter_control_toggle_viaMqtt_label": "Connected via MQTT" +} diff --git a/src/index.tsx b/src/index.tsx index 33d79536..a0090d66 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,10 +1,11 @@ import "@app/index.css"; import { enableMapSet } from "immer"; import "maplibre-gl/dist/maplibre-gl.css"; -import { StrictMode } from "react"; +import { StrictMode, Suspense } from "react"; import { createRoot } from "react-dom/client"; import { App } from "@app/App.tsx"; +import "@app/i18n.ts"; const container = document.getElementById("root") as HTMLElement; const root = createRoot(container); @@ -13,6 +14,8 @@ enableMapSet(); root.render( - + + + , , ); diff --git a/src/pages/Channels.tsx b/src/pages/Channels.tsx index 73fedb05..625a34a1 100644 --- a/src/pages/Channels.tsx +++ b/src/pages/Channels.tsx @@ -10,17 +10,20 @@ import { Sidebar } from "@components/Sidebar.tsx"; import { useDevice } from "@core/stores/deviceStore.ts"; import { Types } from "@meshtastic/core"; import type { Protobuf } from "@meshtastic/core"; +import i18next from "i18next"; import { ImportIcon, QrCodeIcon } from "lucide-react"; import { useState } from "react"; +import { useTranslation } from "react-i18next"; export const getChannelName = (channel: Protobuf.Channel.Channel) => channel.settings?.name.length ? channel.settings?.name : channel.index === 0 - ? "Primary" - : `Ch ${channel.index}`; + ? i18next.t("channel_name_primary") + : i18next.t("channel_name_prefix", { index: channel.index }); const ChannelsPage = () => { + const { t } = useTranslation(); const { channels, setDialogOpen } = useDevice(); const [activeChannel] = useState( Types.ChannelNumber.Primary, @@ -33,9 +36,11 @@ const ChannelsPage = () => { <> } - label={`Channel: ${ - currentChannel ? getChannelName(currentChannel) : "Loading..." - }`} + label={currentChannel + ? t("channel_page_title", { + channelName: getChannelName(currentChannel), + }) + : t("common.loading")} actions={[ { key: "search", diff --git a/src/pages/Config/DeviceConfig.tsx b/src/pages/Config/DeviceConfig.tsx index 2ba5fc3d..f74c522f 100644 --- a/src/pages/Config/DeviceConfig.tsx +++ b/src/pages/Config/DeviceConfig.tsx @@ -12,46 +12,48 @@ import { TabsList, TabsTrigger, } from "@components/UI/Tabs.tsx"; +import { useTranslation } from "react-i18next"; export const DeviceConfig = () => { + const { t } = useTranslation(); const tabs = [ { - label: "Device", + label: t("config_device_tab_device"), element: Device, count: 0, }, { - label: "Position", + label: t("config_device_tab_position"), element: Position, }, { - label: "Power", + label: t("config_device_tab_power"), element: Power, }, { - label: "Network", + label: t("config_device_tab_network"), element: Network, }, { - label: "Display", + label: t("config_device_tab_display"), element: Display, }, { - label: "LoRa", + label: t("config_device_tab_lora"), element: LoRa, }, { - label: "Bluetooth", + label: t("config_device_tab_bluetooth"), element: Bluetooth, }, { - label: "Security", + label: t("config_device_tab_security"), element: Security, }, ]; return ( - + {tabs.map((tab) => ( { + const { t } = useTranslation(); const tabs = [ { - label: "MQTT", + label: t("config_module_tab_mqtt"), element: MQTT, }, { - label: "Serial", + label: t("config_module_tab_serial"), element: Serial, }, { - label: "Ext Notif", + label: t("config_module_tab_externalNotification"), element: ExternalNotification, }, { - label: "S&F", + label: t("config_module_tab_storeAndForward"), element: StoreForward, }, { - label: "Range Test", + label: t("config_module_tab_rangeTest"), element: RangeTest, }, { - label: "Telemetry", + label: t("config_module_tab_telemetry"), element: Telemetry, }, { - label: "Canned", + label: t("config_module_tab_cannedMessage"), element: CannedMessage, }, { - label: "Audio", + label: t("config_module_tab_audio"), element: Audio, }, { - label: "Neighbor Info", + label: t("config_module_tab_neighborInfo"), element: NeighborInfo, }, { - label: "Ambient Lighting", + label: t("config_module_tab_ambientLighting"), element: AmbientLighting, }, { - label: "Detection Sensor", + label: t("config_module_tab_detectionSensor"), element: DetectionSensor, }, { - label: "Paxcounter", + label: t("config_module_tab_paxcounter"), element: Paxcounter, }, ]; return ( - + {tabs.map((tab) => ( { const { workingConfig, workingModuleConfig, connection } = useDevice(); @@ -19,12 +20,13 @@ const ConfigPage = () => { const [isSaving, setIsSaving] = useState(false); const { toast } = useToast(); const isError = hasErrors(); + const { t } = useTranslation(); const handleSave = async () => { if (hasErrors()) { return toast({ - title: "Config Errors Exist", - description: "Please fix the configuration errors before saving.", + title: t("config_toast_errorsExist_title"), + description: t("config_toast_errorsExist_description"), }); } @@ -35,9 +37,10 @@ const ConfigPage = () => { workingConfig.map((config) => connection?.setConfig(config).then(() => toast({ - title: "Saving Config", - description: - `The configuration change ${config.payloadVariant.case} has been saved.`, + title: t("config_toast_saving_title"), + description: t("config_toast_saving_description", { + case: config.payloadVariant.case, + }), }) ) ), @@ -47,9 +50,10 @@ const ConfigPage = () => { workingModuleConfig.map((moduleConfig) => connection?.setModuleConfig(moduleConfig).then(() => toast({ - title: "Saving Config", - description: - `The configuration change ${moduleConfig.payloadVariant.case} has been saved.`, + title: t("config_toast_saving_title"), + description: t("config_toast_saving_description", { + case: moduleConfig.payloadVariant.case, + }), }) ) ), @@ -59,8 +63,8 @@ const ConfigPage = () => { await connection?.commitEditSettings(); } catch (_error) { toast({ - title: "Error Saving Config", - description: "An error occurred while saving the configuration.", + title: t("config_toast_errorSaving_title"), + description: t("config_toast_errorSaving_description"), }); } finally { setIsSaving(false); @@ -70,15 +74,15 @@ const ConfigPage = () => { const leftSidebar = useMemo( () => ( - + setActiveConfigSection("device")} Icon={SettingsIcon} /> setActiveConfigSection("module")} Icon={BoxesIcon} @@ -86,7 +90,7 @@ const ConfigPage = () => { ), - [activeConfigSection], + [activeConfigSection, t], ); return ( @@ -95,8 +99,8 @@ const ConfigPage = () => { contentClassName="overflow-auto" leftBar={leftSidebar} label={activeConfigSection === "device" - ? "Radio Config" - : "Module Config"} + ? t("navigation_title_radioConfig") + : t("navigation_title_moduleConfig")} actions={[ { key: "save", diff --git a/src/pages/Dashboard/index.tsx b/src/pages/Dashboard/index.tsx index 77541d0b..bb7db989 100644 --- a/src/pages/Dashboard/index.tsx +++ b/src/pages/Dashboard/index.tsx @@ -13,8 +13,10 @@ import { UsersIcon, } from "lucide-react"; import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; export const Dashboard = () => { + const { t } = useTranslation(); const { setConnectDialogOpen, setSelectedDevice } = useAppStore(); const { getDevices } = useDeviceStore(); @@ -25,8 +27,12 @@ export const Dashboard = () => {
- Connected Devices - Manage, connect and disconnect devices + + {t("dashboard_connectedDevicesSection_title")} + + + {t("dashboard_connectedDevicesSection_description")} +
@@ -55,19 +61,25 @@ export const Dashboard = () => { {device.connection?.connType === "ble" && ( <> - BLE + {t( + "dashboard_connectedDevicesSection_connectionType_ble", + )} )} {device.connection?.connType === "serial" && ( <> - Serial + {t( + "dashboard_connectedDevicesSection_connectionType_serial", + )} )} {device.connection?.connType === "http" && ( <> - Network + {t( + "dashboard_connectedDevicesSection_connectionType_network", + )} )}
@@ -96,15 +108,21 @@ export const Dashboard = () => { size={48} className="mx-auto text-text-secondary" /> - No Devices - Connect at least one device to get started + + {t("dashboard_connectedDevicesSection_noDevicesTitle")} + + + {t("dashboard_connectedDevicesSection_noDevicesDescription")} +
)} diff --git a/src/pages/Messages.tsx b/src/pages/Messages.tsx index 457b4c83..93ac4e75 100644 --- a/src/pages/Messages.tsx +++ b/src/pages/Messages.tsx @@ -20,6 +20,7 @@ import { import { useSidebar } from "@core/stores/sidebarStore.tsx"; import { Input } from "@components/UI/Input.tsx"; import { randId } from "@core/utils/randId.ts"; +import { useTranslation } from "react-i18next"; type NodeInfoWithUnread = Protobuf.Mesh.NodeInfo & { unreadCount: number }; @@ -45,6 +46,7 @@ export const MessagesPage = () => { const { toast } = useToast(); const { isCollapsed } = useSidebar(); const [searchTerm, setSearchTerm] = useState(""); + const { t } = useTranslation(); const deferredSearch = useDeferredValue(searchTerm); const filteredNodes = (): NodeInfoWithUnread[] => { @@ -163,7 +165,7 @@ export const MessagesPage = () => { default: return (
- Select a channel or node to start messaging. + {t("messages_selectChatPrompt")}
); } @@ -171,13 +173,19 @@ export const MessagesPage = () => { const leftSidebar = useMemo(() => ( - + {filteredChannels?.map((channel) => ( { @@ -214,7 +222,7 @@ export const MessagesPage = () => {
diff --git a/src/pages/Nodes.tsx b/src/pages/Nodes.tsx index df4bf91c..17a07e97 100644 --- a/src/pages/Nodes.tsx +++ b/src/pages/Nodes.tsx @@ -26,6 +26,7 @@ import { useFilterNode, } from "@components/generic/Filter/useFilterNode.ts"; import { FilterControl } from "@components/generic/Filter/FilterControl.tsx"; +import { useTranslation } from "react-i18next"; export interface DeleteNoteDialogProps { open: boolean; @@ -33,6 +34,7 @@ export interface DeleteNoteDialogProps { } const NodesPage = (): JSX.Element => { + const { t } = useTranslation(); const { getNodes, hardware, connection, hasNodeError } = useDevice(); const { nodeFilter, defaultFilterValues, isFilterDirty } = useFilterNode(); const [selectedNode, setSelectedNode] = useState< @@ -95,7 +97,7 @@ const NodesPage = (): JSX.Element => {
{ [
@@ -159,16 +189,20 @@ const NodesPage = (): JSX.Element => { {node.hopsAway !== undefined ? node?.viaMqtt === false && node.hopsAway === 0 - ? "Direct" + ? t("nodes_table_connectionStatus_direct") : `${node.hopsAway?.toString()} ${ - node.hopsAway ?? 0 > 1 ? "hops" : "hop" - } away` - : "-"} - {node?.viaMqtt === true ? ", via MQTT" : ""} + node.hopsAway ?? 0 > 1 + ? t("nodes_table_connectionStatus_hops_other") + : t("nodes_table_connectionStatus_hops_one") + } ${t("nodes_table_connectionStatus_away")}` + : t("nodes_table_connectionStatus_unknown")} + {node?.viaMqtt === true + ? t("nodes_table_connectionStatus_viaMqtt") + : ""} , {node.lastHeard === 0 - ?

Never

+ ?

{t("nodes_table_lastHeardStatus_never")}

: }
, @@ -177,9 +211,14 @@ const NodesPage = (): JSX.Element => { : } , - {node.snr}db/ - {Math.min(Math.max((node.snr + 10) * 5, 0), 100)}%/ - {(node.snr + 10) * 5}raw + {node.snr} + {t("common_unit_dbm")}/ + {Math.min( + Math.max((node.snr + 10) * 5, 0), + 100, + )}%/{/* Percentage */} + {(node.snr + 10) * 5} + {t("common_unit_raw")} , {Protobuf.Mesh.HardwareModel[node.user?.hwModel ?? 0]} @@ -188,7 +227,7 @@ const NodesPage = (): JSX.Element => { {base16 .stringify(node.user?.macaddr ?? []) .match(/.{1,2}/g) - ?.join(":") ?? "UNK"} + ?.join(":") ?? t("common.unknown")} , ])} /> diff --git a/vite.config.ts b/vite.config.ts index 4d5bed60..a49feff5 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,48 +1,47 @@ -import { defineConfig } from 'vite'; -import react from '@vitejs/plugin-react'; -import { VitePWA } from 'vite-plugin-pwa'; -import { execSync } from 'node:child_process'; +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import { VitePWA } from "vite-plugin-pwa"; +import { execSync } from "node:child_process"; import process from "node:process"; -import path from 'node:path'; +import path from "node:path"; - -let hash = ''; +let hash = ""; try { - hash = execSync('git rev-parse --short HEAD', { encoding: 'utf8' }).trim(); + hash = execSync("git rev-parse --short HEAD", { encoding: "utf8" }).trim(); } catch (error) { - console.error('Error getting git hash:', error); - hash = 'DEV'; + console.error("Error getting git hash:", error); + hash = "DEV"; } export default defineConfig({ plugins: [ react(), VitePWA({ - registerType: 'autoUpdate', - strategies: 'generateSW', + registerType: "autoUpdate", + strategies: "generateSW", devOptions: { - enabled: false + enabled: false, }, workbox: { cleanupOutdatedCaches: true, - sourcemap: true - } - }) + sourcemap: true, + }, + }), ], define: { - 'import.meta.env.VITE_COMMIT_HASH': JSON.stringify(hash), + "import.meta.env.VITE_COMMIT_HASH": JSON.stringify(hash), }, build: { emptyOutDir: true, - assetsDir: './', + assetsDir: "./", }, resolve: { alias: { - '@app': path.resolve(process.cwd(), './src'), - '@pages': path.resolve(process.cwd(), './src/pages'), - '@components': path.resolve(process.cwd(), './src/components'), - '@core': path.resolve(process.cwd(), './src/core'), - '@layouts': path.resolve(process.cwd(), './src/layouts'), + "@app": path.resolve(process.cwd(), "./src"), + "@pages": path.resolve(process.cwd(), "./src/pages"), + "@components": path.resolve(process.cwd(), "./src/components"), + "@core": path.resolve(process.cwd(), "./src/core"), + "@layouts": path.resolve(process.cwd(), "./src/layouts"), }, }, server: { @@ -52,4 +51,4 @@ export default defineConfig({ "Cross-Origin-Embedder-Policy": "require-corp", }, }, -}); \ No newline at end of file +});