From 1af1295b8f7624f5a8700f3380781541d0e81759 Mon Sep 17 00:00:00 2001
From: Dan Ditomaso
Date: Mon, 3 Nov 2025 20:24:24 -0500
Subject: [PATCH] feat(connections): Add connections page (replaces new device
dialog) (#919)
* feat(conn): add connection screen and logic
* fixes from code review
* force https
* code review fixes
* add http for self testing
* enable deviceStore persistance
* added translations
* disabled feature flag
* i18n updates
* chore: add new folders to biome config (#910)
* chore(i18n): New Crowdin Translations by GitHub Action (#908)
Co-authored-by: Crowdin Bot
* fix: use correct deprecated GPS coordinate format enum (#917)
The Config_DisplayConfig_GpsCoordinateFormat export doesn't exist in the protobufs package. The correct export is Config_DisplayConfig_DeprecatedGpsCoordinateFormat, which matches what's used in the validation schema.
Added TODO comment explaining that this field is deprecated since protobufs 2.7.4 and should be migrated to DeviceUIConfig.gps_format when DeviceUI settings are implemented.
* style: fix line wrapping for GPS coordinate format enum (#918)
- Split long enum reference across multiple lines to improve code readability
- Maintains consistent code formatting standards without changing functionality
* fix(core): ensure core package works in browser (#923)
* fix(core): ensure core package works in browser
* style(core): revert new line removal
* fix: add @serialport/bindings-cpp to onlyBuiltDependencies (#914)
* feat(ui): Add UI library (#900)
* feat: scaffold UI library
* Update packages/ui/src/components/theme-provider.tsx
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
* add lock file
* lint/formatting fixes
---------
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
* formatting/linting fixes
* fixed some paring logic
* fixed connection issue with serial
---------
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Crowdin Bot
Co-authored-by: Kamil Dzieniszewski
Co-authored-by: Azarattum <43073346+Azarattum@users.noreply.github.com>
Co-authored-by: Ben Allfree
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
---
.gitignore | 5 +
packages/core/src/types.ts | 1 +
packages/web/package.json | 9 +-
.../i18n/locales/be-BY/connections.json | 10 +
.../public/i18n/locales/be-BY/dashboard.json | 12 -
.../web/public/i18n/locales/be-BY/ui.json | 453 ++++++------
.../i18n/locales/bg-BG/connections.json | 12 +
.../public/i18n/locales/bg-BG/dashboard.json | 12 -
.../web/public/i18n/locales/bg-BG/ui.json | 453 ++++++------
.../{dashboard.json => connections.json} | 0
.../web/public/i18n/locales/cs-CZ/ui.json | 453 ++++++------
.../{dashboard.json => connections.json} | 0
.../web/public/i18n/locales/en/common.json | 7 +
.../public/i18n/locales/en/connections.json | 34 +
.../web/public/i18n/locales/en/dashboard.json | 12 -
.../web/public/i18n/locales/en/dialog.json | 116 +--
packages/web/public/i18n/locales/en/ui.json | 3 +-
.../{dashboard.json => connections.json} | 0
.../{dashboard.json => connections.json} | 0
.../{dashboard.json => connections.json} | 0
.../{dashboard.json => connections.json} | 0
.../{dashboard.json => connections.json} | 0
.../{dashboard.json => connections.json} | 0
.../{dashboard.json => connections.json} | 0
.../{dashboard.json => connections.json} | 0
.../{dashboard.json => connections.json} | 0
.../{dashboard.json => connections.json} | 0
.../{dashboard.json => connections.json} | 0
.../{dashboard.json => connections.json} | 0
.../{dashboard.json => connections.json} | 0
.../{dashboard.json => connections.json} | 0
.../{dashboard.json => connections.json} | 0
.../web/public/i18n/locales/zh-CN/ui.json | 453 ++++++------
packages/web/src/App.tsx | 13 +-
.../Badge/ConnectionStatusBadge.tsx | 0
.../src/components/Badge/SupportedBadge.tsx | 19 +
.../web/src/components/DeviceInfoPanel.tsx | 70 ++
.../AddConnectionDialog.tsx | 671 ++++++++++++++++++
.../Dialog/AddConnectionDialog/validation.ts | 27 +
.../components/Dialog/DeviceNameDialog.tsx | 140 ----
.../src/components/Dialog/DialogManager.tsx | 7 -
.../src/components/Dialog/ImportDialog.tsx | 2 +-
.../PageComponents/Channels/Channel.tsx | 6 +-
.../components/PageComponents/Connect/BLE.tsx | 104 ---
.../PageComponents/Connect/HTTP.test.tsx | 103 ---
.../PageComponents/Connect/HTTP.tsx | 172 -----
.../PageComponents/Connect/Serial.tsx | 116 ---
.../Connections/ConnectionStatusBadge.tsx | 38 +
packages/web/src/components/Sidebar.tsx | 10 +
.../web/src/components/UI/AlertDialog.tsx | 149 ++++
packages/web/src/components/UI/Badge.tsx | 35 +
packages/web/src/components/UI/Card.tsx | 85 +++
packages/web/src/components/UI/ErrorPage.tsx | 4 +-
packages/web/src/core/hooks/useLRUList.ts | 232 ++++++
.../web/src/core/services/dev-overrides.ts | 1 -
.../core/stores/deviceStore/changeRegistry.ts | 14 +-
.../web/src/core/stores/deviceStore/index.ts | 63 +-
.../web/src/core/stores/deviceStore/types.ts | 40 ++
packages/web/src/i18n-config.ts | 2 +-
packages/web/src/pages/Connections/index.tsx | 411 +++++++++++
.../src/pages/Connections/useConnections.ts | 496 +++++++++++++
packages/web/src/pages/Connections/utils.ts | 84 +++
packages/web/src/pages/Dashboard/index.tsx | 93 ---
packages/web/src/pages/Nodes/index.tsx | 4 +-
packages/web/src/routes.tsx | 11 +-
packages/web/src/tests/setup.ts | 6 +-
packages/web/src/validation/config/user.ts | 8 +-
packages/web/vite.config.ts | 9 +-
pnpm-lock.yaml | 219 +++---
69 files changed, 3628 insertions(+), 1881 deletions(-)
create mode 100644 packages/web/public/i18n/locales/be-BY/connections.json
delete mode 100644 packages/web/public/i18n/locales/be-BY/dashboard.json
create mode 100644 packages/web/public/i18n/locales/bg-BG/connections.json
delete mode 100644 packages/web/public/i18n/locales/bg-BG/dashboard.json
rename packages/web/public/i18n/locales/cs-CZ/{dashboard.json => connections.json} (100%)
rename packages/web/public/i18n/locales/de-DE/{dashboard.json => connections.json} (100%)
create mode 100644 packages/web/public/i18n/locales/en/connections.json
delete mode 100644 packages/web/public/i18n/locales/en/dashboard.json
rename packages/web/public/i18n/locales/es-ES/{dashboard.json => connections.json} (100%)
rename packages/web/public/i18n/locales/fi-FI/{dashboard.json => connections.json} (100%)
rename packages/web/public/i18n/locales/fr-FR/{dashboard.json => connections.json} (100%)
rename packages/web/public/i18n/locales/hu-HU/{dashboard.json => connections.json} (100%)
rename packages/web/public/i18n/locales/it-IT/{dashboard.json => connections.json} (100%)
rename packages/web/public/i18n/locales/ja-JP/{dashboard.json => connections.json} (100%)
rename packages/web/public/i18n/locales/ko-KR/{dashboard.json => connections.json} (100%)
rename packages/web/public/i18n/locales/nl-NL/{dashboard.json => connections.json} (100%)
rename packages/web/public/i18n/locales/pl-PL/{dashboard.json => connections.json} (100%)
rename packages/web/public/i18n/locales/pt-BR/{dashboard.json => connections.json} (100%)
rename packages/web/public/i18n/locales/pt-PT/{dashboard.json => connections.json} (100%)
rename packages/web/public/i18n/locales/sv-SE/{dashboard.json => connections.json} (100%)
rename packages/web/public/i18n/locales/tr-TR/{dashboard.json => connections.json} (100%)
rename packages/web/public/i18n/locales/uk-UA/{dashboard.json => connections.json} (100%)
rename packages/web/public/i18n/locales/zh-CN/{dashboard.json => connections.json} (100%)
create mode 100644 packages/web/src/components/Badge/ConnectionStatusBadge.tsx
create mode 100644 packages/web/src/components/Badge/SupportedBadge.tsx
create mode 100644 packages/web/src/components/Dialog/AddConnectionDialog/AddConnectionDialog.tsx
create mode 100644 packages/web/src/components/Dialog/AddConnectionDialog/validation.ts
delete mode 100644 packages/web/src/components/Dialog/DeviceNameDialog.tsx
delete mode 100644 packages/web/src/components/PageComponents/Connect/BLE.tsx
delete mode 100644 packages/web/src/components/PageComponents/Connect/HTTP.test.tsx
delete mode 100644 packages/web/src/components/PageComponents/Connect/HTTP.tsx
delete mode 100644 packages/web/src/components/PageComponents/Connect/Serial.tsx
create mode 100644 packages/web/src/components/PageComponents/Connections/ConnectionStatusBadge.tsx
create mode 100644 packages/web/src/components/UI/AlertDialog.tsx
create mode 100644 packages/web/src/components/UI/Badge.tsx
create mode 100644 packages/web/src/components/UI/Card.tsx
create mode 100644 packages/web/src/core/hooks/useLRUList.ts
create mode 100644 packages/web/src/pages/Connections/index.tsx
create mode 100644 packages/web/src/pages/Connections/useConnections.ts
create mode 100644 packages/web/src/pages/Connections/utils.ts
delete mode 100644 packages/web/src/pages/Dashboard/index.tsx
diff --git a/.gitignore b/.gitignore
index cb8f36f4..5c67a54a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,3 +11,8 @@ npm/
**/LICENSE
packages/protobufs/packages/ts/dist
+
+# Local dev certs
+*.pem
+*.crt
+*.key
\ No newline at end of file
diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts
index 751f4a6c..8f055d67 100644
--- a/packages/core/src/types.ts
+++ b/packages/core/src/types.ts
@@ -46,6 +46,7 @@ export enum DeviceStatusEnum {
DeviceConnected = 5,
DeviceConfiguring = 6,
DeviceConfigured = 7,
+ DeviceError = 8,
}
export type LogEventPacket = LogEvent & { date: Date };
diff --git a/packages/web/package.json b/packages/web/package.json
index 14897845..39ea2963 100644
--- a/packages/web/package.json
+++ b/packages/web/package.json
@@ -14,16 +14,17 @@
"homepage": "https://meshtastic.org",
"scripts": {
"preinstall": "npx only-allow pnpm",
+ "setup:certs": "mkcert localhost 127.0.0.1 ::1",
"build": "vite build",
- "build:analyze": "BUNDLE_ANALYZE=true bun run build",
+ "build:analyze": "BUNDLE_ANALYZE=true pnpm run build",
"check": "biome check src/",
"check:fix": "biome check --write src/",
"dev": "vite",
+ "dev:https": "USE_HTTPS=true vite",
"test": "vitest",
- "ts:check": "bun run tsc --noEmit",
+ "typecheck": "pnpm run tsc --noEmit",
"preview": "vite preview",
"docker:build": "docker build -t meshtastic-web:latest -f ./infra/Containerfile .",
- "generate:routes": "bun @tanstack/router-cli generate --outDir src/ routes --rootRoutePath /",
"package": "gzipper c -i html,js,css,png,ico,svg,json,webmanifest,txt dist dist/output && tar -cvf dist/build.tar -C ./dist/output/ ."
},
"dependencies": {
@@ -34,6 +35,7 @@
"@meshtastic/transport-web-serial": "workspace:*",
"@noble/curves": "^1.9.2",
"@radix-ui/react-accordion": "^1.2.12",
+ "@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
@@ -97,6 +99,7 @@
"@types/react-dom": "^19.2.0",
"@types/serviceworker": "^0.0.158",
"@types/w3c-web-serial": "^1.0.8",
+ "@vitejs/plugin-basic-ssl": "^2.1.0",
"@vitejs/plugin-react": "^5.0.4",
"autoprefixer": "^10.4.21",
"gzipper": "^8.2.1",
diff --git a/packages/web/public/i18n/locales/be-BY/connections.json b/packages/web/public/i18n/locales/be-BY/connections.json
new file mode 100644
index 00000000..bc119e1b
--- /dev/null
+++ b/packages/web/public/i18n/locales/be-BY/connections.json
@@ -0,0 +1,10 @@
+{
+ "title": "Connected Devices",
+ "description": "Manage your connected Meshtastic devices.",
+ "connectionType_ble": "BLE",
+ "connectionType_serial": "Serial",
+ "connectionType_network": "Network",
+ "noDevicesTitle": "No devices connected",
+ "noDevicesDescription": "Connect a new device to get started.",
+ "button_newConnection": "New Connection"
+}
diff --git a/packages/web/public/i18n/locales/be-BY/dashboard.json b/packages/web/public/i18n/locales/be-BY/dashboard.json
deleted file mode 100644
index 3a3cd869..00000000
--- a/packages/web/public/i18n/locales/be-BY/dashboard.json
+++ /dev/null
@@ -1,12 +0,0 @@
-{
- "dashboard": {
- "title": "Connected Devices",
- "description": "Manage your connected Meshtastic devices.",
- "connectionType_ble": "BLE",
- "connectionType_serial": "Serial",
- "connectionType_network": "Network",
- "noDevicesTitle": "No devices connected",
- "noDevicesDescription": "Connect a new device to get started.",
- "button_newConnection": "New Connection"
- }
-}
diff --git a/packages/web/public/i18n/locales/be-BY/ui.json b/packages/web/public/i18n/locales/be-BY/ui.json
index 77436c05..277a515c 100644
--- a/packages/web/public/i18n/locales/be-BY/ui.json
+++ b/packages/web/public/i18n/locales/be-BY/ui.json
@@ -1,229 +1,228 @@
{
- "navigation": {
- "title": "Navigation",
- "messages": "Messages",
- "map": "Map",
- "settings": "Settings",
- "channels": "Channels",
- "radioConfig": "Radio Config",
- "deviceConfig": "Device Config",
- "moduleConfig": "Module Config",
- "nodes": "Nodes"
- },
- "app": {
- "title": "Meshtastic",
- "logo": "Meshtastic Logo"
- },
- "sidebar": {
- "collapseToggle": {
- "button": {
- "open": "Open sidebar",
- "close": "Close sidebar"
- }
- },
- "deviceInfo": {
- "volts": "{{voltage}} volts",
- "firmware": {
- "title": "Firmware",
- "version": "v{{version}}",
- "buildDate": "Build date: {{date}}"
- }
- }
- },
- "batteryStatus": {
- "charging": "{{level}}% charging",
- "pluggedIn": "Plugged in",
- "title": "Battery"
- },
- "search": {
- "nodes": "Search nodes...",
- "channels": "Search channels...",
- "commandPalette": "Search commands..."
- },
- "toast": {
- "positionRequestSent": {
- "title": "Position request sent."
- },
- "requestingPosition": {
- "title": "Requesting position, please wait..."
- },
- "sendingTraceroute": {
- "title": "Sending Traceroute, please wait..."
- },
- "tracerouteSent": {
- "title": "Traceroute sent."
- },
- "savedChannel": {
- "title": "Saved Channel: {{channelName}}"
- },
- "messages": {
- "pkiEncryption": {
- "title": "Chat is using PKI encryption."
- },
- "pskEncryption": {
- "title": "Chat is using PSK encryption."
- }
- },
- "configSaveError": {
- "title": "Error Saving Config",
- "description": "An error occurred while saving the configuration."
- },
- "validationError": {
- "title": "Config Errors Exist",
- "description": "Please fix the configuration errors before saving."
- },
- "saveSuccess": {
- "title": "Saving Config",
- "description": "The configuration change {{case}} has been saved."
- },
- "saveAllSuccess": {
- "title": "Saved",
- "description": "All configuration changes have been saved."
- },
- "favoriteNode": {
- "title": "{{action}} {{nodeName}} {{direction}} favorites.",
- "action": {
- "added": "Added",
- "removed": "Removed",
- "to": "to",
- "from": "from"
- }
- },
- "ignoreNode": {
- "title": "{{action}} {{nodeName}} {{direction}} ignore list",
- "action": {
- "added": "Added",
- "removed": "Removed",
- "to": "to",
- "from": "from"
- }
- }
- },
- "notifications": {
- "copied": {
- "label": "Copied!"
- },
- "copyToClipboard": {
- "label": "Copy to clipboard"
- },
- "hidePassword": {
- "label": "Hide password"
- },
- "showPassword": {
- "label": "Show password"
- },
- "deliveryStatus": {
- "delivered": "Delivered",
- "failed": "Delivery Failed",
- "waiting": "Waiting",
- "unknown": "Unknown"
- }
- },
- "general": {
- "label": "General"
- },
- "hardware": {
- "label": "Hardware"
- },
- "metrics": {
- "label": "Metrics"
- },
- "role": {
- "label": "Role"
- },
- "filter": {
- "label": "Filter"
- },
- "advanced": {
- "label": "Advanced"
- },
- "clearInput": {
- "label": "Clear input"
- },
- "resetFilters": {
- "label": "Reset Filters"
- },
- "nodeName": {
- "label": "Node name/number",
- "placeholder": "Meshtastic 1234"
- },
- "airtimeUtilization": {
- "label": "Airtime Utilization (%)",
- "short": "Airtime Util. (%)"
- },
- "batteryLevel": {
- "label": "Battery level (%)",
- "labelText": "Battery level (%): {{value}}"
- },
- "batteryVoltage": {
- "label": "Battery voltage (V)",
- "title": "Voltage"
- },
- "channelUtilization": {
- "label": "Channel Utilization (%)",
- "short": "Channel Util. (%)"
- },
- "hops": {
- "direct": "Direct",
- "label": "Number of hops",
- "text": "Number of hops: {{value}}"
- },
- "lastHeard": {
- "label": "Last heard",
- "labelText": "Last heard: {{value}}",
- "nowLabel": "Now"
- },
- "snr": {
- "label": "SNR (db)"
- },
- "favorites": {
- "label": "Favorites"
- },
- "hide": {
- "label": "Hide"
- },
- "showOnly": {
- "label": "Show Only"
- },
- "viaMqtt": {
- "label": "Connected via MQTT"
- },
- "hopsUnknown": {
- "label": "Unknown number of hops"
- },
- "showUnheard": {
- "label": "Never heard"
- },
- "language": {
- "label": "Language",
- "changeLanguage": "Change Language"
- },
- "theme": {
- "dark": "Dark",
- "light": "Light",
- "system": "Automatic",
- "changeTheme": "Change Color Scheme"
- },
- "errorPage": {
- "title": "This is a little embarrassing...",
- "description1": "We are really sorry but an error occurred in the web client that caused it to crash.
This is not supposed to happen, and we are working hard to fix it.",
- "description2": "The best way to prevent this from happening again to you or anyone else is to report the issue to us.",
- "reportInstructions": "Please include the following information in your report:",
- "reportSteps": {
- "step1": "What you were doing when the error occurred",
- "step2": "What you expected to happen",
- "step3": "What actually happened",
- "step4": "Any other relevant information"
- },
- "reportLink": "You can report the issue to our <0>GitHub0>",
- "dashboardLink": "Return to the <0>dashboard0>",
- "detailsSummary": "Error Details",
- "errorMessageLabel": "Error message:",
- "stackTraceLabel": "Stack trace:",
- "fallbackError": "{{error}}"
- },
- "footer": {
- "text": "Powered by <0>▲ Vercel0> | Meshtastic® is a registered trademark of Meshtastic LLC. | <1>Legal Information1>",
- "commitSha": "Commit SHA: {{sha}}"
- }
+ "navigation": {
+ "title": "Navigation",
+ "messages": "Messages",
+ "map": "Map",
+ "config": "Config",
+ "radioConfig": "Radio Config",
+ "moduleConfig": "Module Config",
+ "channels": "Channels",
+ "nodes": "Nodes"
+ },
+ "app": {
+ "title": "Meshtastic",
+ "logo": "Meshtastic Logo"
+ },
+ "sidebar": {
+ "collapseToggle": {
+ "button": {
+ "open": "Open sidebar",
+ "close": "Close sidebar"
+ }
+ },
+ "deviceInfo": {
+ "volts": "{{voltage}} volts",
+ "firmware": {
+ "title": "Firmware",
+ "version": "v{{version}}",
+ "buildDate": "Build date: {{date}}"
+ },
+ "deviceName": {
+ "title": "Device Name",
+ "changeName": "Change Device Name",
+ "placeholder": "Enter device name"
+ },
+ "editDeviceName": "Edit device name"
+ }
+ },
+ "batteryStatus": {
+ "charging": "{{level}}% charging",
+ "pluggedIn": "Plugged in",
+ "title": "Battery"
+ },
+ "search": {
+ "nodes": "Search nodes...",
+ "channels": "Search channels...",
+ "commandPalette": "Search commands..."
+ },
+ "toast": {
+ "positionRequestSent": {
+ "title": "Position request sent."
+ },
+ "requestingPosition": {
+ "title": "Requesting position, please wait..."
+ },
+ "sendingTraceroute": {
+ "title": "Sending Traceroute, please wait..."
+ },
+ "tracerouteSent": {
+ "title": "Traceroute sent."
+ },
+ "savedChannel": {
+ "title": "Saved Channel: {{channelName}}"
+ },
+ "messages": {
+ "pkiEncryption": {
+ "title": "Chat is using PKI encryption."
+ },
+ "pskEncryption": {
+ "title": "Chat is using PSK encryption."
+ }
+ },
+ "configSaveError": {
+ "title": "Error Saving Config",
+ "description": "An error occurred while saving the configuration."
+ },
+ "validationError": {
+ "title": "Config Errors Exist",
+ "description": "Please fix the configuration errors before saving."
+ },
+ "saveSuccess": {
+ "title": "Saving Config",
+ "description": "The configuration change {{case}} has been saved."
+ },
+ "favoriteNode": {
+ "title": "{{action}} {{nodeName}} {{direction}} favorites.",
+ "action": {
+ "added": "Added",
+ "removed": "Removed",
+ "to": "to",
+ "from": "from"
+ }
+ },
+ "ignoreNode": {
+ "title": "{{action}} {{nodeName}} {{direction}} ignore list",
+ "action": {
+ "added": "Added",
+ "removed": "Removed",
+ "to": "to",
+ "from": "from"
+ }
+ }
+ },
+ "notifications": {
+ "copied": {
+ "label": "Copied!"
+ },
+ "copyToClipboard": {
+ "label": "Copy to clipboard"
+ },
+ "hidePassword": {
+ "label": "Hide password"
+ },
+ "showPassword": {
+ "label": "Show password"
+ },
+ "deliveryStatus": {
+ "delivered": "Delivered",
+ "failed": "Delivery Failed",
+ "waiting": "Waiting",
+ "unknown": "Unknown"
+ }
+ },
+ "general": {
+ "label": "General"
+ },
+ "hardware": {
+ "label": "Hardware"
+ },
+ "metrics": {
+ "label": "Metrics"
+ },
+ "role": {
+ "label": "Role"
+ },
+ "filter": {
+ "label": "Filter"
+ },
+ "advanced": {
+ "label": "Advanced"
+ },
+ "clearInput": {
+ "label": "Clear input"
+ },
+ "resetFilters": {
+ "label": "Reset Filters"
+ },
+ "nodeName": {
+ "label": "Node name/number",
+ "placeholder": "Meshtastic 1234"
+ },
+ "airtimeUtilization": {
+ "label": "Airtime Utilization (%)"
+ },
+ "batteryLevel": {
+ "label": "Battery level (%)",
+ "labelText": "Battery level (%): {{value}}"
+ },
+ "batteryVoltage": {
+ "label": "Battery voltage (V)",
+ "title": "Voltage"
+ },
+ "channelUtilization": {
+ "label": "Channel Utilization (%)"
+ },
+ "hops": {
+ "direct": "Direct",
+ "label": "Number of hops",
+ "text": "Number of hops: {{value}}"
+ },
+ "lastHeard": {
+ "label": "Last heard",
+ "labelText": "Last heard: {{value}}",
+ "nowLabel": "Now"
+ },
+ "snr": {
+ "label": "SNR (db)"
+ },
+ "favorites": {
+ "label": "Favorites"
+ },
+ "hide": {
+ "label": "Hide"
+ },
+ "showOnly": {
+ "label": "Show Only"
+ },
+ "viaMqtt": {
+ "label": "Connected via MQTT"
+ },
+ "hopsUnknown": {
+ "label": "Unknown number of hops"
+ },
+ "showUnheard": {
+ "label": "Never heard"
+ },
+ "language": {
+ "label": "Language",
+ "changeLanguage": "Change Language"
+ },
+ "theme": {
+ "dark": "Dark",
+ "light": "Light",
+ "system": "Automatic",
+ "changeTheme": "Change Color Scheme"
+ },
+ "errorPage": {
+ "title": "This is a little embarrassing...",
+ "description1": "We are really sorry but an error occurred in the web client that caused it to crash.
This is not supposed to happen, and we are working hard to fix it.",
+ "description2": "The best way to prevent this from happening again to you or anyone else is to report the issue to us.",
+ "reportInstructions": "Please include the following information in your report:",
+ "reportSteps": {
+ "step1": "What you were doing when the error occurred",
+ "step2": "What you expected to happen",
+ "step3": "What actually happened",
+ "step4": "Any other relevant information"
+ },
+ "reportLink": "You can report the issue to our <0>GitHub0>",
+ "connectionsLink": "Return to the <0>connections0>",
+ "detailsSummary": "Error Details",
+ "errorMessageLabel": "Error message:",
+ "stackTraceLabel": "Stack trace:",
+ "fallbackError": "{{error}}"
+ },
+ "footer": {
+ "text": "Powered by <0>▲ Vercel0> | Meshtastic® is a registered trademark of Meshtastic LLC. | <1>Legal Information1>",
+ "commitSha": "Commit SHA: {{sha}}"
+ }
}
diff --git a/packages/web/public/i18n/locales/bg-BG/connections.json b/packages/web/public/i18n/locales/bg-BG/connections.json
new file mode 100644
index 00000000..6ceb36fb
--- /dev/null
+++ b/packages/web/public/i18n/locales/bg-BG/connections.json
@@ -0,0 +1,12 @@
+{
+ "connections": {
+ "title": "Свързани устройства",
+ "description": "Управлявайте свързаните си устройства Meshtastic.",
+ "connectionType_ble": "BLE",
+ "connectionType_serial": "Серийна",
+ "connectionType_network": "Мрежа",
+ "noDevicesTitle": "Няма свързани устройства",
+ "noDevicesDescription": "Свържете ново устройство, за да започнете.",
+ "button_newConnection": "Нова връзка"
+ }
+}
diff --git a/packages/web/public/i18n/locales/bg-BG/dashboard.json b/packages/web/public/i18n/locales/bg-BG/dashboard.json
deleted file mode 100644
index 4f1c9d39..00000000
--- a/packages/web/public/i18n/locales/bg-BG/dashboard.json
+++ /dev/null
@@ -1,12 +0,0 @@
-{
- "dashboard": {
- "title": "Свързани устройства",
- "description": "Управлявайте свързаните си устройства Meshtastic.",
- "connectionType_ble": "BLE",
- "connectionType_serial": "Серийна",
- "connectionType_network": "Мрежа",
- "noDevicesTitle": "Няма свързани устройства",
- "noDevicesDescription": "Свържете ново устройство, за да започнете.",
- "button_newConnection": "Нова връзка"
- }
-}
diff --git a/packages/web/public/i18n/locales/bg-BG/ui.json b/packages/web/public/i18n/locales/bg-BG/ui.json
index abb09c41..a63d6d3a 100644
--- a/packages/web/public/i18n/locales/bg-BG/ui.json
+++ b/packages/web/public/i18n/locales/bg-BG/ui.json
@@ -1,229 +1,228 @@
{
- "navigation": {
- "title": "Навигация",
- "messages": "Съобщения",
- "map": "Карта",
- "settings": "Настройки",
- "channels": "Канали",
- "radioConfig": "Конфигурация на радиото",
- "deviceConfig": "Конфигуриране на устройството",
- "moduleConfig": "Конфигурация на модула",
- "nodes": "Възли"
- },
- "app": {
- "title": "Meshtastic",
- "logo": "Лого на Meshtastic"
- },
- "sidebar": {
- "collapseToggle": {
- "button": {
- "open": "Отваряне на страничната лента",
- "close": "Затваряне на страничната лента"
- }
- },
- "deviceInfo": {
- "volts": "{{voltage}} волта",
- "firmware": {
- "title": "Фърмуер",
- "version": "v{{version}}",
- "buildDate": "Дата на компилация: {{date}}"
- }
- }
- },
- "batteryStatus": {
- "charging": "{{level}}% зареждане",
- "pluggedIn": "Включен в ел. мрежа",
- "title": "Батерия"
- },
- "search": {
- "nodes": "Търсене на възли...",
- "channels": "Търсене на канали...",
- "commandPalette": "Търсене на команди..."
- },
- "toast": {
- "positionRequestSent": {
- "title": "Заявката за позиция е изпратена."
- },
- "requestingPosition": {
- "title": "Запитване за позиция, моля изчакайте..."
- },
- "sendingTraceroute": {
- "title": "Sending Traceroute, please wait..."
- },
- "tracerouteSent": {
- "title": "Traceroute sent."
- },
- "savedChannel": {
- "title": "Запазен канал: {{channelName}}"
- },
- "messages": {
- "pkiEncryption": {
- "title": "Чатът използва PKI криптиране."
- },
- "pskEncryption": {
- "title": "Чатът използва PSK криптиране."
- }
- },
- "configSaveError": {
- "title": "Грешка при запазване на конфигурацията",
- "description": "Възникна грешка при запазване на конфигурацията."
- },
- "validationError": {
- "title": "Съществуват грешки в конфигурацията",
- "description": "Моля, коригирайте грешките в конфигурацията, преди да я запазите."
- },
- "saveSuccess": {
- "title": "Запазване на конфигурацията",
- "description": "Промяната в конфигурацията {{case}} е запазена."
- },
- "saveAllSuccess": {
- "title": "Saved",
- "description": "All configuration changes have been saved."
- },
- "favoriteNode": {
- "title": "{{action}} {{nodeName}} {{direction}} любими.",
- "action": {
- "added": "Добавен",
- "removed": "Премахнат",
- "to": "в",
- "from": "от"
- }
- },
- "ignoreNode": {
- "title": "{{action}} {{nodeName}} {{direction}} списък с игнорирани",
- "action": {
- "added": "Добавен",
- "removed": "Премахнат",
- "to": "в",
- "from": "от"
- }
- }
- },
- "notifications": {
- "copied": {
- "label": "Копирано!"
- },
- "copyToClipboard": {
- "label": "Копиране в клипборда"
- },
- "hidePassword": {
- "label": "Скриване на паролата"
- },
- "showPassword": {
- "label": "Показване на паролата"
- },
- "deliveryStatus": {
- "delivered": "Доставено",
- "failed": "Неуспешна доставка",
- "waiting": "Изчакване",
- "unknown": "Неизвестно"
- }
- },
- "general": {
- "label": "General"
- },
- "hardware": {
- "label": "Хардуер"
- },
- "metrics": {
- "label": "Метрики"
- },
- "role": {
- "label": "Роля"
- },
- "filter": {
- "label": "Филтър"
- },
- "advanced": {
- "label": "Разширени"
- },
- "clearInput": {
- "label": "Clear input"
- },
- "resetFilters": {
- "label": "Нулиране на филтрите"
- },
- "nodeName": {
- "label": "Име/номер на възел",
- "placeholder": "Meshtastic 1234"
- },
- "airtimeUtilization": {
- "label": "Използване на ефира (%)",
- "short": "Airtime Util. (%)"
- },
- "batteryLevel": {
- "label": "Ниво на батерията (%)",
- "labelText": "Ниво на батерията (%): {{value}}"
- },
- "batteryVoltage": {
- "label": "Напрежение на батерията (V)",
- "title": "Напрежение"
- },
- "channelUtilization": {
- "label": "Използване на канала (%)",
- "short": "Channel Util. (%)"
- },
- "hops": {
- "direct": "Директно",
- "label": "Брой хопове",
- "text": "Брой хопове: {{value}}"
- },
- "lastHeard": {
- "label": "Последно чут",
- "labelText": "Последно чут: {{value}}",
- "nowLabel": "Сега"
- },
- "snr": {
- "label": "SNR (db)"
- },
- "favorites": {
- "label": "Любими"
- },
- "hide": {
- "label": "Скриване"
- },
- "showOnly": {
- "label": "Показване само"
- },
- "viaMqtt": {
- "label": "Свързан чрез MQTT"
- },
- "hopsUnknown": {
- "label": "Неизвестен брой хопове"
- },
- "showUnheard": {
- "label": "Никога не е чуван"
- },
- "language": {
- "label": "Език",
- "changeLanguage": "Промяна на езика"
- },
- "theme": {
- "dark": "Тъмна",
- "light": "Светла",
- "system": "Автоматично",
- "changeTheme": "Промяна на цветовата схема"
- },
- "errorPage": {
- "title": "Това е малко смущаващо...",
- "description1": "Наистина съжаляваме, но възникна грешка в web клиента, която доведе до срив.
Това не би трябвало да се случва и работим усилено, за да го поправим.",
- "description2": "Най-добрият начин да предотвратите това да се случи отново с вас или с някой друг е да ни съобщите за проблема.",
- "reportInstructions": "Моля, включете следната информация в доклада си:",
- "reportSteps": {
- "step1": "Какво правехте, когато възникна грешката",
- "step2": "Какво очаквахте да се случи",
- "step3": "Какво всъщност се случи",
- "step4": "Всяка друга подходяща информация"
- },
- "reportLink": "Можете да съобщите за проблема в нашия <0>GitHub0>",
- "dashboardLink": "Връщане към <0>таблото0>",
- "detailsSummary": "Подробности за грешката",
- "errorMessageLabel": "Съобщение за грешка:",
- "stackTraceLabel": "Stack trace:",
- "fallbackError": "{{error}}"
- },
- "footer": {
- "text": "Задвижвано от <0>▲ Vercel0> | Meshtastic® е регистрирана търговска марка на Meshtastic LLC. | <1>Правна информация1>",
- "commitSha": "Commit SHA: {{sha}}"
- }
+ "navigation": {
+ "title": "Навигация",
+ "messages": "Съобщения",
+ "map": "Карта",
+ "config": "Конфигурация",
+ "radioConfig": "Конфигурация на радиото",
+ "moduleConfig": "Конфигурация на модула",
+ "channels": "Канали",
+ "nodes": "Възли"
+ },
+ "app": {
+ "title": "Meshtastic",
+ "logo": "Лого на Meshtastic"
+ },
+ "sidebar": {
+ "collapseToggle": {
+ "button": {
+ "open": "Отваряне на страничната лента",
+ "close": "Затваряне на страничната лента"
+ }
+ },
+ "deviceInfo": {
+ "volts": "{{voltage}} волта",
+ "firmware": {
+ "title": "Фърмуер",
+ "version": "v{{version}}",
+ "buildDate": "Дата на компилация: {{date}}"
+ },
+ "deviceName": {
+ "title": "Име на устройството",
+ "changeName": "Промяна на името на устройството",
+ "placeholder": "Въвеждане на име на устройството"
+ },
+ "editDeviceName": "Редактиране на името на устройство"
+ }
+ },
+ "batteryStatus": {
+ "charging": "{{level}}% зареждане",
+ "pluggedIn": "Включен в ел. мрежа",
+ "title": "Батерия"
+ },
+ "search": {
+ "nodes": "Търсене на възли...",
+ "channels": "Търсене на канали...",
+ "commandPalette": "Търсене на команди..."
+ },
+ "toast": {
+ "positionRequestSent": {
+ "title": "Заявката за позиция е изпратена."
+ },
+ "requestingPosition": {
+ "title": "Запитване за позиция, моля изчакайте..."
+ },
+ "sendingTraceroute": {
+ "title": "Sending Traceroute, please wait..."
+ },
+ "tracerouteSent": {
+ "title": "Traceroute sent."
+ },
+ "savedChannel": {
+ "title": "Запазен канал: {{channelName}}"
+ },
+ "messages": {
+ "pkiEncryption": {
+ "title": "Чатът използва PKI криптиране."
+ },
+ "pskEncryption": {
+ "title": "Чатът използва PSK криптиране."
+ }
+ },
+ "configSaveError": {
+ "title": "Грешка при запазване на конфигурацията",
+ "description": "Възникна грешка при запазване на конфигурацията."
+ },
+ "validationError": {
+ "title": "Съществуват грешки в конфигурацията",
+ "description": "Моля, коригирайте грешките в конфигурацията, преди да я запазите."
+ },
+ "saveSuccess": {
+ "title": "Запазване на конфигурацията",
+ "description": "Промяната в конфигурацията {{case}} е запазена."
+ },
+ "favoriteNode": {
+ "title": "{{action}} {{nodeName}} {{direction}} любими.",
+ "action": {
+ "added": "Добавен",
+ "removed": "Премахнат",
+ "to": "в",
+ "from": "от"
+ }
+ },
+ "ignoreNode": {
+ "title": "{{action}} {{nodeName}} {{direction}} списък с игнорирани",
+ "action": {
+ "added": "Добавен",
+ "removed": "Премахнат",
+ "to": "в",
+ "from": "от"
+ }
+ }
+ },
+ "notifications": {
+ "copied": {
+ "label": "Копирано!"
+ },
+ "copyToClipboard": {
+ "label": "Копиране в клипборда"
+ },
+ "hidePassword": {
+ "label": "Скриване на паролата"
+ },
+ "showPassword": {
+ "label": "Показване на паролата"
+ },
+ "deliveryStatus": {
+ "delivered": "Доставено",
+ "failed": "Неуспешна доставка",
+ "waiting": "Изчакване",
+ "unknown": "Неизвестно"
+ }
+ },
+ "general": {
+ "label": "General"
+ },
+ "hardware": {
+ "label": "Хардуер"
+ },
+ "metrics": {
+ "label": "Метрики"
+ },
+ "role": {
+ "label": "Роля"
+ },
+ "filter": {
+ "label": "Филтър"
+ },
+ "advanced": {
+ "label": "Разширени"
+ },
+ "clearInput": {
+ "label": "Clear input"
+ },
+ "resetFilters": {
+ "label": "Нулиране на филтрите"
+ },
+ "nodeName": {
+ "label": "Име/номер на възел",
+ "placeholder": "Meshtastic 1234"
+ },
+ "airtimeUtilization": {
+ "label": "Използване на ефира (%)"
+ },
+ "batteryLevel": {
+ "label": "Ниво на батерията (%)",
+ "labelText": "Ниво на батерията (%): {{value}}"
+ },
+ "batteryVoltage": {
+ "label": "Напрежение на батерията (V)",
+ "title": "Напрежение"
+ },
+ "channelUtilization": {
+ "label": "Използване на канала (%)"
+ },
+ "hops": {
+ "direct": "Директно",
+ "label": "Брой хопове",
+ "text": "Брой хопове: {{value}}"
+ },
+ "lastHeard": {
+ "label": "Последно чут",
+ "labelText": "Последно чут: {{value}}",
+ "nowLabel": "Сега"
+ },
+ "snr": {
+ "label": "SNR (db)"
+ },
+ "favorites": {
+ "label": "Любими"
+ },
+ "hide": {
+ "label": "Скриване"
+ },
+ "showOnly": {
+ "label": "Показване само"
+ },
+ "viaMqtt": {
+ "label": "Свързан чрез MQTT"
+ },
+ "hopsUnknown": {
+ "label": "Неизвестен брой хопове"
+ },
+ "showUnheard": {
+ "label": "Никога не е чуван"
+ },
+ "language": {
+ "label": "Език",
+ "changeLanguage": "Промяна на езика"
+ },
+ "theme": {
+ "dark": "Тъмна",
+ "light": "Светла",
+ "system": "Автоматично",
+ "changeTheme": "Промяна на цветовата схема"
+ },
+ "errorPage": {
+ "title": "Това е малко смущаващо...",
+ "description1": "Наистина съжаляваме, но възникна грешка в web клиента, която доведе до срив.
Това не би трябвало да се случва и работим усилено, за да го поправим.",
+ "description2": "Най-добрият начин да предотвратите това да се случи отново с вас или с някой друг е да ни съобщите за проблема.",
+ "reportInstructions": "Моля, включете следната информация в доклада си:",
+ "reportSteps": {
+ "step1": "Какво правехте, когато възникна грешката",
+ "step2": "Какво очаквахте да се случи",
+ "step3": "Какво всъщност се случи",
+ "step4": "Всяка друга подходяща информация"
+ },
+ "reportLink": "Можете да съобщите за проблема в нашия <0>GitHub0>",
+ "connectionsLink": "Връщане към <0>таблото0>",
+ "detailsSummary": "Подробности за грешката",
+ "errorMessageLabel": "Съобщение за грешка:",
+ "stackTraceLabel": "Stack trace:",
+ "fallbackError": "{{error}}"
+ },
+ "footer": {
+ "text": "Задвижвано от <0>▲ Vercel0> | Meshtastic® е регистрирана търговска марка на Meshtastic LLC. | <1>Правна информация1>",
+ "commitSha": "Commit SHA: {{sha}}"
+ }
}
diff --git a/packages/web/public/i18n/locales/cs-CZ/dashboard.json b/packages/web/public/i18n/locales/cs-CZ/connections.json
similarity index 100%
rename from packages/web/public/i18n/locales/cs-CZ/dashboard.json
rename to packages/web/public/i18n/locales/cs-CZ/connections.json
diff --git a/packages/web/public/i18n/locales/cs-CZ/ui.json b/packages/web/public/i18n/locales/cs-CZ/ui.json
index a31f2fee..c609446a 100644
--- a/packages/web/public/i18n/locales/cs-CZ/ui.json
+++ b/packages/web/public/i18n/locales/cs-CZ/ui.json
@@ -1,229 +1,228 @@
{
- "navigation": {
- "title": "Navigation",
- "messages": "Zprávy",
- "map": "Mapa",
- "settings": "Nastavení",
- "channels": "Kanály",
- "radioConfig": "Radio Config",
- "deviceConfig": "Nastavení zařízení",
- "moduleConfig": "Module Config",
- "nodes": "Uzly"
- },
- "app": {
- "title": "Meshtastic",
- "logo": "Meshtastic Logo"
- },
- "sidebar": {
- "collapseToggle": {
- "button": {
- "open": "Open sidebar",
- "close": "Close sidebar"
- }
- },
- "deviceInfo": {
- "volts": "{{voltage}} volts",
- "firmware": {
- "title": "Firmware",
- "version": "v{{version}}",
- "buildDate": "Build date: {{date}}"
- }
- }
- },
- "batteryStatus": {
- "charging": "{{level}}% charging",
- "pluggedIn": "Plugged in",
- "title": "Baterie"
- },
- "search": {
- "nodes": "Search nodes...",
- "channels": "Search channels...",
- "commandPalette": "Search commands..."
- },
- "toast": {
- "positionRequestSent": {
- "title": "Position request sent."
- },
- "requestingPosition": {
- "title": "Requesting position, please wait..."
- },
- "sendingTraceroute": {
- "title": "Sending Traceroute, please wait..."
- },
- "tracerouteSent": {
- "title": "Traceroute sent."
- },
- "savedChannel": {
- "title": "Saved Channel: {{channelName}}"
- },
- "messages": {
- "pkiEncryption": {
- "title": "Chat is using PKI encryption."
- },
- "pskEncryption": {
- "title": "Chat is using PSK encryption."
- }
- },
- "configSaveError": {
- "title": "Error Saving Config",
- "description": "An error occurred while saving the configuration."
- },
- "validationError": {
- "title": "Config Errors Exist",
- "description": "Please fix the configuration errors before saving."
- },
- "saveSuccess": {
- "title": "Saving Config",
- "description": "The configuration change {{case}} has been saved."
- },
- "saveAllSuccess": {
- "title": "Saved",
- "description": "All configuration changes have been saved."
- },
- "favoriteNode": {
- "title": "{{action}} {{nodeName}} {{direction}} favorites.",
- "action": {
- "added": "Added",
- "removed": "Removed",
- "to": "to",
- "from": "from"
- }
- },
- "ignoreNode": {
- "title": "{{action}} {{nodeName}} {{direction}} ignore list",
- "action": {
- "added": "Added",
- "removed": "Removed",
- "to": "to",
- "from": "from"
- }
- }
- },
- "notifications": {
- "copied": {
- "label": "Copied!"
- },
- "copyToClipboard": {
- "label": "Copy to clipboard"
- },
- "hidePassword": {
- "label": "Skrýt heslo"
- },
- "showPassword": {
- "label": "Zobrazit heslo"
- },
- "deliveryStatus": {
- "delivered": "Delivered",
- "failed": "Delivery Failed",
- "waiting": "Waiting",
- "unknown": "Unknown"
- }
- },
- "general": {
- "label": "General"
- },
- "hardware": {
- "label": "Hardware"
- },
- "metrics": {
- "label": "Metriky"
- },
- "role": {
- "label": "Role"
- },
- "filter": {
- "label": "Filtr"
- },
- "advanced": {
- "label": "Advanced"
- },
- "clearInput": {
- "label": "Clear input"
- },
- "resetFilters": {
- "label": "Reset Filters"
- },
- "nodeName": {
- "label": "Node name/number",
- "placeholder": "Meshtastic 1234"
- },
- "airtimeUtilization": {
- "label": "Airtime Utilization (%)",
- "short": "Airtime Util. (%)"
- },
- "batteryLevel": {
- "label": "Battery level (%)",
- "labelText": "Battery level (%): {{value}}"
- },
- "batteryVoltage": {
- "label": "Battery voltage (V)",
- "title": "Napětí"
- },
- "channelUtilization": {
- "label": "Channel Utilization (%)",
- "short": "Channel Util. (%)"
- },
- "hops": {
- "direct": "Přímý",
- "label": "Number of hops",
- "text": "Number of hops: {{value}}"
- },
- "lastHeard": {
- "label": "Naposledy slyšen",
- "labelText": "Last heard: {{value}}",
- "nowLabel": "Now"
- },
- "snr": {
- "label": "SNR (db)"
- },
- "favorites": {
- "label": "Favorites"
- },
- "hide": {
- "label": "Hide"
- },
- "showOnly": {
- "label": "Show Only"
- },
- "viaMqtt": {
- "label": "Connected via MQTT"
- },
- "hopsUnknown": {
- "label": "Unknown number of hops"
- },
- "showUnheard": {
- "label": "Never heard"
- },
- "language": {
- "label": "Jazyk",
- "changeLanguage": "Change Language"
- },
- "theme": {
- "dark": "Tmavý",
- "light": "Světlý",
- "system": "Automatic",
- "changeTheme": "Change Color Scheme"
- },
- "errorPage": {
- "title": "This is a little embarrassing...",
- "description1": "We are really sorry but an error occurred in the web client that caused it to crash.
This is not supposed to happen, and we are working hard to fix it.",
- "description2": "The best way to prevent this from happening again to you or anyone else is to report the issue to us.",
- "reportInstructions": "Please include the following information in your report:",
- "reportSteps": {
- "step1": "What you were doing when the error occurred",
- "step2": "What you expected to happen",
- "step3": "What actually happened",
- "step4": "Any other relevant information"
- },
- "reportLink": "You can report the issue to our <0>GitHub0>",
- "dashboardLink": "Return to the <0>dashboard0>",
- "detailsSummary": "Error Details",
- "errorMessageLabel": "Error message:",
- "stackTraceLabel": "Stack trace:",
- "fallbackError": "{{error}}"
- },
- "footer": {
- "text": "Powered by <0>▲ Vercel0> | Meshtastic® is a registered trademark of Meshtastic LLC. | <1>Legal Information1>",
- "commitSha": "Commit SHA: {{sha}}"
- }
+ "navigation": {
+ "title": "Navigation",
+ "messages": "Zprávy",
+ "map": "Mapa",
+ "config": "Config",
+ "radioConfig": "Radio Config",
+ "moduleConfig": "Module Config",
+ "channels": "Kanály",
+ "nodes": "Uzly"
+ },
+ "app": {
+ "title": "Meshtastic",
+ "logo": "Meshtastic Logo"
+ },
+ "sidebar": {
+ "collapseToggle": {
+ "button": {
+ "open": "Open sidebar",
+ "close": "Close sidebar"
+ }
+ },
+ "deviceInfo": {
+ "volts": "{{voltage}} volts",
+ "firmware": {
+ "title": "Firmware",
+ "version": "v{{version}}",
+ "buildDate": "Build date: {{date}}"
+ },
+ "deviceName": {
+ "title": "Device Name",
+ "changeName": "Change Device Name",
+ "placeholder": "Enter device name"
+ },
+ "editDeviceName": "Edit device name"
+ }
+ },
+ "batteryStatus": {
+ "charging": "{{level}}% charging",
+ "pluggedIn": "Plugged in",
+ "title": "Baterie"
+ },
+ "search": {
+ "nodes": "Search nodes...",
+ "channels": "Search channels...",
+ "commandPalette": "Search commands..."
+ },
+ "toast": {
+ "positionRequestSent": {
+ "title": "Position request sent."
+ },
+ "requestingPosition": {
+ "title": "Requesting position, please wait..."
+ },
+ "sendingTraceroute": {
+ "title": "Sending Traceroute, please wait..."
+ },
+ "tracerouteSent": {
+ "title": "Traceroute sent."
+ },
+ "savedChannel": {
+ "title": "Saved Channel: {{channelName}}"
+ },
+ "messages": {
+ "pkiEncryption": {
+ "title": "Chat is using PKI encryption."
+ },
+ "pskEncryption": {
+ "title": "Chat is using PSK encryption."
+ }
+ },
+ "configSaveError": {
+ "title": "Error Saving Config",
+ "description": "An error occurred while saving the configuration."
+ },
+ "validationError": {
+ "title": "Config Errors Exist",
+ "description": "Please fix the configuration errors before saving."
+ },
+ "saveSuccess": {
+ "title": "Saving Config",
+ "description": "The configuration change {{case}} has been saved."
+ },
+ "favoriteNode": {
+ "title": "{{action}} {{nodeName}} {{direction}} favorites.",
+ "action": {
+ "added": "Added",
+ "removed": "Removed",
+ "to": "to",
+ "from": "from"
+ }
+ },
+ "ignoreNode": {
+ "title": "{{action}} {{nodeName}} {{direction}} ignore list",
+ "action": {
+ "added": "Added",
+ "removed": "Removed",
+ "to": "to",
+ "from": "from"
+ }
+ }
+ },
+ "notifications": {
+ "copied": {
+ "label": "Copied!"
+ },
+ "copyToClipboard": {
+ "label": "Copy to clipboard"
+ },
+ "hidePassword": {
+ "label": "Skrýt heslo"
+ },
+ "showPassword": {
+ "label": "Zobrazit heslo"
+ },
+ "deliveryStatus": {
+ "delivered": "Delivered",
+ "failed": "Delivery Failed",
+ "waiting": "Waiting",
+ "unknown": "Unknown"
+ }
+ },
+ "general": {
+ "label": "General"
+ },
+ "hardware": {
+ "label": "Hardware"
+ },
+ "metrics": {
+ "label": "Metriky"
+ },
+ "role": {
+ "label": "Role"
+ },
+ "filter": {
+ "label": "Filtr"
+ },
+ "advanced": {
+ "label": "Advanced"
+ },
+ "clearInput": {
+ "label": "Clear input"
+ },
+ "resetFilters": {
+ "label": "Reset Filters"
+ },
+ "nodeName": {
+ "label": "Node name/number",
+ "placeholder": "Meshtastic 1234"
+ },
+ "airtimeUtilization": {
+ "label": "Airtime Utilization (%)"
+ },
+ "batteryLevel": {
+ "label": "Battery level (%)",
+ "labelText": "Battery level (%): {{value}}"
+ },
+ "batteryVoltage": {
+ "label": "Battery voltage (V)",
+ "title": "Napětí"
+ },
+ "channelUtilization": {
+ "label": "Channel Utilization (%)"
+ },
+ "hops": {
+ "direct": "Přímý",
+ "label": "Number of hops",
+ "text": "Number of hops: {{value}}"
+ },
+ "lastHeard": {
+ "label": "Naposledy slyšen",
+ "labelText": "Last heard: {{value}}",
+ "nowLabel": "Now"
+ },
+ "snr": {
+ "label": "SNR (db)"
+ },
+ "favorites": {
+ "label": "Favorites"
+ },
+ "hide": {
+ "label": "Hide"
+ },
+ "showOnly": {
+ "label": "Show Only"
+ },
+ "viaMqtt": {
+ "label": "Connected via MQTT"
+ },
+ "hopsUnknown": {
+ "label": "Unknown number of hops"
+ },
+ "showUnheard": {
+ "label": "Never heard"
+ },
+ "language": {
+ "label": "Jazyk",
+ "changeLanguage": "Change Language"
+ },
+ "theme": {
+ "dark": "Tmavý",
+ "light": "Světlý",
+ "system": "Automatic",
+ "changeTheme": "Change Color Scheme"
+ },
+ "errorPage": {
+ "title": "This is a little embarrassing...",
+ "description1": "We are really sorry but an error occurred in the web client that caused it to crash.
This is not supposed to happen, and we are working hard to fix it.",
+ "description2": "The best way to prevent this from happening again to you or anyone else is to report the issue to us.",
+ "reportInstructions": "Please include the following information in your report:",
+ "reportSteps": {
+ "step1": "What you were doing when the error occurred",
+ "step2": "What you expected to happen",
+ "step3": "What actually happened",
+ "step4": "Any other relevant information"
+ },
+ "reportLink": "You can report the issue to our <0>GitHub0>",
+ "connectionsLink": "Return to the <0>connections0>",
+ "detailsSummary": "Error Details",
+ "errorMessageLabel": "Error message:",
+ "stackTraceLabel": "Stack trace:",
+ "fallbackError": "{{error}}"
+ },
+ "footer": {
+ "text": "Powered by <0>▲ Vercel0> | Meshtastic® is a registered trademark of Meshtastic LLC. | <1>Legal Information1>",
+ "commitSha": "Commit SHA: {{sha}}"
+ }
}
diff --git a/packages/web/public/i18n/locales/de-DE/dashboard.json b/packages/web/public/i18n/locales/de-DE/connections.json
similarity index 100%
rename from packages/web/public/i18n/locales/de-DE/dashboard.json
rename to packages/web/public/i18n/locales/de-DE/connections.json
diff --git a/packages/web/public/i18n/locales/en/common.json b/packages/web/public/i18n/locales/en/common.json
index 6c25fc9f..db09d144 100644
--- a/packages/web/public/i18n/locales/en/common.json
+++ b/packages/web/public/i18n/locales/en/common.json
@@ -1,14 +1,18 @@
{
"button": {
"apply": "Apply",
+ "addConnection": "Add Connection",
+ "saveConnection": "Save connection",
"backupKey": "Backup Key",
"cancel": "Cancel",
+ "connect": "Connect",
"clearMessages": "Clear Messages",
"close": "Close",
"confirm": "Confirm",
"delete": "Delete",
"dismiss": "Dismiss",
"download": "Download",
+ "disconnect": "Disconnect",
"export": "Export",
"generate": "Generate",
"regenerate": "Regenerate",
@@ -21,7 +25,10 @@
"requestNewKeys": "Request New Keys",
"requestPosition": "Request Position",
"reset": "Reset",
+ "retry": "Retry",
"save": "Save",
+ "setDefault": "Set as default",
+ "unsetDefault": "Unset default",
"scanQr": "Scan QR Code",
"traceRoute": "Trace Route",
"submit": "Submit"
diff --git a/packages/web/public/i18n/locales/en/connections.json b/packages/web/public/i18n/locales/en/connections.json
new file mode 100644
index 00000000..d8679be5
--- /dev/null
+++ b/packages/web/public/i18n/locales/en/connections.json
@@ -0,0 +1,34 @@
+{
+ "page": {
+ "title": "Connect to a Meshtastic device",
+ "description": "Add a device connection via HTTP, Bluetooth, or Serial. Your saved connections will be saved in your browser."
+ },
+ "connectionType_ble": "BLE",
+ "connectionType_serial": "Serial",
+ "connectionType_network": "Network",
+ "deleteConnection": "Delete connection",
+ "areYouSure": "This will remove {{name}}. You canot undo this action.",
+ "moreActions": "More actions",
+ "noConnections": {
+ "title": "No connections yet.",
+ "description": "Create your first connection. It will connect immediately and be saved for later."
+ },
+ "lastConnectedAt": "Last connected: {{date}}",
+ "neverConnected": "Never connected",
+ "toasts": {
+ "connected": "Connected",
+ "nowConnected": "{{name}} is now connected",
+ "nowDisconnected": "{{name}} are now disconnecte",
+ "disconnected": "Disconnected",
+ "failed": "Failed to connect",
+ "checkConnetion": "Check your device or settings and try again",
+ "defaultSet": "Default set",
+ "defaultConnection": "Default connection is now {{nameisconnected}}",
+ "deleted": "Deleted",
+ "deletedByName": "{{name}} was removed",
+ "pickConnectionAgain": "Could not connect. You may need to reselect the device/port.",
+ "added": "Connection added",
+ "savedByName": "{{name}} saved.",
+ "savedCantConnect": "The connection was saved but could not connect."
+ }
+}
diff --git a/packages/web/public/i18n/locales/en/dashboard.json b/packages/web/public/i18n/locales/en/dashboard.json
deleted file mode 100644
index 3a3cd869..00000000
--- a/packages/web/public/i18n/locales/en/dashboard.json
+++ /dev/null
@@ -1,12 +0,0 @@
-{
- "dashboard": {
- "title": "Connected Devices",
- "description": "Manage your connected Meshtastic devices.",
- "connectionType_ble": "BLE",
- "connectionType_serial": "Serial",
- "connectionType_network": "Network",
- "noDevicesTitle": "No devices connected",
- "noDevicesDescription": "Connect a new device to get started.",
- "button_newConnection": "New Connection"
- }
-}
diff --git a/packages/web/public/i18n/locales/en/dialog.json b/packages/web/public/i18n/locales/en/dialog.json
index 4ffc3ac2..be5366dd 100644
--- a/packages/web/public/i18n/locales/en/dialog.json
+++ b/packages/web/public/i18n/locales/en/dialog.json
@@ -3,18 +3,6 @@
"description": "This action will clear all message history. This cannot be undone. Are you sure you want to continue?",
"title": "Clear All Messages"
},
- "deviceName": {
- "description": "The Device will restart once the config is saved.",
- "longName": "Long Name",
- "shortName": "Short Name",
- "title": "Change Device Name",
- "validation": {
- "longNameMax": "Long name must not be more than 40 characters",
- "shortNameMax": "Short name must not be more than 4 characters",
- "longNameMin": "Long name must have at least 1 character",
- "shortNameMin": "Short name must have at least 1 character"
- }
- },
"import": {
"description": "Import a Channel Set from a Meshtastic URL.
Valid Meshtasic URLs start with \"https://meshtastic.org/e/...\"",
"error": {
@@ -41,49 +29,77 @@
"description": "Are you sure you want to regenerate the pre-shared key?",
"regenerate": "Regenerate"
},
- "newDeviceDialog": {
- "title": "Connect New Device",
- "https": "https",
- "http": "http",
- "tabHttp": "HTTP",
- "tabBluetooth": "Bluetooth",
- "tabSerial": "Serial",
-
- "useHttps": "Use HTTPS",
- "connecting": "Connecting...",
- "connect": "Connect",
- "connectionFailedAlert": {
- "title": "Connection Failed",
- "descriptionPrefix": "Could not connect to the device. ",
- "httpsHint": "If using HTTPS, you may need to accept a self-signed certificate first. ",
- "openLinkPrefix": "Please open ",
- "openLinkSuffix": " in a new tab",
- "acceptTlsWarningSuffix": ", accept any TLS warnings if prompted, then try again",
- "learnMoreLink": "Learn more"
- },
- "httpConnection": {
- "label": "IP Address/Hostname",
- "placeholder": "000.000.000.000 / meshtastic.local"
- },
- "serialConnection": {
- "noDevicesPaired": "No devices paired yet.",
- "newDeviceButton": "New device",
- "deviceIdentifier": "# {{index}} - {{vendorId}} - {{productId}}"
- },
- "bluetoothConnection": {
- "noDevicesPaired": "No devices paired yet.",
- "newDeviceButton": "New device",
- "connectionFailed": "Connection failed",
- "deviceDisconnected": "Device disconnected",
- "unknownDevice": "Unknown Device",
- "errorLoadingDevices": "Error loading devices",
- "unknownErrorLoadingDevices": "Unknown error loading devices"
- },
+ "addConnection": {
+ "title": "Add connection",
+ "description": "Choose a connection type and fill in the details",
"validation": {
"requiresWebBluetooth": "This connection type requires <0>Web Bluetooth0>. Please use a supported browser, like Chrome or Edge.",
"requiresWebSerial": "This connection type requires <0>Web Serial0>. Please use a supported browser, like Chrome or Edge.",
"requiresSecureContext": "This application requires a <0>secure context0>. Please connect using HTTPS or localhost.",
"additionallyRequiresSecureContext": "Additionally, it requires a <0>secure context0>. Please connect using HTTPS or localhost."
+ },
+ "bluetoothConnection": {
+ "namePlaceholder": "My Bluetooth Node",
+ "supported": {
+ "title": "Web Bluetooth supported"
+ },
+ "notSupported": {
+ "title": "Web Bluetooth not supported",
+ "description": "Your browser or device does not support Web Bluetooth"
+ },
+ "short": "BT: {{deviceName}}",
+ "long": "Bluetooth Device",
+ "device": "Device",
+ "selectDevice": "Select device",
+ "selected": "Bluetooth device selected",
+ "notSelected": "No device selected",
+ "helperText": "Uses the Meshtastic Bluetooth service for discovery."
+ },
+ "serialConnection": {
+ "namePlaceholder": "My Serial Node",
+ "helperText": "Selecting a port grants permission so the app can open it to connect.",
+ "supported": {
+ "title": "Web Serial supported"
+ },
+ "notSupported": {
+ "title": "Web Serial not supported",
+ "description": "Your browser or device does not support Web Serial"
+ },
+ "portSelected": {
+ "title": "Serial port selected",
+ "description": "Port permissions granted."
+ },
+ "port": "Port",
+ "selectPort": "Select port",
+ "deviceName": "USB {{vendorId}}:{{productId}}",
+ "notSelected": "No port selected"
+ },
+ "httpConnection": {
+ "namePlaceholder": "My HTTP Node",
+ "inputPlaceholder": "192.168.1.10 or meshtastic.local",
+ "heading": "URL or IP",
+ "useHttps": "Use HTTTPS",
+ "invalidUrl": {
+ "title": "Invalid URL",
+ "description": "Please enter a valid HTTP or HTTPS URL."
+ },
+ "connectionTest": {
+ "description": "Test the connetion before saving to verify the device is reachable.",
+ "button": {
+ "loading": "Testing...",
+ "label": "Test connection"
+ },
+ "reachable": "Reachable",
+ "notReachable": "Not reachable",
+ "success": {
+ "title": "Connection test successful",
+ "description": "The device appears to be reachable."
+ },
+ "failure": {
+ "title": "Connection test failed",
+ "description": "Could not reach the device. Check the URL and try again."
+ }
+ }
}
},
"nodeDetails": {
diff --git a/packages/web/public/i18n/locales/en/ui.json b/packages/web/public/i18n/locales/en/ui.json
index 18ea8092..00d3bd5f 100644
--- a/packages/web/public/i18n/locales/en/ui.json
+++ b/packages/web/public/i18n/locales/en/ui.json
@@ -8,6 +8,7 @@
"radioConfig": "Radio Config",
"deviceConfig": "Device Config",
"moduleConfig": "Module Config",
+ "manageConnections": "Manage Connections",
"nodes": "Nodes"
},
"app": {
@@ -202,7 +203,7 @@
"step4": "Any other relevant information"
},
"reportLink": "You can report the issue to our <0>GitHub0>",
- "dashboardLink": "Return to the <0>dashboard0>",
+ "connectionsLink": "Return to the <0>connections0>",
"detailsSummary": "Error Details",
"errorMessageLabel": "Error message:",
"stackTraceLabel": "Stack trace:",
diff --git a/packages/web/public/i18n/locales/es-ES/dashboard.json b/packages/web/public/i18n/locales/es-ES/connections.json
similarity index 100%
rename from packages/web/public/i18n/locales/es-ES/dashboard.json
rename to packages/web/public/i18n/locales/es-ES/connections.json
diff --git a/packages/web/public/i18n/locales/fi-FI/dashboard.json b/packages/web/public/i18n/locales/fi-FI/connections.json
similarity index 100%
rename from packages/web/public/i18n/locales/fi-FI/dashboard.json
rename to packages/web/public/i18n/locales/fi-FI/connections.json
diff --git a/packages/web/public/i18n/locales/fr-FR/dashboard.json b/packages/web/public/i18n/locales/fr-FR/connections.json
similarity index 100%
rename from packages/web/public/i18n/locales/fr-FR/dashboard.json
rename to packages/web/public/i18n/locales/fr-FR/connections.json
diff --git a/packages/web/public/i18n/locales/hu-HU/dashboard.json b/packages/web/public/i18n/locales/hu-HU/connections.json
similarity index 100%
rename from packages/web/public/i18n/locales/hu-HU/dashboard.json
rename to packages/web/public/i18n/locales/hu-HU/connections.json
diff --git a/packages/web/public/i18n/locales/it-IT/dashboard.json b/packages/web/public/i18n/locales/it-IT/connections.json
similarity index 100%
rename from packages/web/public/i18n/locales/it-IT/dashboard.json
rename to packages/web/public/i18n/locales/it-IT/connections.json
diff --git a/packages/web/public/i18n/locales/ja-JP/dashboard.json b/packages/web/public/i18n/locales/ja-JP/connections.json
similarity index 100%
rename from packages/web/public/i18n/locales/ja-JP/dashboard.json
rename to packages/web/public/i18n/locales/ja-JP/connections.json
diff --git a/packages/web/public/i18n/locales/ko-KR/dashboard.json b/packages/web/public/i18n/locales/ko-KR/connections.json
similarity index 100%
rename from packages/web/public/i18n/locales/ko-KR/dashboard.json
rename to packages/web/public/i18n/locales/ko-KR/connections.json
diff --git a/packages/web/public/i18n/locales/nl-NL/dashboard.json b/packages/web/public/i18n/locales/nl-NL/connections.json
similarity index 100%
rename from packages/web/public/i18n/locales/nl-NL/dashboard.json
rename to packages/web/public/i18n/locales/nl-NL/connections.json
diff --git a/packages/web/public/i18n/locales/pl-PL/dashboard.json b/packages/web/public/i18n/locales/pl-PL/connections.json
similarity index 100%
rename from packages/web/public/i18n/locales/pl-PL/dashboard.json
rename to packages/web/public/i18n/locales/pl-PL/connections.json
diff --git a/packages/web/public/i18n/locales/pt-BR/dashboard.json b/packages/web/public/i18n/locales/pt-BR/connections.json
similarity index 100%
rename from packages/web/public/i18n/locales/pt-BR/dashboard.json
rename to packages/web/public/i18n/locales/pt-BR/connections.json
diff --git a/packages/web/public/i18n/locales/pt-PT/dashboard.json b/packages/web/public/i18n/locales/pt-PT/connections.json
similarity index 100%
rename from packages/web/public/i18n/locales/pt-PT/dashboard.json
rename to packages/web/public/i18n/locales/pt-PT/connections.json
diff --git a/packages/web/public/i18n/locales/sv-SE/dashboard.json b/packages/web/public/i18n/locales/sv-SE/connections.json
similarity index 100%
rename from packages/web/public/i18n/locales/sv-SE/dashboard.json
rename to packages/web/public/i18n/locales/sv-SE/connections.json
diff --git a/packages/web/public/i18n/locales/tr-TR/dashboard.json b/packages/web/public/i18n/locales/tr-TR/connections.json
similarity index 100%
rename from packages/web/public/i18n/locales/tr-TR/dashboard.json
rename to packages/web/public/i18n/locales/tr-TR/connections.json
diff --git a/packages/web/public/i18n/locales/uk-UA/dashboard.json b/packages/web/public/i18n/locales/uk-UA/connections.json
similarity index 100%
rename from packages/web/public/i18n/locales/uk-UA/dashboard.json
rename to packages/web/public/i18n/locales/uk-UA/connections.json
diff --git a/packages/web/public/i18n/locales/zh-CN/dashboard.json b/packages/web/public/i18n/locales/zh-CN/connections.json
similarity index 100%
rename from packages/web/public/i18n/locales/zh-CN/dashboard.json
rename to packages/web/public/i18n/locales/zh-CN/connections.json
diff --git a/packages/web/public/i18n/locales/zh-CN/ui.json b/packages/web/public/i18n/locales/zh-CN/ui.json
index 21652d5b..52ce6497 100644
--- a/packages/web/public/i18n/locales/zh-CN/ui.json
+++ b/packages/web/public/i18n/locales/zh-CN/ui.json
@@ -1,229 +1,228 @@
{
- "navigation": {
- "title": "Navigation",
- "messages": "消息",
- "map": "地图",
- "settings": "设置",
- "channels": "频道",
- "radioConfig": "Radio Config",
- "deviceConfig": "设备配置",
- "moduleConfig": "Module Config",
- "nodes": "节点"
- },
- "app": {
- "title": "Meshtastic",
- "logo": "Meshtastic Logo"
- },
- "sidebar": {
- "collapseToggle": {
- "button": {
- "open": "Open sidebar",
- "close": "Close sidebar"
- }
- },
- "deviceInfo": {
- "volts": "{{voltage}} volts",
- "firmware": {
- "title": "固件",
- "version": "v{{version}}",
- "buildDate": "Build date: {{date}}"
- }
- }
- },
- "batteryStatus": {
- "charging": "{{level}}% charging",
- "pluggedIn": "Plugged in",
- "title": "电池"
- },
- "search": {
- "nodes": "Search nodes...",
- "channels": "Search channels...",
- "commandPalette": "Search commands..."
- },
- "toast": {
- "positionRequestSent": {
- "title": "Position request sent."
- },
- "requestingPosition": {
- "title": "Requesting position, please wait..."
- },
- "sendingTraceroute": {
- "title": "Sending Traceroute, please wait..."
- },
- "tracerouteSent": {
- "title": "Traceroute sent."
- },
- "savedChannel": {
- "title": "Saved Channel: {{channelName}}"
- },
- "messages": {
- "pkiEncryption": {
- "title": "Chat is using PKI encryption."
- },
- "pskEncryption": {
- "title": "Chat is using PSK encryption."
- }
- },
- "configSaveError": {
- "title": "Error Saving Config",
- "description": "An error occurred while saving the configuration."
- },
- "validationError": {
- "title": "Config Errors Exist",
- "description": "Please fix the configuration errors before saving."
- },
- "saveSuccess": {
- "title": "Saving Config",
- "description": "The configuration change {{case}} has been saved."
- },
- "saveAllSuccess": {
- "title": "Saved",
- "description": "All configuration changes have been saved."
- },
- "favoriteNode": {
- "title": "{{action}} {{nodeName}} {{direction}} favorites.",
- "action": {
- "added": "Added",
- "removed": "Removed",
- "to": "to",
- "from": "from"
- }
- },
- "ignoreNode": {
- "title": "{{action}} {{nodeName}} {{direction}} ignore list",
- "action": {
- "added": "Added",
- "removed": "Removed",
- "to": "to",
- "from": "from"
- }
- }
- },
- "notifications": {
- "copied": {
- "label": "Copied!"
- },
- "copyToClipboard": {
- "label": "Copy to clipboard"
- },
- "hidePassword": {
- "label": "隐藏密码"
- },
- "showPassword": {
- "label": "显示密码"
- },
- "deliveryStatus": {
- "delivered": "Delivered",
- "failed": "Delivery Failed",
- "waiting": "等待中...",
- "unknown": "未知"
- }
- },
- "general": {
- "label": "General"
- },
- "hardware": {
- "label": "硬件"
- },
- "metrics": {
- "label": "Metrics"
- },
- "role": {
- "label": "角色"
- },
- "filter": {
- "label": "筛选器csvfganw"
- },
- "advanced": {
- "label": "高级"
- },
- "clearInput": {
- "label": "Clear input"
- },
- "resetFilters": {
- "label": "Reset Filters"
- },
- "nodeName": {
- "label": "Node name/number",
- "placeholder": "Meshtastic 1234"
- },
- "airtimeUtilization": {
- "label": "Airtime Utilization (%)",
- "short": "Airtime Util. (%)"
- },
- "batteryLevel": {
- "label": "Battery level (%)",
- "labelText": "Battery level (%): {{value}}"
- },
- "batteryVoltage": {
- "label": "Battery voltage (V)",
- "title": "电压"
- },
- "channelUtilization": {
- "label": "Channel Utilization (%)",
- "short": "Channel Util. (%)"
- },
- "hops": {
- "direct": "直频",
- "label": "Number of hops",
- "text": "Number of hops: {{value}}"
- },
- "lastHeard": {
- "label": "最后听到",
- "labelText": "Last heard: {{value}}",
- "nowLabel": "Now"
- },
- "snr": {
- "label": "SNR (db)"
- },
- "favorites": {
- "label": "Favorites"
- },
- "hide": {
- "label": "Hide"
- },
- "showOnly": {
- "label": "Show Only"
- },
- "viaMqtt": {
- "label": "Connected via MQTT"
- },
- "hopsUnknown": {
- "label": "Unknown number of hops"
- },
- "showUnheard": {
- "label": "Never heard"
- },
- "language": {
- "label": "语言",
- "changeLanguage": "Change Language"
- },
- "theme": {
- "dark": "深色",
- "light": "浅色",
- "system": "Automatic",
- "changeTheme": "Change Color Scheme"
- },
- "errorPage": {
- "title": "This is a little embarrassing...",
- "description1": "We are really sorry but an error occurred in the web client that caused it to crash.
This is not supposed to happen, and we are working hard to fix it.",
- "description2": "The best way to prevent this from happening again to you or anyone else is to report the issue to us.",
- "reportInstructions": "Please include the following information in your report:",
- "reportSteps": {
- "step1": "What you were doing when the error occurred",
- "step2": "What you expected to happen",
- "step3": "What actually happened",
- "step4": "Any other relevant information"
- },
- "reportLink": "You can report the issue to our <0>GitHub0>",
- "dashboardLink": "Return to the <0>dashboard0>",
- "detailsSummary": "Error Details",
- "errorMessageLabel": "Error message:",
- "stackTraceLabel": "Stack trace:",
- "fallbackError": "{{error}}"
- },
- "footer": {
- "text": "Powered by <0>▲ Vercel0> | Meshtastic® is a registered trademark of Meshtastic LLC. | <1>Legal Information1>",
- "commitSha": "Commit SHA: {{sha}}"
- }
+ "navigation": {
+ "title": "Navigation",
+ "messages": "消息",
+ "map": "地图",
+ "config": "配置",
+ "radioConfig": "Radio Config",
+ "moduleConfig": "Module Config",
+ "channels": "频道",
+ "nodes": "节点"
+ },
+ "app": {
+ "title": "Meshtastic",
+ "logo": "Meshtastic Logo"
+ },
+ "sidebar": {
+ "collapseToggle": {
+ "button": {
+ "open": "Open sidebar",
+ "close": "Close sidebar"
+ }
+ },
+ "deviceInfo": {
+ "volts": "{{voltage}} volts",
+ "firmware": {
+ "title": "固件",
+ "version": "v{{version}}",
+ "buildDate": "Build date: {{date}}"
+ },
+ "deviceName": {
+ "title": "Device Name",
+ "changeName": "Change Device Name",
+ "placeholder": "Enter device name"
+ },
+ "editDeviceName": "Edit device name"
+ }
+ },
+ "batteryStatus": {
+ "charging": "{{level}}% charging",
+ "pluggedIn": "Plugged in",
+ "title": "电池"
+ },
+ "search": {
+ "nodes": "Search nodes...",
+ "channels": "Search channels...",
+ "commandPalette": "Search commands..."
+ },
+ "toast": {
+ "positionRequestSent": {
+ "title": "Position request sent."
+ },
+ "requestingPosition": {
+ "title": "Requesting position, please wait..."
+ },
+ "sendingTraceroute": {
+ "title": "Sending Traceroute, please wait..."
+ },
+ "tracerouteSent": {
+ "title": "Traceroute sent."
+ },
+ "savedChannel": {
+ "title": "Saved Channel: {{channelName}}"
+ },
+ "messages": {
+ "pkiEncryption": {
+ "title": "Chat is using PKI encryption."
+ },
+ "pskEncryption": {
+ "title": "Chat is using PSK encryption."
+ }
+ },
+ "configSaveError": {
+ "title": "Error Saving Config",
+ "description": "An error occurred while saving the configuration."
+ },
+ "validationError": {
+ "title": "Config Errors Exist",
+ "description": "Please fix the configuration errors before saving."
+ },
+ "saveSuccess": {
+ "title": "Saving Config",
+ "description": "The configuration change {{case}} has been saved."
+ },
+ "favoriteNode": {
+ "title": "{{action}} {{nodeName}} {{direction}} favorites.",
+ "action": {
+ "added": "Added",
+ "removed": "Removed",
+ "to": "to",
+ "from": "from"
+ }
+ },
+ "ignoreNode": {
+ "title": "{{action}} {{nodeName}} {{direction}} ignore list",
+ "action": {
+ "added": "Added",
+ "removed": "Removed",
+ "to": "to",
+ "from": "from"
+ }
+ }
+ },
+ "notifications": {
+ "copied": {
+ "label": "Copied!"
+ },
+ "copyToClipboard": {
+ "label": "Copy to clipboard"
+ },
+ "hidePassword": {
+ "label": "隐藏密码"
+ },
+ "showPassword": {
+ "label": "显示密码"
+ },
+ "deliveryStatus": {
+ "delivered": "Delivered",
+ "failed": "Delivery Failed",
+ "waiting": "等待中...",
+ "unknown": "未知"
+ }
+ },
+ "general": {
+ "label": "General"
+ },
+ "hardware": {
+ "label": "硬件"
+ },
+ "metrics": {
+ "label": "Metrics"
+ },
+ "role": {
+ "label": "角色"
+ },
+ "filter": {
+ "label": "筛选器csvfganw"
+ },
+ "advanced": {
+ "label": "高级"
+ },
+ "clearInput": {
+ "label": "Clear input"
+ },
+ "resetFilters": {
+ "label": "Reset Filters"
+ },
+ "nodeName": {
+ "label": "Node name/number",
+ "placeholder": "Meshtastic 1234"
+ },
+ "airtimeUtilization": {
+ "label": "Airtime Utilization (%)"
+ },
+ "batteryLevel": {
+ "label": "Battery level (%)",
+ "labelText": "Battery level (%): {{value}}"
+ },
+ "batteryVoltage": {
+ "label": "Battery voltage (V)",
+ "title": "电压"
+ },
+ "channelUtilization": {
+ "label": "Channel Utilization (%)"
+ },
+ "hops": {
+ "direct": "直频",
+ "label": "Number of hops",
+ "text": "Number of hops: {{value}}"
+ },
+ "lastHeard": {
+ "label": "最后听到",
+ "labelText": "Last heard: {{value}}",
+ "nowLabel": "Now"
+ },
+ "snr": {
+ "label": "SNR (db)"
+ },
+ "favorites": {
+ "label": "Favorites"
+ },
+ "hide": {
+ "label": "Hide"
+ },
+ "showOnly": {
+ "label": "Show Only"
+ },
+ "viaMqtt": {
+ "label": "Connected via MQTT"
+ },
+ "hopsUnknown": {
+ "label": "Unknown number of hops"
+ },
+ "showUnheard": {
+ "label": "Never heard"
+ },
+ "language": {
+ "label": "语言",
+ "changeLanguage": "Change Language"
+ },
+ "theme": {
+ "dark": "深色",
+ "light": "浅色",
+ "system": "Automatic",
+ "changeTheme": "Change Color Scheme"
+ },
+ "errorPage": {
+ "title": "This is a little embarrassing...",
+ "description1": "We are really sorry but an error occurred in the web client that caused it to crash.
This is not supposed to happen, and we are working hard to fix it.",
+ "description2": "The best way to prevent this from happening again to you or anyone else is to report the issue to us.",
+ "reportInstructions": "Please include the following information in your report:",
+ "reportSteps": {
+ "step1": "What you were doing when the error occurred",
+ "step2": "What you expected to happen",
+ "step3": "What actually happened",
+ "step4": "Any other relevant information"
+ },
+ "reportLink": "You can report the issue to our <0>GitHub0>",
+ "connectionsLink": "Return to the <0>connections0>",
+ "detailsSummary": "Error Details",
+ "errorMessageLabel": "Error message:",
+ "stackTraceLabel": "Stack trace:",
+ "fallbackError": "{{error}}"
+ },
+ "footer": {
+ "text": "Powered by <0>▲ Vercel0> | Meshtastic® is a registered trademark of Meshtastic LLC. | <1>Legal Information1>",
+ "commitSha": "Commit SHA: {{sha}}"
+ }
}
diff --git a/packages/web/src/App.tsx b/packages/web/src/App.tsx
index 6403f2b2..5fc115c6 100644
--- a/packages/web/src/App.tsx
+++ b/packages/web/src/App.tsx
@@ -1,15 +1,13 @@
import { DeviceWrapper } from "@app/DeviceWrapper.tsx";
import { CommandPalette } from "@components/CommandPalette/index.tsx";
import { DialogManager } from "@components/Dialog/DialogManager.tsx";
-import { NewDeviceDialog } from "@components/Dialog/NewDeviceDialog.tsx";
import { KeyBackupReminder } from "@components/KeyBackupReminder.tsx";
import { Toaster } from "@components/Toaster.tsx";
import { ErrorPage } from "@components/UI/ErrorPage.tsx";
import Footer from "@components/UI/Footer.tsx";
import { useTheme } from "@core/hooks/useTheme.ts";
import { SidebarProvider, useAppStore, useDeviceStore } from "@core/stores";
-// import { SidebarProvider, ThemeProvider } from "@meshtastic/ui";
-import { Dashboard } from "@pages/Dashboard/index.tsx";
+import { Connections } from "@pages/Connections/index.tsx";
import { Outlet } from "@tanstack/react-router";
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
import { ErrorBoundary } from "react-error-boundary";
@@ -19,20 +17,19 @@ export function App() {
useTheme();
const { getDevice } = useDeviceStore();
- const { selectedDeviceId, setConnectDialogOpen, connectDialogOpen } =
- useAppStore();
+ const { selectedDeviceId } = useAppStore();
const device = getDevice(selectedDeviceId);
return (
//
- {
setConnectDialogOpen(open);
}}
- />
+ /> */}
@@ -53,7 +50,7 @@ export function App() {
) : (
<>
-
+
>
)}
diff --git a/packages/web/src/components/Badge/ConnectionStatusBadge.tsx b/packages/web/src/components/Badge/ConnectionStatusBadge.tsx
new file mode 100644
index 00000000..e69de29b
diff --git a/packages/web/src/components/Badge/SupportedBadge.tsx b/packages/web/src/components/Badge/SupportedBadge.tsx
new file mode 100644
index 00000000..ae935c9c
--- /dev/null
+++ b/packages/web/src/components/Badge/SupportedBadge.tsx
@@ -0,0 +1,19 @@
+import { Badge } from "../UI/Badge.tsx";
+
+export function SupportBadge({
+ supported,
+ labelSupported,
+ labelUnsupported,
+}: {
+ supported: boolean;
+ labelSupported: string;
+ labelUnsupported: string;
+}) {
+ return (
+
+
+ {supported ? labelSupported : labelUnsupported}
+
+
+ );
+}
diff --git a/packages/web/src/components/DeviceInfoPanel.tsx b/packages/web/src/components/DeviceInfoPanel.tsx
index 7b781d4c..c9a94020 100644
--- a/packages/web/src/components/DeviceInfoPanel.tsx
+++ b/packages/web/src/components/DeviceInfoPanel.tsx
@@ -1,5 +1,8 @@
+import type { ConnectionStatus } from "@app/core/stores/deviceStore/types.ts";
import { cn } from "@core/utils/cn.ts";
+import { useNavigate } from "@tanstack/react-router";
import {
+ ChevronRight,
CpuIcon,
Languages,
type LucideIcon,
@@ -29,6 +32,8 @@ interface DeviceInfoPanelProps {
setDialogOpen: () => void;
setCommandPaletteOpen: () => void;
disableHover?: boolean;
+ connectionStatus?: ConnectionStatus;
+ connectionName?: string;
}
interface InfoDisplayItem {
@@ -54,10 +59,36 @@ export const DeviceInfoPanel = ({
isCollapsed,
setCommandPaletteOpen,
disableHover = false,
+ connectionStatus,
+ connectionName,
}: DeviceInfoPanelProps) => {
const { t } = useTranslation();
+ const navigate = useNavigate({ from: "/" });
const { batteryLevel, voltage } = deviceMetrics;
+ const getStatusColor = (status?: ConnectionStatus): string => {
+ if (!status) {
+ return "bg-gray-400";
+ }
+ switch (status) {
+ case "connected":
+ return "bg-emerald-500";
+ case "connecting":
+ return "bg-amber-500";
+ case "error":
+ return "bg-red-500";
+ default:
+ return "bg-gray-400";
+ }
+ };
+
+ const getStatusLabel = (status?: ConnectionStatus): string => {
+ if (!status) {
+ return t("unknown.notAvailable", "N/A");
+ }
+ return status;
+ };
+
const deviceInfoItems: InfoDisplayItem[] = [
{
id: "battery",
@@ -129,6 +160,45 @@ export const DeviceInfoPanel = ({
)}
+ {connectionStatus && (
+
+ )}
+
{!isCollapsed && (
)}
diff --git a/packages/web/src/components/Dialog/AddConnectionDialog/AddConnectionDialog.tsx b/packages/web/src/components/Dialog/AddConnectionDialog/AddConnectionDialog.tsx
new file mode 100644
index 00000000..9ccc2860
--- /dev/null
+++ b/packages/web/src/components/Dialog/AddConnectionDialog/AddConnectionDialog.tsx
@@ -0,0 +1,671 @@
+import { SupportBadge } from "@app/components/Badge/SupportedBadge.tsx";
+import { Switch } from "@app/components/UI/Switch.tsx";
+import type { NewConnection } from "@app/core/stores/deviceStore/types.ts";
+import { testHttpReachable } from "@app/pages/Connections/utils";
+import { Button } from "@components/UI/Button.tsx";
+import { Input } from "@components/UI/Input.tsx";
+import { Label } from "@components/UI/Label.tsx";
+import {
+ Tabs,
+ TabsContent,
+ TabsList,
+ TabsTrigger,
+} from "@components/UI/Tabs.tsx";
+import { Link } from "@components/UI/Typography/Link.tsx";
+import {
+ type BrowserFeature,
+ useBrowserFeatureDetection,
+} from "@core/hooks/useBrowserFeatureDetection.ts";
+import { useToast } from "@core/hooks/useToast.ts";
+import { TransportWebBluetooth } from "@meshtastic/transport-web-bluetooth";
+import {
+ AlertCircle,
+ Bluetooth,
+ Cable,
+ CheckCircle2,
+ Globe,
+ Loader2,
+ type LucideIcon,
+ MousePointerClick,
+ XCircle,
+} from "lucide-react";
+import { useCallback, useEffect, useMemo, useReducer } from "react";
+import { Trans, useTranslation } from "react-i18next";
+import { DialogWrapper } from "../DialogWrapper.tsx";
+import { urlOrIpv4Schema } from "./validation.ts";
+
+type TabKey = "http" | "bluetooth" | "serial";
+type TestingStatus = "idle" | "testing" | "success" | "failure";
+type DialogState = {
+ tab: TabKey;
+ name: string;
+ protocol: "http" | "https";
+ url: string;
+ testStatus: TestingStatus;
+ btSelected: { id: string; name?: string; device?: BluetoothDevice } | undefined;
+ serialSelected: { vendorId?: number; productId?: number } | undefined;
+};
+
+type DialogAction =
+ | { type: "RESET"; payload?: { isHTTPS?: boolean } }
+ | { type: "SET_TAB"; payload: TabKey }
+ | { type: "SET_NAME"; payload: string }
+ | { type: "SET_PROTOCOL"; payload: "http" | "https" }
+ | { type: "SET_URL"; payload: string }
+ | { type: "SET_TEST_STATUS"; payload: TestingStatus }
+ | {
+ type: "SET_BT_SELECTED";
+ payload: { id: string; name?: string; device?: BluetoothDevice } | undefined;
+ }
+ | {
+ type: "SET_SERIAL_SELECTED";
+ payload: { vendorId?: number; productId?: number } | undefined;
+ }
+ | { type: "SET_URL_AND_RESET_TEST"; payload: string };
+
+interface FeatureErrorProps {
+ missingFeatures: BrowserFeature[];
+ tabId: "bluetooth" | "serial";
+}
+
+type Pane = {
+ children: () => React.ReactNode;
+ placeholder: string;
+ validate: () => boolean;
+ build: () => NewConnection | null;
+};
+
+const featureErrors: Record =
+ {
+ "Web Bluetooth": {
+ href: "https://developer.mozilla.org/en-US/docs/Web/API/Web_Bluetooth_API#browser_compatibility",
+ i18nKey: "addConnection.validation.requiresWebBluetooth",
+ },
+ "Web Serial": {
+ href: "https://developer.mozilla.org/en-US/docs/Web/API/Web_Serial_API#browser_compatibility",
+ i18nKey: "addConnection.validation.requiresWebSerial",
+ },
+ "Secure Context": {
+ href: "https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts",
+ i18nKey: "addConnection.validation.requiresSecureContext",
+ },
+ };
+
+const FeatureErrorMessage = ({ missingFeatures, tabId }: FeatureErrorProps) => {
+ if (missingFeatures.length === 0) {
+ return null;
+ }
+
+ const browserFeatures = missingFeatures.filter(
+ (feature) => feature !== "Secure Context",
+ );
+ const needsSecureContext = missingFeatures.includes("Secure Context");
+
+ const needsFeature =
+ tabId === "bluetooth" && browserFeatures.includes("Web Bluetooth")
+ ? "Web Bluetooth"
+ : tabId === "serial" && browserFeatures.includes("Web Serial")
+ ? "Web Serial"
+ : undefined;
+
+ return (
+
+
+
+
+
+ {needsFeature && (
+ ,
+ ]}
+ />
+ )}
+ {needsFeature && needsSecureContext && " "}
+ {needsSecureContext && (
+ 0
+ ? "addConnection.validation.additionallyRequiresSecureContext"
+ : "addConnection.validation.requiresSecureContext"
+ }
+ components={{
+ "0": (
+
+ ),
+ }}
+ />
+ )}
+
+
+
+
+ );
+};
+
+const initialState: DialogState = {
+ tab: "http",
+ name: "",
+ protocol: "http",
+ url: "",
+ testStatus: "idle",
+ btSelected: undefined,
+ serialSelected: undefined,
+};
+export const createInitialDialogState = (
+ overrides?: Partial,
+): DialogState => {
+ return { ...initialState, ...(overrides ?? {}) };
+};
+
+export const dialogStateInitializer = (
+ overrides?: Partial,
+): DialogState => createInitialDialogState(overrides);
+
+function dialogReducer(state: DialogState, action: DialogAction): DialogState {
+ switch (action.type) {
+ case "RESET":
+ return createInitialDialogState(
+ action.payload?.isHTTPS ? { protocol: "https" } : {},
+ );
+ case "SET_TAB":
+ return { ...state, tab: action.payload };
+ case "SET_NAME":
+ return { ...state, name: action.payload };
+ case "SET_PROTOCOL":
+ return { ...state, protocol: action.payload };
+ case "SET_URL":
+ return { ...state, url: action.payload };
+ case "SET_TEST_STATUS":
+ return { ...state, testStatus: action.payload };
+ case "SET_BT_SELECTED":
+ return { ...state, btSelected: action.payload };
+ case "SET_SERIAL_SELECTED":
+ return { ...state, serialSelected: action.payload };
+ case "SET_URL_AND_RESET_TEST":
+ return { ...state, url: action.payload, testStatus: "idle" };
+ default:
+ return state;
+ }
+}
+
+function PickerRow({
+ label,
+ buttonText,
+ onPick,
+ disabled,
+ display,
+ helper,
+}: {
+ label: string;
+ buttonText: string;
+ onPick: () => void | Promise;
+ disabled?: boolean;
+ display: string;
+ helper?: string;
+}) {
+ return (
+
+
+
+
+
+ {display}
+
+
+ {helper ? (
+
{helper}
+ ) : null}
+
+ );
+}
+
+const TAB_META: Array<{ key: TabKey; label: string; Icon: LucideIcon }> = [
+ { key: "http", label: "HTTP", Icon: Globe },
+ { key: "bluetooth", label: "Bluetooth", Icon: Bluetooth },
+ { key: "serial", label: "Serial", Icon: Cable },
+];
+
+export default function AddConnectionDialog({
+ open = false,
+ onOpenChange = () => {},
+ onSave = async () => {},
+ isHTTPS = false,
+}: {
+ open?: boolean;
+ onOpenChange?: (open: boolean) => void;
+ onSave?: (conn: NewConnection, device?: BluetoothDevice) => Promise;
+ isHTTPS?: boolean;
+}) {
+ const { toast } = useToast();
+ const [state, dispatch] = useReducer(dialogReducer, initialState, () =>
+ dialogStateInitializer(isHTTPS ? { protocol: "https" } : {}),
+ );
+ const { unsupported } = useBrowserFeatureDetection();
+ const { t } = useTranslation();
+
+ const bluetoothSupported =
+ typeof navigator !== "undefined" && "bluetooth" in navigator;
+ const serialSupported =
+ typeof navigator !== "undefined" && "serial" in navigator;
+ const isURLHTTPS = isHTTPS;
+
+ const reset = useCallback(() => {
+ dispatch({ type: "RESET", payload: { isHTTPS } });
+ }, [isHTTPS]);
+
+ useEffect(() => {
+ if (!open) {
+ reset();
+ }
+ }, [reset, open]);
+
+ const makeToastErrorHandler = useCallback(
+ (titlePrefix: string) =>
+ (err: unknown): void => {
+ if (err && (err as { name?: string }).name === "NotFoundError") {
+ return; // user canceled
+ }
+ const message = err instanceof Error ? err.message : String(err);
+ toast({ title: `${titlePrefix} error`, description: message });
+ },
+ [toast],
+ );
+
+ const handlePickBluetooth = useCallback(async () => {
+ if (!bluetoothSupported) {
+ toast({
+ title: t("addConnection.bluetoothConnection.notSupported.title"),
+ description: t(
+ "addConnection.bluetoothConnection.notSupported.description",
+ ),
+ });
+ return;
+ }
+ try {
+ const device = await navigator.bluetooth.requestDevice({
+ filters: [{ services: [TransportWebBluetooth.ServiceUuid] }],
+ });
+ dispatch({
+ type: "SET_BT_SELECTED",
+ payload: { id: device.id, name: device.name ?? undefined, device },
+ });
+ if (!state.name || state.name === "") {
+ dispatch({
+ type: "SET_NAME",
+ payload: device.name
+ ? t("addConnection.bluetoothConnection.short", {
+ deviceName: device.name,
+ })
+ : t("addConnection.bluetoothConnection.long"),
+ });
+ }
+ toast({
+ title: t("addConnection.bluetoothConnection.selected"),
+ description: device.name || device.id,
+ });
+ } catch (err) {
+ makeToastErrorHandler("Bluetooth")(err);
+ }
+ }, [bluetoothSupported, state.name, toast, makeToastErrorHandler, t]);
+
+ const handlePickSerial = useCallback(async () => {
+ if (!serialSupported) {
+ toast({
+ title: t("addConnection.serialConnection.notSupported.title"),
+ description: t(
+ "addConnection.serialConnection.notSupported.description",
+ ),
+ });
+ return;
+ }
+ try {
+ const port = await (
+ navigator as Navigator & {
+ serial: {
+ requestPort: (o: Record) => Promise;
+ };
+ }
+ ).serial.requestPort({});
+ const info =
+ (
+ port as SerialPort & {
+ getInfo?: () => { usbVendorId?: number; usbProductId?: number };
+ }
+ ).getInfo?.() ?? {};
+ dispatch({
+ type: "SET_SERIAL_SELECTED",
+ payload: {
+ vendorId: info.usbVendorId,
+ productId: info.usbProductId,
+ },
+ });
+ if (!state.name || state.name === "") {
+ const v = info.usbVendorId ? info.usbVendorId.toString(16) : "?";
+ const p = info.usbProductId ? info.usbProductId.toString(16) : "?";
+ dispatch({ type: "SET_NAME", payload: `Serial: ${v}:${p}` });
+ }
+ toast({
+ title: t("addConnection.serialConnection.portSelected.title"),
+ description: t(
+ "addConnection.serialConnection.portSelected.description",
+ ),
+ });
+ } catch (err) {
+ makeToastErrorHandler("Serial")(err);
+ }
+ }, [serialSupported, state.name, toast, makeToastErrorHandler, t]);
+
+ const handleTestHttp = useCallback(async () => {
+ const fullUrl = `${state.protocol}://${state.url}`;
+ const validatedURL = urlOrIpv4Schema.safeParse(fullUrl);
+ if (validatedURL.success === false) {
+ toast({
+ title: t("addConnection.httpConnection.invalidUrl.title"),
+ description: t("addConnection.httpConnection.invalidUrl.description"),
+ });
+ return;
+ }
+ dispatch({ type: "SET_TEST_STATUS", payload: "testing" });
+ const reachable = await testHttpReachable(validatedURL.data);
+ if (reachable) {
+ dispatch({ type: "SET_TEST_STATUS", payload: "success" });
+ toast({
+ title: t("addConnection.httpConnection.connectionTest.success.title"),
+ description: t(
+ "addConnection.httpConnection.connectionTest.success.description",
+ ),
+ });
+ } else {
+ dispatch({ type: "SET_TEST_STATUS", payload: "failure" });
+ toast({
+ title: t("addConnection.httpConnection.connectionTest.failure.title"),
+ description: t(
+ "addConnection.httpConnection.connectionTest.failure.description",
+ ),
+ });
+ }
+ }, [state.protocol, state.url, toast, t]);
+
+ const PANES: Record = useMemo(
+ () => ({
+ http: {
+ placeholder: t("addConnection.httpConnection.namePlaceholder"),
+ children: () => (
+
+
+
+
{
+ dispatch({
+ type: "SET_URL_AND_RESET_TEST",
+ payload: e.target.value,
+ });
+ }}
+ />
+
+ {
+ dispatch({
+ type: "SET_PROTOCOL",
+ payload: value ? "https" : "http",
+ });
+ dispatch({ type: "SET_TEST_STATUS", payload: "idle" });
+ }}
+ >
+
+
+
+
+ {state.testStatus === "success" && (
+
+
+ {t("addConnection.httpConnection.connectionTest.reachable")}
+
+ )}
+ {state.testStatus === "failure" && (
+
+
+ {t(
+ "addConnection.httpConnection.connectionTest.notReachable",
+ )}
+
+ )}
+
+
+ {t("addConnection.httpConnection.connectionTest.description")}
+
+
+ ),
+ validate: () =>
+ urlOrIpv4Schema.safeParse(`${state.protocol}://${state.url}`)
+ .success === true && state.testStatus === "success",
+ build: () => ({
+ type: "http",
+ name: state.name.trim(),
+ url: `${state.protocol}://${state.url.trim()}`,
+ }),
+ },
+ bluetooth: {
+ placeholder: "My Bluetooth Node",
+ children: () => (
+ <>
+
+
+
+ >
+ ),
+ validate: () => state.name.trim().length > 0 && !!state.btSelected,
+ build: () => ({
+ type: "bluetooth",
+ name: state.name.trim(),
+ deviceId: state.btSelected?.id,
+ deviceName: state.btSelected?.name,
+ gattServiceUUID: TransportWebBluetooth.ServiceUuid,
+ }),
+ },
+ serial: {
+ placeholder: t("addConnection.serialConnection.namePlaceholder"),
+ children: () => (
+ <>
+
+
+
+ >
+ ),
+ validate: () =>
+ state.name.trim().length > 0 &&
+ (!!state.serialSelected || !serialSupported),
+ build: () => ({
+ type: "serial",
+ name: state.name.trim(),
+ usbVendorId: state.serialSelected?.vendorId,
+ usbProductId: state.serialSelected?.productId,
+ }),
+ },
+ }),
+ [
+ state,
+ bluetoothSupported,
+ serialSupported,
+ isURLHTTPS,
+ handlePickBluetooth,
+ handlePickSerial,
+ handleTestHttp,
+ unsupported,
+ t,
+ ],
+ );
+
+ const currentPane = PANES[state.tab];
+ const canCreate = useMemo(() => currentPane.validate(), [currentPane]);
+
+ const submit = (fn: (p: NewConnection, device?: BluetoothDevice) => Promise) => async () => {
+ if (!canCreate) {
+ return;
+ }
+ const payload = currentPane.build();
+
+ if (!payload) {
+ return;
+ }
+ const btDevice = state.tab === "bluetooth" ? state.btSelected?.device : undefined;
+ await fn(payload, btDevice);
+ };
+
+ return (
+
+
+ dispatch({ type: "SET_TAB", payload: v as TabKey })
+ }
+ >
+
+ {TAB_META.map(({ key, label, Icon }) => (
+
+
+ {label}
+
+ ))}
+
+
+ {TAB_META.map(({ key }) => (
+
+ {state.tab === key ? (
+
+
+
+
+ dispatch({ type: "SET_NAME", payload: evt.target.value })
+ }
+ placeholder={currentPane.placeholder}
+ />
+
+ {PANES[key].children()}
+
+
+
+
+
+
+ ) : null}
+
+ ))}
+
+
+ );
+}
diff --git a/packages/web/src/components/Dialog/AddConnectionDialog/validation.ts b/packages/web/src/components/Dialog/AddConnectionDialog/validation.ts
new file mode 100644
index 00000000..0fbb238e
--- /dev/null
+++ b/packages/web/src/components/Dialog/AddConnectionDialog/validation.ts
@@ -0,0 +1,27 @@
+import z from "zod";
+
+export const urlOrIpv4Schema = z
+ .string()
+ .trim()
+ .refine((val) => {
+ const input = val.replace(/^https?:\/\//i, ""); // remove protocol for validation
+
+ // IPv4 pattern
+ const ipv4Regex =
+ /^(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}$/;
+
+ // Domain pattern (e.g. example.com, meshtastic.local)
+ const domainRegex = /^(?!-)(?:[a-zA-Z0-9-]{1,63}\.)+[a-zA-Z]{2,}$/;
+
+ // Local domain (e.g. meshtastic.local)
+ const localDomainRegex = /^(?!-)[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)*\.local$/;
+
+ return (
+ ipv4Regex.test(input) ||
+ domainRegex.test(input) ||
+ localDomainRegex.test(input)
+ );
+ }, "Must be a valid IPv4 address or domain name")
+ .transform((val) => {
+ return /^https?:\/\//i.test(val) ? val : `http://${val}`;
+ });
diff --git a/packages/web/src/components/Dialog/DeviceNameDialog.tsx b/packages/web/src/components/Dialog/DeviceNameDialog.tsx
deleted file mode 100644
index 055d11f8..00000000
--- a/packages/web/src/components/Dialog/DeviceNameDialog.tsx
+++ /dev/null
@@ -1,140 +0,0 @@
-import { create } from "@bufbuild/protobuf";
-import { GenericInput } from "@components/Form/FormInput.tsx";
-import { Button } from "@components/UI/Button.tsx";
-import {
- Dialog,
- DialogClose,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
-} from "@components/UI/Dialog.tsx";
-import { useDevice, useNodeDB } from "@core/stores";
-import { zodResolver } from "@hookform/resolvers/zod";
-import { Protobuf } from "@meshtastic/core";
-import { useForm } from "react-hook-form";
-import { useTranslation } from "react-i18next";
-import z from "zod";
-import { Label } from "../UI/Label.tsx";
-
-export interface User {
- longName: string;
- shortName: string;
-}
-
-export interface DeviceNameDialogProps {
- open: boolean;
- onOpenChange: (open: boolean) => void;
-}
-
-export const DeviceNameDialog = ({
- open,
- onOpenChange,
-}: DeviceNameDialogProps) => {
- const { t } = useTranslation("dialog");
- const { hardware, connection } = useDevice();
- const { getNode } = useNodeDB();
- const myNode = getNode(hardware.myNodeNum);
-
- const defaultValues = {
- shortName: myNode?.user?.shortName ?? "",
- longName: myNode?.user?.longName ?? "",
- };
-
- const deviceNameSchema = z.object({
- longName: z
- .string()
- .min(1, t("deviceName.validation.longNameMin"))
- .max(40, t("deviceName.validation.longNameMax")),
- shortName: z
- .string()
- .min(2, t("deviceName.validation.shortNameMin"))
- .max(4, t("deviceName.validation.shortNameMax")),
- });
-
- const { getValues, reset, control, handleSubmit } = useForm({
- values: defaultValues,
- resolver: zodResolver(deviceNameSchema),
- });
-
- const onSubmit = handleSubmit((data) => {
- connection?.setOwner(
- create(Protobuf.Mesh.UserSchema, {
- ...data,
- }),
- );
- onOpenChange(false);
- });
-
- const handleReset = () => {
- reset(defaultValues);
- };
-
- return (
-
- );
-};
diff --git a/packages/web/src/components/Dialog/DialogManager.tsx b/packages/web/src/components/Dialog/DialogManager.tsx
index 484cc261..811e35a7 100644
--- a/packages/web/src/components/Dialog/DialogManager.tsx
+++ b/packages/web/src/components/Dialog/DialogManager.tsx
@@ -3,7 +3,6 @@ import { FactoryResetDeviceDialog } from "@app/components/Dialog/FactoryResetDev
import { ClearAllStoresDialog } from "@components/Dialog/ClearAllStoresDialog/ClearAllStoresDialog.tsx";
import { ClientNotificationDialog } from "@components/Dialog/ClientNotificationDialog/ClientNotificationDialog.tsx";
import { DeleteMessagesDialog } from "@components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.tsx";
-import { DeviceNameDialog } from "@components/Dialog/DeviceNameDialog.tsx";
import { ImportDialog } from "@components/Dialog/ImportDialog.tsx";
import { NodeDetailsDialog } from "@components/Dialog/NodeDetailsDialog/NodeDetailsDialog.tsx";
import { PkiBackupDialog } from "@components/Dialog/PKIBackupDialog.tsx";
@@ -47,12 +46,6 @@ export const DialogManager = () => {
setDialogOpen("reboot", false);
}}
/>
- {
- setDialogOpen("deviceName", open);
- }}
- />
{
diff --git a/packages/web/src/components/Dialog/ImportDialog.tsx b/packages/web/src/components/Dialog/ImportDialog.tsx
index 71affa79..aea9bf74 100644
--- a/packages/web/src/components/Dialog/ImportDialog.tsx
+++ b/packages/web/src/components/Dialog/ImportDialog.tsx
@@ -105,7 +105,7 @@ export const ImportDialog = ({ open, onOpenChange }: ImportDialogProps) => {
)
) {
setChange(
- { type: "channel", index: importIndex[index] ?? 0 },
+ { type: "channels", index: importIndex[index] ?? 0 },
payload,
channels.get(importIndex[index] ?? 0),
);
diff --git a/packages/web/src/components/PageComponents/Channels/Channel.tsx b/packages/web/src/components/PageComponents/Channels/Channel.tsx
index dedff891..eef96340 100644
--- a/packages/web/src/components/PageComponents/Channels/Channel.tsx
+++ b/packages/web/src/components/PageComponents/Channels/Channel.tsx
@@ -47,7 +47,7 @@ export const Channel = ({ onFormInit, channel }: SettingsPanelProps) => {
};
const workingChannel = getChange({
- type: "channel",
+ type: "channels",
index: channel.index,
}) as Protobuf.Channel.Channel | undefined;
const effectiveConfig = workingChannel ?? channel;
@@ -123,11 +123,11 @@ export const Channel = ({ onFormInit, channel }: SettingsPanelProps) => {
});
if (deepCompareConfig(channel, payload, true)) {
- removeChange({ type: "channel", index: channel.index });
+ removeChange({ type: "channels", index: channel.index });
return;
}
- setChange({ type: "channel", index: channel.index }, payload, channel);
+ setChange({ type: "channels", index: channel.index }, payload, channel);
};
const preSharedKeyRegenerate = async () => {
diff --git a/packages/web/src/components/PageComponents/Connect/BLE.tsx b/packages/web/src/components/PageComponents/Connect/BLE.tsx
deleted file mode 100644
index 21e32238..00000000
--- a/packages/web/src/components/PageComponents/Connect/BLE.tsx
+++ /dev/null
@@ -1,104 +0,0 @@
-import { Mono } from "@components/generic/Mono.tsx";
-import { Button } from "@components/UI/Button.tsx";
-import {
- useAppStore,
- useDeviceStore,
- useMessageStore,
- useNodeDBStore,
-} from "@core/stores";
-import { subscribeAll } from "@core/subscriptions.ts";
-import { randId } from "@core/utils/randId.ts";
-import { MeshDevice } from "@meshtastic/core";
-import { TransportWebBluetooth } from "@meshtastic/transport-web-bluetooth";
-import { useCallback, useEffect, useState } from "react";
-import { useTranslation } from "react-i18next";
-import type { TabElementProps } from "../../Dialog/NewDeviceDialog.tsx";
-
-export const BLE = ({ closeDialog }: TabElementProps) => {
- const [connectionInProgress, setConnectionInProgress] = useState(false);
- const [bleDevices, setBleDevices] = useState([]);
-
- const { addDevice } = useDeviceStore();
- const { addNodeDB } = useNodeDBStore();
- const { addMessageStore } = useMessageStore();
- const { setSelectedDevice } = useAppStore();
- const { t } = useTranslation();
-
- const updateBleDeviceList = useCallback(async (): Promise => {
- setBleDevices(await navigator.bluetooth.getDevices());
- }, []);
-
- useEffect(() => {
- updateBleDeviceList();
- }, [updateBleDeviceList]);
-
- const onConnect = async (bleDevice: BluetoothDevice) => {
- const id = randId();
- const transport = await TransportWebBluetooth.createFromDevice(bleDevice);
- const device = addDevice(id);
- const nodeDB = addNodeDB(id);
- const messageStore = addMessageStore(id);
-
- const connection = new MeshDevice(transport, id);
- connection.configure();
- setSelectedDevice(id);
- device.addConnection(connection);
- subscribeAll(device, connection, messageStore, nodeDB);
-
- const HEARTBEAT_INTERVAL = 5 * 60 * 1000;
- connection.setHeartbeatInterval(HEARTBEAT_INTERVAL);
-
- closeDialog();
- };
-
- return (
-
- );
-};
diff --git a/packages/web/src/components/PageComponents/Connect/HTTP.test.tsx b/packages/web/src/components/PageComponents/Connect/HTTP.test.tsx
deleted file mode 100644
index 34040002..00000000
--- a/packages/web/src/components/PageComponents/Connect/HTTP.test.tsx
+++ /dev/null
@@ -1,103 +0,0 @@
-import { HTTP } from "@components/PageComponents/Connect/HTTP.tsx";
-import { MeshDevice } from "@meshtastic/core";
-import { TransportHTTP } from "@meshtastic/transport-http";
-import { fireEvent, render, screen, waitFor } from "@testing-library/react";
-import { describe, expect, it, vi } from "vitest";
-
-vi.mock("@core/stores", () => ({
- useAppStore: vi.fn(() => ({ setSelectedDevice: vi.fn() })),
- useDeviceStore: vi.fn(() => ({
- addDevice: vi.fn(() => ({ addConnection: vi.fn() })),
- })),
- useMessageStore: vi.fn(() => ({
- addMessageStore: vi.fn(),
- })),
- useNodeDBStore: vi.fn(() => ({
- addNodeDB: vi.fn(),
- })),
-}));
-
-vi.mock("@core/utils/randId.ts", () => ({
- randId: vi.fn(() => "mock-id"),
-}));
-
-vi.mock("@meshtastic/transport-http", () => ({
- TransportHTTP: {
- create: vi.fn(() => Promise.resolve({})),
- },
-}));
-
-vi.mock("@meshtastic/core", () => ({
- MeshDevice: vi.fn(() => ({
- configure: vi.fn(),
- })),
-}));
-
-describe("HTTP Component", () => {
- it("renders correctly", () => {
- render();
- expect(screen.getByText("IP Address/Hostname")).toBeInTheDocument();
- expect(screen.getByRole("textbox")).toBeInTheDocument();
- expect(
- screen.getByPlaceholderText("000.000.000.000 / meshtastic.local"),
- ).toBeInTheDocument();
- expect(screen.getByText("Use HTTPS")).toBeInTheDocument();
- expect(screen.getByRole("button", { name: "Connect" })).toBeInTheDocument();
- });
-
- it("allows input field to be updated", () => {
- render();
- const inputField = screen.getByRole("textbox");
- fireEvent.change(inputField, { target: { value: "meshtastic.local" } });
- expect(
- screen.getByPlaceholderText("000.000.000.000 / meshtastic.local"),
- ).toBeInTheDocument();
- });
-
- it("toggles HTTPS switch and updates prefix", () => {
- render();
-
- const switchInput = screen.getByRole("switch");
- expect(screen.getByText("http://")).toBeInTheDocument();
-
- fireEvent.click(switchInput);
- expect(screen.getByText("https://")).toBeInTheDocument();
-
- fireEvent.click(switchInput);
- expect(switchInput).not.toBeChecked();
- expect(screen.getByText("http://")).toBeInTheDocument();
- });
-
- it("enables HTTPS toggle when location protocol is https", () => {
- Object.defineProperty(window, "location", {
- value: { protocol: "https:" },
- writable: true,
- });
-
- render();
-
- const switchInput = screen.getByRole("switch");
- expect(switchInput).toBeChecked();
-
- expect(screen.getByText("https://")).toBeInTheDocument();
- });
-
- it.skip("submits form and triggers connection process", async () => {
- const closeDialog = vi.fn();
- render();
- const button = screen.getByRole("button", { name: "Connect" });
- expect(button).not.toBeDisabled();
-
- try {
- fireEvent.click(button);
- await waitFor(() => {
- expect(button).toBeDisabled();
- expect(closeDialog).toBeCalled();
- expect(TransportHTTP.create).toBeCalled();
- expect(MeshDevice).toBeCalled();
- });
- } catch (e) {
- console.error(e);
- }
- });
-});
diff --git a/packages/web/src/components/PageComponents/Connect/HTTP.tsx b/packages/web/src/components/PageComponents/Connect/HTTP.tsx
deleted file mode 100644
index ebc24a77..00000000
--- a/packages/web/src/components/PageComponents/Connect/HTTP.tsx
+++ /dev/null
@@ -1,172 +0,0 @@
-import type { TabElementProps } from "@components/Dialog/NewDeviceDialog.tsx";
-import { Button } from "@components/UI/Button.tsx";
-import { Input } from "@components/UI/Input.tsx";
-import { Label } from "@components/UI/Label.tsx";
-import { Switch } from "@components/UI/Switch.tsx";
-import { Link } from "@components/UI/Typography/Link.tsx";
-import {
- useAppStore,
- useDeviceStore,
- useMessageStore,
- useNodeDBStore,
-} from "@core/stores";
-import { subscribeAll } from "@core/subscriptions.ts";
-import { randId } from "@core/utils/randId.ts";
-import { MeshDevice } from "@meshtastic/core";
-import { TransportHTTP } from "@meshtastic/transport-http";
-import { AlertTriangle } from "lucide-react";
-import { useState } from "react";
-import { useController, useForm } from "react-hook-form";
-import { useTranslation } from "react-i18next";
-
-interface FormData {
- ip: string;
- tls: boolean;
-}
-
-export const HTTP = ({ closeDialog }: TabElementProps) => {
- const { t } = useTranslation("dialog");
- const [connectionInProgress, setConnectionInProgress] = useState(false);
- const isURLHTTPS = location.protocol === "https:";
-
- const { addDevice } = useDeviceStore();
- const { addNodeDB } = useNodeDBStore();
- const { addMessageStore } = useMessageStore();
- const { setSelectedDevice } = useAppStore();
-
- const { control, handleSubmit, register } = useForm({
- defaultValues: {
- ip: ["client.meshtastic.org", "localhost"].includes(
- globalThis.location.hostname,
- )
- ? "meshtastic.local"
- : globalThis.location.host,
- tls: !!isURLHTTPS,
- },
- });
-
- const {
- field: { value: tlsValue, onChange: setTLS },
- } = useController({ name: "tls", control });
-
- const [connectionError, setConnectionError] = useState<{
- host: string;
- secure: boolean;
- } | null>(null);
-
- const onSubmit = handleSubmit(async (data) => {
- setConnectionInProgress(true);
- setConnectionError(null);
-
- try {
- const id = randId();
- const transport = await TransportHTTP.create(data.ip, data.tls);
- const device = addDevice(id);
- const nodeDB = addNodeDB(id);
- const messageStore = addMessageStore(id);
-
- const connection = new MeshDevice(transport, id);
- connection.configure();
- setSelectedDevice(id);
- device.addConnection(connection);
- subscribeAll(device, connection, messageStore, nodeDB);
- closeDialog();
- } catch (error) {
- if (error instanceof Error) {
- console.error("Connection error:", error);
- }
- // Capture all connection errors regardless of type
- setConnectionError({ host: data.ip, secure: data.tls });
- setConnectionInProgress(false);
- }
- });
-
- return (
-
- );
-};
diff --git a/packages/web/src/components/PageComponents/Connect/Serial.tsx b/packages/web/src/components/PageComponents/Connect/Serial.tsx
deleted file mode 100644
index 477ded03..00000000
--- a/packages/web/src/components/PageComponents/Connect/Serial.tsx
+++ /dev/null
@@ -1,116 +0,0 @@
-import { Mono } from "@components/generic/Mono.tsx";
-import { Button } from "@components/UI/Button.tsx";
-import {
- useAppStore,
- useDeviceStore,
- useMessageStore,
- useNodeDBStore,
-} from "@core/stores";
-import { subscribeAll } from "@core/subscriptions.ts";
-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 type { TabElementProps } from "../../Dialog/NewDeviceDialog.tsx";
-
-export const Serial = ({ closeDialog }: TabElementProps) => {
- const [connectionInProgress, setConnectionInProgress] = useState(false);
- const [serialPorts, setSerialPorts] = useState([]);
-
- const { addDevice } = useDeviceStore();
- const { addNodeDB } = useNodeDBStore();
- const { addMessageStore } = useMessageStore();
- const { setSelectedDevice } = useAppStore();
- const { t } = useTranslation();
-
- const updateSerialPortList = useCallback(async () => {
- setSerialPorts(await navigator.serial.getPorts());
- }, []);
-
- navigator.serial.addEventListener("connect", () => {
- updateSerialPortList();
- });
- navigator.serial.addEventListener("disconnect", () => {
- updateSerialPortList();
- });
- useEffect(() => {
- updateSerialPortList();
- }, [updateSerialPortList]);
-
- const onConnect = async (port: SerialPort) => {
- const id = randId();
- const device = addDevice(id);
- const nodeDB = addNodeDB(id);
- const messageStore = addMessageStore(id);
-
- setSelectedDevice(id);
- const transport = await TransportWebSerial.createFromPort(port);
- const connection = new MeshDevice(transport, id);
- connection.configure();
- device.addConnection(connection);
- subscribeAll(device, connection, messageStore, nodeDB);
-
- const HEARTBEAT_INTERVAL = 5 * 60 * 1000;
- connection.setHeartbeatInterval(HEARTBEAT_INTERVAL);
-
- closeDialog();
- };
-
- return (
-
- );
-};
diff --git a/packages/web/src/components/PageComponents/Connections/ConnectionStatusBadge.tsx b/packages/web/src/components/PageComponents/Connections/ConnectionStatusBadge.tsx
new file mode 100644
index 00000000..f1b2802b
--- /dev/null
+++ b/packages/web/src/components/PageComponents/Connections/ConnectionStatusBadge.tsx
@@ -0,0 +1,38 @@
+import { Button } from "@app/components/UI/Button";
+import type { Connection } from "@app/core/stores/deviceStore/types";
+
+export function ConnectionStatusBadge({
+ status,
+}: {
+ status: Connection["status"];
+}) {
+ let color = "";
+
+ switch (status) {
+ case "connected":
+ color = "bg-emerald-500";
+ break;
+ case "connecting":
+ color = "bg-amber-500";
+ break;
+ case "online":
+ color = "bg-blue-500";
+ break;
+ case "error":
+ color = "bg-red-500";
+ break;
+ default:
+ color = "bg-gray-400";
+ }
+ return (
+
+ );
+}
diff --git a/packages/web/src/components/Sidebar.tsx b/packages/web/src/components/Sidebar.tsx
index 5f551d93..a804d51c 100644
--- a/packages/web/src/components/Sidebar.tsx
+++ b/packages/web/src/components/Sidebar.tsx
@@ -6,6 +6,7 @@ import {
type Page,
useAppStore,
useDevice,
+ useDeviceStore,
useNodeDB,
useSidebar,
} from "@core/stores";
@@ -70,11 +71,18 @@ export const Sidebar = ({ children }: SidebarProps) => {
const { hardware, metadata, unreadCounts, setDialogOpen } = useDevice();
const { getNode, getNodesLength } = useNodeDB();
const { setCommandPaletteOpen } = useAppStore();
+ const savedConnections = useDeviceStore((s) => s.savedConnections);
const myNode = getNode(hardware.myNodeNum);
const { isCollapsed } = useSidebar();
const { t } = useTranslation("ui");
const navigate = useNavigate({ from: "/" });
+ // Get the active connection (connected > default > first)
+ const activeConnection =
+ savedConnections.find((c) => c.status === "connected") ||
+ savedConnections.find((c) => c.isDefault) ||
+ savedConnections[0];
+
const pathname = useLocation({
select: (location) => location.pathname.replace(/^\//, ""),
});
@@ -210,6 +218,8 @@ export const Sidebar = ({ children }: SidebarProps) => {
? Math.abs(myNode.deviceMetrics?.voltage)
: undefined,
}}
+ connectionStatus={activeConnection?.status}
+ connectionName={activeConnection?.name}
/>
)}
diff --git a/packages/web/src/components/UI/AlertDialog.tsx b/packages/web/src/components/UI/AlertDialog.tsx
new file mode 100644
index 00000000..264328cb
--- /dev/null
+++ b/packages/web/src/components/UI/AlertDialog.tsx
@@ -0,0 +1,149 @@
+import { cn } from "@core/utils/cn.ts";
+import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
+import * as React from "react";
+import { buttonVariants } from "./Button.tsx";
+
+const AlertDialog = AlertDialogPrimitive.Root;
+
+const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
+
+const AlertDialogPortal = ({
+ children,
+ ...props
+}: AlertDialogPrimitive.AlertDialogPortalProps) => (
+
+
+ {children}
+
+
+);
+AlertDialogPortal.displayName = AlertDialogPrimitive.Portal.displayName;
+
+const AlertDialogOverlay = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
+
+const AlertDialogContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+
+));
+AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
+
+const AlertDialogHeader = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+);
+AlertDialogHeader.displayName = "AlertDialogHeader";
+
+const AlertDialogFooter = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+);
+AlertDialogFooter.displayName = "AlertDialogFooter";
+
+const AlertDialogTitle = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
+
+const AlertDialogDescription = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+AlertDialogDescription.displayName =
+ AlertDialogPrimitive.Description.displayName;
+
+const AlertDialogAction = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
+
+const AlertDialogCancel = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
+
+export {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+};
diff --git a/packages/web/src/components/UI/Badge.tsx b/packages/web/src/components/UI/Badge.tsx
new file mode 100644
index 00000000..df7ee932
--- /dev/null
+++ b/packages/web/src/components/UI/Badge.tsx
@@ -0,0 +1,35 @@
+import { cn } from "@core/utils/cn.ts";
+import { cva, type VariantProps } from "class-variance-authority";
+import type * as React from "react";
+
+const badgeVariants = cva(
+ "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2",
+ {
+ variants: {
+ variant: {
+ default:
+ "border-transparent bg-slate-900 text-white hover:bg-slate-900/80 dark:bg-slate-50 dark:text-slate-900 dark:hover:bg-slate-50/80",
+ secondary:
+ "border-transparent bg-slate-100 text-slate-900 hover:bg-slate-100/80 dark:bg-slate-800 dark:text-slate-100 dark:hover:bg-slate-800/80",
+ destructive:
+ "border-transparent bg-red-500 text-white hover:bg-red-500/80 dark:bg-red-900 dark:text-red-50 dark:hover:bg-red-900/80",
+ outline: "text-slate-900 dark:text-slate-100",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ },
+);
+
+export interface BadgeProps
+ extends React.HTMLAttributes,
+ VariantProps {}
+
+function Badge({ className, variant, ...props }: BadgeProps) {
+ return (
+
+ );
+}
+
+export { Badge, badgeVariants };
diff --git a/packages/web/src/components/UI/Card.tsx b/packages/web/src/components/UI/Card.tsx
new file mode 100644
index 00000000..e67e1b38
--- /dev/null
+++ b/packages/web/src/components/UI/Card.tsx
@@ -0,0 +1,85 @@
+import { cn } from "@core/utils/cn.ts";
+import * as React from "react";
+
+const Card = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+Card.displayName = "Card";
+
+const CardHeader = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+CardHeader.displayName = "CardHeader";
+
+const CardTitle = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+CardTitle.displayName = "CardTitle";
+
+const CardDescription = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+CardDescription.displayName = "CardDescription";
+
+const CardContent = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+CardContent.displayName = "CardContent";
+
+const CardFooter = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+CardFooter.displayName = "CardFooter";
+
+export {
+ Card,
+ CardContent,
+ CardDescription,
+ CardFooter,
+ CardHeader,
+ CardTitle,
+};
diff --git a/packages/web/src/components/UI/ErrorPage.tsx b/packages/web/src/components/UI/ErrorPage.tsx
index 2cd2b638..c35a74df 100644
--- a/packages/web/src/components/UI/ErrorPage.tsx
+++ b/packages/web/src/components/UI/ErrorPage.tsx
@@ -48,8 +48,8 @@ export function ErrorPage({ error }: { error: Error }) {
]}
+ i18nKey="errorPage.connectionsLink"
+ components={[]}
/>
diff --git a/packages/web/src/core/hooks/useLRUList.ts b/packages/web/src/core/hooks/useLRUList.ts
new file mode 100644
index 00000000..d28fb61b
--- /dev/null
+++ b/packages/web/src/core/hooks/useLRUList.ts
@@ -0,0 +1,232 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+
+/**
+ * A minimal serializable wrapper so we can store metadata alongside items.
+ */
+type PersistedPayload = {
+ v: 1; // schema version
+ capacity: number; // last used capacity (for info/migrations)
+ items: J[]; // serialized items (MRU -> LRU)
+};
+
+export type UseLRUListOptions = {
+ /** localStorage key */
+ key: string;
+ /** max number of items to keep (>=1) */
+ capacity: number;
+ /** optional initial items used when storage is empty/invalid */
+ initial?: T[];
+ /** equality to de-duplicate items; default: Object.is */
+ eq?: (a: T, b: T) => boolean;
+ /** convert T -> JSON-safe type; default: (x) => x as unknown as J */
+ toJSON?: (t: T) => J;
+ /** convert JSON-safe type -> T; default: (x) => x as unknown as T */
+ fromJSON?: (j: J) => T;
+ /** storage impl (for tests); default: window.localStorage */
+ storage?: Storage;
+ /** listen to storage events and live-sync across tabs/windows; default: true */
+ syncTabs?: boolean;
+};
+
+export type UseLRUListReturn = {
+ /** Items ordered MRU -> LRU */
+ items: T[];
+ /** Add or "touch" an item (move to MRU); inserts if missing */
+ add: (item: T) => void;
+ /** Remove a matching item (no-op if missing) */
+ remove: (item: T) => void;
+ /** Clear all items */
+ clear: () => void;
+ /** Whether a matching item exists */
+ has: (item: T) => boolean;
+ /** Replace the entire list (applies LRU trimming) */
+ replaceAll: (next: T[]) => void;
+ /** Current capacity (for information) */
+ capacity: number;
+};
+
+/**
+ * useLRUList – maintains a most-recently-used list and persists it to localStorage.
+ *
+ * MRU is index 0. Adding an existing item "touches" it (moves to front).
+ */
+export function useLRUList(
+ opts: UseLRUListOptions,
+): UseLRUListReturn {
+ const {
+ key,
+ capacity,
+ initial = [],
+ eq = Object.is,
+ toJSON = (x: T) => x as unknown as J,
+ fromJSON = (x: J) => x as unknown as T,
+ storage = typeof window !== "undefined" ? window.localStorage : undefined,
+ syncTabs = true,
+ } = opts;
+
+ if (capacity < 1) {
+ // Fail fast in dev; silently coerce in prod
+ if (process.env.NODE_ENV !== "production") {
+ throw new Error("useLRUList: capacity must be >= 1");
+ }
+ }
+
+ const effectiveCapacity = Math.max(1, capacity);
+
+ // Guard against SSR or no-storage environments
+ const canPersist = !!storage && typeof storage.getItem === "function";
+
+ const readPersisted = useCallback((): T[] | null => {
+ if (!canPersist) {
+ return null;
+ }
+ try {
+ const raw = storage.getItem(key);
+ if (!raw) {
+ return null;
+ }
+ const parsed = JSON.parse(raw) as PersistedPayload;
+ if (!parsed || parsed.v !== 1 || !Array.isArray(parsed.items)) {
+ return null;
+ }
+ const deserialized = parsed.items.map(fromJSON);
+ return trimToCapacity(deserialized, effectiveCapacity);
+ } catch {
+ return null;
+ }
+ }, [canPersist, storage, key, fromJSON, effectiveCapacity]);
+
+ const writePersisted = useCallback(
+ (items: T[]) => {
+ if (!canPersist) {
+ return;
+ }
+ try {
+ const payload: PersistedPayload = {
+ v: 1,
+ capacity: effectiveCapacity,
+ items: items.map(toJSON),
+ };
+ storage.setItem(key, JSON.stringify(payload));
+ } catch {
+ // Swallow quota/serialization errors; keep in-memory state working
+ }
+ },
+ [canPersist, storage, key, toJSON, effectiveCapacity],
+ );
+
+ // Initialize from storage (or fallback to `initial`)
+ const [items, setItems] = useState(
+ () => readPersisted() ?? trimToCapacity([...initial], effectiveCapacity),
+ );
+
+ // Keep a ref to avoid feedback loops when applying remote (storage event) updates
+ const applyingExternal = useRef(false);
+
+ // Persist on changes
+ useEffect(() => {
+ if (applyingExternal.current) {
+ applyingExternal.current = false;
+ return;
+ }
+ writePersisted(items);
+ }, [items, writePersisted]);
+
+ // Cross-tab synchronization via storage events
+ useEffect(() => {
+ if (!syncTabs || !canPersist || typeof window === "undefined") {
+ return;
+ }
+ const onStorage = (e: StorageEvent) => {
+ if (e.storageArea !== storage || e.key !== key) {
+ return;
+ }
+ // Another tab changed it; re-read safely
+ const next = readPersisted();
+ if (!next) {
+ return;
+ }
+ applyingExternal.current = true;
+ setItems(next);
+ };
+ window.addEventListener("storage", onStorage);
+ return () => window.removeEventListener("storage", onStorage);
+ }, [syncTabs, canPersist, storage, key, readPersisted]);
+
+ // Helpers
+ const indexOf = useCallback(
+ (arr: T[], needle: T) => arr.findIndex((x) => eq(x, needle)),
+ [eq],
+ );
+
+ const add = useCallback(
+ (item: T) => {
+ setItems((prev) => {
+ const idx = indexOf(prev, item);
+ if (idx === 0) {
+ return prev; // already MRU
+ }
+ if (idx > 0) {
+ const next = [...prev];
+ next.splice(idx, 1);
+ next.unshift(item);
+ return next;
+ }
+ // Not present: insert at MRU and trim
+ const next = [item, ...prev];
+ return trimToCapacity(next, effectiveCapacity);
+ });
+ },
+ [indexOf, effectiveCapacity],
+ );
+
+ const remove = useCallback(
+ (item: T) => {
+ setItems((prev) => {
+ const idx = indexOf(prev, item);
+ if (idx === -1) {
+ return prev;
+ }
+ const next = [...prev];
+ next.splice(idx, 1);
+ return next;
+ });
+ },
+ [indexOf],
+ );
+
+ const clear = useCallback(() => setItems([]), []);
+
+ const has = useCallback(
+ (item: T) => indexOf(items, item) !== -1,
+ [items, indexOf],
+ );
+
+ const replaceAll = useCallback(
+ (next: T[]) => setItems(trimToCapacity([...next], effectiveCapacity)),
+ [effectiveCapacity],
+ );
+
+ // Stable API shape
+ return useMemo(
+ () => ({
+ items,
+ add,
+ remove,
+ clear,
+ has,
+ replaceAll,
+ capacity: effectiveCapacity,
+ }),
+ [items, add, remove, clear, has, replaceAll, effectiveCapacity],
+ );
+}
+
+// --- utils ---
+
+function trimToCapacity(arr: T[], capacity: number): T[] {
+ if (arr.length <= capacity) {
+ return arr;
+ }
+ return arr.slice(0, capacity);
+}
diff --git a/packages/web/src/core/services/dev-overrides.ts b/packages/web/src/core/services/dev-overrides.ts
index 0785ebe6..59c060de 100644
--- a/packages/web/src/core/services/dev-overrides.ts
+++ b/packages/web/src/core/services/dev-overrides.ts
@@ -7,7 +7,6 @@ if (isDev) {
featureFlags.setOverrides({
persistNodeDB: true,
persistMessages: true,
- persistDevices: false,
persistApp: true,
});
}
diff --git a/packages/web/src/core/stores/deviceStore/changeRegistry.ts b/packages/web/src/core/stores/deviceStore/changeRegistry.ts
index 76de523e..7dbaae12 100644
--- a/packages/web/src/core/stores/deviceStore/changeRegistry.ts
+++ b/packages/web/src/core/stores/deviceStore/changeRegistry.ts
@@ -29,7 +29,7 @@ export type ValidModuleConfigType =
export type ConfigChangeKey =
| { type: "config"; variant: ValidConfigType }
| { type: "moduleConfig"; variant: ValidModuleConfigType }
- | { type: "channel"; index: Types.ChannelNumber }
+ | { type: "channels"; index: Types.ChannelNumber }
| { type: "user" };
// Serialized key for Map storage
@@ -57,7 +57,7 @@ export function serializeKey(key: ConfigChangeKey): ConfigChangeKeyString {
return `config:${key.variant}`;
case "moduleConfig":
return `moduleConfig:${key.variant}`;
- case "channel":
+ case "channels":
return `channel:${key.index}`;
case "user":
return "user";
@@ -78,9 +78,9 @@ export function deserializeKey(keyStr: ConfigChangeKeyString): ConfigChangeKey {
type: "moduleConfig",
variant: variant as ValidModuleConfigType,
};
- case "channel":
+ case "channels":
return {
- type: "channel",
+ type: "channels",
index: Number(variant) as Types.ChannelNumber,
};
case "user":
@@ -126,7 +126,7 @@ export function hasChannelChange(
registry: ChangeRegistry,
index: Types.ChannelNumber,
): boolean {
- return registry.changes.has(serializeKey({ type: "channel", index }));
+ return registry.changes.has(serializeKey({ type: "channels", index }));
}
/**
@@ -171,7 +171,7 @@ export function getChannelChangeCount(registry: ChangeRegistry): number {
let count = 0;
for (const keyStr of registry.changes.keys()) {
const key = deserializeKey(keyStr);
- if (key.type === "channel") {
+ if (key.type === "channels") {
count++;
}
}
@@ -212,7 +212,7 @@ export function getAllModuleConfigChanges(
export function getAllChannelChanges(registry: ChangeRegistry): ChangeEntry[] {
const changes: ChangeEntry[] = [];
for (const entry of registry.changes.values()) {
- if (entry.key.type === "channel") {
+ if (entry.key.type === "channels") {
changes.push(entry);
}
}
diff --git a/packages/web/src/core/stores/deviceStore/index.ts b/packages/web/src/core/stores/deviceStore/index.ts
index 55b10935..c003d982 100644
--- a/packages/web/src/core/stores/deviceStore/index.ts
+++ b/packages/web/src/core/stores/deviceStore/index.ts
@@ -1,5 +1,4 @@
import { create, toBinary } from "@bufbuild/protobuf";
-import { featureFlags } from "@core/services/featureFlags";
import { evictOldestEntries } from "@core/stores/utils/evictOldestEntries.ts";
import { createStorage } from "@core/stores/utils/indexDB.ts";
import { type MeshDevice, Protobuf, Types } from "@meshtastic/core";
@@ -26,6 +25,8 @@ import {
serializeKey,
} from "./changeRegistry.ts";
import type {
+ Connection,
+ ConnectionId,
Dialogs,
DialogVariant,
ValidConfigType,
@@ -142,6 +143,16 @@ export interface deviceState {
removeDevice: (id: number) => void;
getDevices: () => Device[];
getDevice: (id: number) => Device | undefined;
+
+ // Saved connections management
+ savedConnections: Connection[];
+ addSavedConnection: (connection: Connection) => void;
+ updateSavedConnection: (
+ id: ConnectionId,
+ updates: Partial,
+ ) => void;
+ removeSavedConnection: (id: ConnectionId) => void;
+ getSavedConnections: () => Connection[];
}
interface PrivateDeviceState extends deviceState {
@@ -150,6 +161,7 @@ interface PrivateDeviceState extends deviceState {
type DevicePersisted = {
devices: Map;
+ savedConnections: Connection[];
};
function deviceFactory(
@@ -894,6 +906,7 @@ export const deviceStoreInitializer: StateCreator = (
get,
) => ({
devices: new Map(),
+ savedConnections: [],
addDevice: (id) => {
const existing = get().devices.get(id);
@@ -924,6 +937,41 @@ export const deviceStoreInitializer: StateCreator = (
},
getDevices: () => Array.from(get().devices.values()),
getDevice: (id) => get().devices.get(id),
+
+ addSavedConnection: (connection) => {
+ set(
+ produce((draft) => {
+ draft.savedConnections.push(connection);
+ }),
+ );
+ },
+ updateSavedConnection: (id, updates) => {
+ set(
+ produce((draft) => {
+ const conn = draft.savedConnections.find(
+ (c: Connection) => c.id === id,
+ );
+ if (conn) {
+ for (const key in updates) {
+ if (Object.hasOwn(updates, key)) {
+ (conn as Record)[key] =
+ updates[key as keyof typeof updates];
+ }
+ }
+ }
+ }),
+ );
+ },
+ removeSavedConnection: (id) => {
+ set(
+ produce((draft) => {
+ draft.savedConnections = draft.savedConnections.filter(
+ (c: Connection) => c.id !== id,
+ );
+ }),
+ );
+ },
+ getSavedConnections: () => get().savedConnections,
});
const persistOptions: PersistOptions = {
@@ -943,6 +991,7 @@ const persistOptions: PersistOptions = {
},
]),
),
+ savedConnections: s.savedConnections,
}),
onRehydrateStorage: () => (state) => {
if (!state) {
@@ -980,14 +1029,6 @@ const persistOptions: PersistOptions = {
},
};
-// Add persist middleware on the store if the feature flag is enabled
-const persistDevices = featureFlags.get("persistDevices");
-console.debug(
- `DeviceStore: Persisting devices is ${persistDevices ? "enabled" : "disabled"}`,
+export const useDeviceStore = createStore(
+ subscribeWithSelector(persist(deviceStoreInitializer, persistOptions)),
);
-
-export const useDeviceStore = persistDevices
- ? createStore(
- subscribeWithSelector(persist(deviceStoreInitializer, persistOptions)),
- )
- : createStore(subscribeWithSelector(deviceStoreInitializer));
diff --git a/packages/web/src/core/stores/deviceStore/types.ts b/packages/web/src/core/stores/deviceStore/types.ts
index c6df2a32..484f3b31 100644
--- a/packages/web/src/core/stores/deviceStore/types.ts
+++ b/packages/web/src/core/stores/deviceStore/types.ts
@@ -28,6 +28,46 @@ type DialogVariant = keyof Dialogs;
type Page = "messages" | "map" | "settings" | "channels" | "nodes";
+export type ConnectionId = number;
+export type ConnectionType = "http" | "bluetooth" | "serial";
+export type ConnectionStatus =
+ | "connected"
+ | "connecting"
+ | "disconnected"
+ | "disconnecting"
+ | "configuring"
+ | "configured"
+ | "online"
+ | "error";
+
+export type Connection = {
+ id: ConnectionId;
+ type: ConnectionType;
+ name: string;
+ createdAt: number;
+ lastConnectedAt?: number;
+ isDefault?: boolean;
+ status: ConnectionStatus;
+ error?: string;
+ meshDeviceId?: number;
+} & NewConnection;
+
+export type NewConnection =
+ | { type: "http"; name: string; url: string }
+ | {
+ type: "bluetooth";
+ name: string;
+ deviceId?: string;
+ deviceName?: string;
+ gattServiceUUID?: string;
+ }
+ | {
+ type: "serial";
+ name: string;
+ usbVendorId?: number;
+ usbProductId?: number;
+ };
+
type WaypointWithMetadata = Protobuf.Mesh.Waypoint & {
metadata: {
channel: number; // Channel on which the waypoint was received
diff --git a/packages/web/src/i18n-config.ts b/packages/web/src/i18n-config.ts
index dc8cde12..3c6f4637 100644
--- a/packages/web/src/i18n-config.ts
+++ b/packages/web/src/i18n-config.ts
@@ -50,11 +50,11 @@ i18next
debug: import.meta.env.MODE === "development",
ns: [
"channels",
+ "connections",
"commandPalette",
"common",
"config",
"moduleConfig",
- "dashboard",
"dialog",
"messages",
"nodes",
diff --git a/packages/web/src/pages/Connections/index.tsx b/packages/web/src/pages/Connections/index.tsx
new file mode 100644
index 00000000..6d042622
--- /dev/null
+++ b/packages/web/src/pages/Connections/index.tsx
@@ -0,0 +1,411 @@
+import AddConnectionDialog from "@app/components/Dialog/AddConnectionDialog/AddConnectionDialog";
+import { ConnectionStatusBadge } from "@app/components/PageComponents/Connections/ConnectionStatusBadge";
+import type { Connection } from "@app/core/stores/deviceStore/types";
+import { useConnections } from "@app/pages/Connections/useConnections";
+import {
+ connectionTypeIcon,
+ formatConnectionSubtext,
+} from "@app/pages/Connections/utils";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+} from "@components/UI/AlertDialog.tsx";
+import { Badge } from "@components/UI/Badge.tsx";
+import { Button } from "@components/UI/Button.tsx";
+import {
+ Card,
+ CardContent,
+ CardFooter,
+ CardHeader,
+ CardTitle,
+} from "@components/UI/Card.tsx";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@components/UI/DropdownMenu.tsx";
+import { Separator } from "@components/UI/Separator.tsx";
+import { useToast } from "@core/hooks/useToast.ts";
+import { useNavigate } from "@tanstack/react-router";
+import {
+ ArrowLeft,
+ LinkIcon,
+ MoreHorizontal,
+ PlugZap,
+ RotateCw,
+ Star,
+ StarOff,
+ Trash2,
+} from "lucide-react";
+import { useEffect, useMemo, useState } from "react";
+import { useTranslation } from "react-i18next";
+
+export const Connections = () => {
+ const {
+ connections,
+ addConnectionAndConnect,
+ connect,
+ disconnect,
+ removeConnection,
+ setDefaultConnection,
+ refreshStatuses,
+ } = useConnections();
+ const { toast } = useToast();
+ const navigate = useNavigate({ from: "/" });
+ const [addOpen, setAddOpen] = useState(false);
+ const isURLHTTPS = useMemo(() => location.protocol === "https:", []);
+ const { t } = useTranslation("connections");
+
+ // On first mount, try to refresh statuses
+ // biome-ignore lint/correctness/useExhaustiveDependencies: This can cause the icon to refresh too often
+ useEffect(() => {
+ refreshStatuses();
+ }, []);
+
+ const sorted = useMemo(() => {
+ const copy = [...connections];
+ return copy.sort((a, b) => {
+ if (a.isDefault && !b.isDefault) {
+ return -1;
+ }
+ if (!a.isDefault && b.isDefault) {
+ return 1;
+ }
+ if (a.status === "connected" && b.status !== "connected") {
+ return -1;
+ }
+ return a.name.localeCompare(b.name);
+ });
+ }, [connections]);
+
+ return (
+
+
+
+
+
+
+ {t("page.title")}
+
+
+ {t("page.description")}
+
+
+
+
+
+
+
+
+
+
+ {sorted.length === 0 ? (
+
+
+
+ {t("noConnections.title")}{" "}
+
+
+
+ {t("noConnections.description")}
+
+
+
+
+
+ ) : (
+
+ {sorted.map((c) => (
+ {
+ const ok = await connect(c.id, { allowPrompt: true });
+ toast({
+ title: ok ? t("toasts.connected") : t("toasts.failed"),
+ description: ok
+ ? t("toasts.nowConnected", {
+ name: c.name,
+ interpolation: { escapeValue: false },
+ })
+ : t("toasts.checkConnetion"),
+ });
+ if (ok) {
+ navigate({ to: "/" });
+ }
+ }}
+ onDisconnect={async () => {
+ await disconnect(c.id);
+ toast({
+ title: t("toasts.disconnected"),
+ description: t("toasts.nowDisconnected", {
+ name: c.name,
+ interpolation: { escapeValue: false },
+ }),
+ });
+ }}
+ onSetDefault={() => {
+ setDefaultConnection(c.id);
+ toast({
+ title: t("toasts.defaultSet"),
+ description: t("toasts.defaultConnection", {
+ name: c.name,
+ interpolation: { escapeValue: false },
+ }),
+ });
+ }}
+ onDelete={async () => {
+ await disconnect(c.id);
+ removeConnection(c.id);
+ toast({
+ title: t("toasts.deleted"),
+ description: t("toasts.deletedByName", {
+ name: c.name,
+ interpolation: { escapeValue: false },
+ }),
+ });
+ }}
+ onRetry={async () => {
+ const ok = await connect(c.id, { allowPrompt: true });
+ toast({
+ title: ok ? t("toasts.connected") : t("toasts.failed"),
+ description: ok
+ ? t("toasts.nowConnected", {
+ name: c.name,
+ interpolation: { escapeValue: false },
+ })
+ : t("toasts.pickConnectionAgain"),
+ });
+ if (ok) {
+ navigate({ to: "/" });
+ }
+ }}
+ />
+ ))}
+
+ )}
+
+
{
+ const created = await addConnectionAndConnect(partial, btDevice);
+ if (created) {
+ setAddOpen(false);
+ toast({
+ title: t("toasts.added"),
+ description: t("toasts.savedByName", {
+ name: created.name,
+ interpolation: { escapeValue: false },
+ }),
+ });
+ if (created.status === "connected") {
+ navigate({ to: "/" });
+ }
+ } else {
+ toast({
+ title: "Unable to connect",
+ description: "savedCantConnect",
+ });
+ }
+ }}
+ />
+
+ );
+};
+
+function TypeBadge({ type }: { type: Connection["type"] }) {
+ const Icon = connectionTypeIcon(type);
+ const label =
+ type === "http" ? "HTTP" : type === "bluetooth" ? "Bluetooth" : "Serial";
+ return (
+
+
+ {label}
+
+ );
+}
+
+function ConnectionCard({
+ connection,
+ onConnect,
+ onDisconnect,
+ onSetDefault,
+ onDelete,
+ onRetry,
+}: {
+ connection: Connection;
+ onConnect: () => Promise | Promise;
+ onDisconnect: () => Promise | Promise;
+ onSetDefault: () => void;
+ onDelete: () => void;
+ onRetry: () => Promise | Promise;
+}) {
+ const { t } = useTranslation("connections");
+
+ const Icon = connectionTypeIcon(connection.type);
+ const isBusy = connection.status === "connecting";
+ const isConnected = connection.status === "connected";
+ const isError = connection.status === "error";
+
+ return (
+
+
+
+
+
+
+ {connection.name}
+ {connection.isDefault ? (
+
+
+ {t("default")}
+
+ ) : null}
+
+
+
+
+ {formatConnectionSubtext(connection)}
+
+
+
+
+
+
+
+
+
+
+
+ {connection.type === "http" && connection.isDefault && (
+ onSetDefault()}
+ >
+
+ {t("button.unsetDefault")}
+
+ )}
+ {connection.type === "http" && !connection.isDefault && (
+ onSetDefault()}
+ >
+
+ {t("button.setDefault")}
+
+ )}
+
+
+ e.preventDefault()}
+ >
+
+ {t("button.delete")}
+
+
+
+
+
+ {t("deleteConnection")}
+
+
+ {t("areYouSure", { name: connection.name })}
+
+
+
+
+ {t("button.cancel")}
+
+ onDelete()}
+ >
+ {t("button.delete")}
+
+
+
+
+
+
+
+
+
+
+ {connection.error ? (
+
+ {connection.error}
+
+ ) : connection.lastConnectedAt ? (
+
+ {t("lastConnectedAt", {
+ date: new Date(connection.lastConnectedAt),
+ })}
+ :{new Date(connection.lastConnectedAt).toLocaleString()}
+
+ ) : (
+
+ {t("neverConnected")}
+
+ )}
+
+
+ {isConnected ? (
+
+ ) : (
+
+ )}
+
+
+ );
+}
diff --git a/packages/web/src/pages/Connections/useConnections.ts b/packages/web/src/pages/Connections/useConnections.ts
new file mode 100644
index 00000000..9e6f7f9d
--- /dev/null
+++ b/packages/web/src/pages/Connections/useConnections.ts
@@ -0,0 +1,496 @@
+import type {
+ Connection,
+ ConnectionId,
+ ConnectionStatus,
+ NewConnection,
+} from "@app/core/stores/deviceStore/types";
+import {
+ createConnectionFromInput,
+ testHttpReachable,
+} from "@app/pages/Connections/utils";
+import {
+ useAppStore,
+ useDeviceStore,
+ useMessageStore,
+ useNodeDBStore,
+} from "@core/stores";
+import { subscribeAll } from "@core/subscriptions.ts";
+import { randId } from "@core/utils/randId.ts";
+import { MeshDevice } from "@meshtastic/core";
+import { TransportHTTP } from "@meshtastic/transport-http";
+import { TransportWebBluetooth } from "@meshtastic/transport-web-bluetooth";
+import { TransportWebSerial } from "@meshtastic/transport-web-serial";
+import { useCallback, useRef } from "react";
+
+type LiveRefs = {
+ bt: Map;
+ serial: Map;
+ meshDevices: Map;
+};
+
+export function useConnections() {
+ const connections = useDeviceStore((s) => s.savedConnections);
+
+ const addSavedConnection = useDeviceStore((s) => s.addSavedConnection);
+ const updateSavedConnection = useDeviceStore((s) => s.updateSavedConnection);
+ const removeSavedConnectionFromStore = useDeviceStore(
+ (s) => s.removeSavedConnection,
+ );
+
+ const live = useRef({
+ bt: new Map(),
+ serial: new Map(),
+ meshDevices: new Map(),
+ });
+ const { addDevice } = useDeviceStore();
+ const { addNodeDB } = useNodeDBStore();
+ const { addMessageStore } = useMessageStore();
+ const { setSelectedDevice } = useAppStore();
+
+ const updateStatus = useCallback(
+ (id: ConnectionId, status: ConnectionStatus, error?: string) => {
+ const updates: Partial = {
+ status,
+ error: error || undefined,
+ ...(status === "disconnected" ? { lastConnectedAt: Date.now() } : {}),
+ };
+ updateSavedConnection(id, updates);
+ },
+ [updateSavedConnection],
+ );
+
+ const removeConnection = useCallback(
+ (id: ConnectionId) => {
+ // Disconnect MeshDevice first
+ const meshDevice = live.current.meshDevices.get(id);
+ if (meshDevice) {
+ try {
+ meshDevice.disconnect();
+ } catch {}
+ live.current.meshDevices.delete(id);
+ }
+
+ // Close live refs if open
+ const bt = live.current.bt.get(id);
+ if (bt?.gatt?.connected) {
+ try {
+ bt.gatt.disconnect();
+ } catch {
+ // Ignore errors
+ }
+ }
+ const sp = live.current.serial.get(id);
+ if (sp && "close" in sp) {
+ try {
+ (sp as SerialPort & { close: () => Promise }).close();
+ } catch {
+ // Ignore errors
+ }
+ }
+ live.current.bt.delete(id);
+ live.current.serial.delete(id);
+ removeSavedConnectionFromStore(id);
+ },
+ [removeSavedConnectionFromStore],
+ );
+
+ const setDefaultConnection = useCallback(
+ (id: ConnectionId) => {
+ for (const connection of connections) {
+ if (connection.id === id) {
+ updateSavedConnection(connection.id, {
+ isDefault: !connection.isDefault,
+ });
+ }
+ }
+ },
+ [connections, updateSavedConnection],
+ );
+
+ const setupMeshDevice = useCallback(
+ (
+ id: ConnectionId,
+ transport:
+ | Awaited>
+ | Awaited>
+ | Awaited>,
+ options?: {
+ setHeartbeat?: boolean;
+ onDisconnect?: () => void;
+ },
+ ): number => {
+ const deviceId = randId();
+ const device = addDevice(deviceId);
+ const nodeDB = addNodeDB(deviceId);
+ const messageStore = addMessageStore(deviceId);
+ const meshDevice = new MeshDevice(transport, deviceId);
+ meshDevice.configure();
+ setSelectedDevice(deviceId);
+ device.addConnection(meshDevice);
+ subscribeAll(device, meshDevice, messageStore, nodeDB);
+ live.current.meshDevices.set(id, meshDevice);
+
+ if (options?.setHeartbeat) {
+ const HEARTBEAT_INTERVAL = 5 * 60 * 1000;
+ meshDevice.setHeartbeatInterval(HEARTBEAT_INTERVAL);
+ }
+
+ updateSavedConnection(id, { meshDeviceId: deviceId });
+ return deviceId;
+ },
+ [
+ addDevice,
+ addNodeDB,
+ addMessageStore,
+ setSelectedDevice,
+ updateSavedConnection,
+ ],
+ );
+
+ const connect = useCallback(
+ async (id: ConnectionId, opts?: { allowPrompt?: boolean }) => {
+ const conn = connections.find((c) => c.id === id);
+ if (!conn) {
+ return false;
+ }
+ if (conn.status === "connected") {
+ return true;
+ }
+
+ updateStatus(id, "connecting");
+ try {
+ if (conn.type === "http") {
+ const ok = await testHttpReachable(conn.url);
+ if (!ok) {
+ const url = new URL(conn.url);
+ const isHTTPS = url.protocol === "https:";
+ const message = isHTTPS
+ ? `Cannot reach HTTPS endpoint. If using a self-signed certificate, open ${conn.url} in a new tab, accept the certificate warning, then try connecting again.`
+ : "HTTP endpoint not reachable (may be blocked by CORS)";
+ throw new Error(message);
+ }
+
+ const url = new URL(conn.url);
+ const isTLS = url.protocol === "https:";
+ const transport = await TransportHTTP.create(url.host, isTLS);
+ setupMeshDevice(id, transport);
+ updateStatus(id, "connected");
+ return true;
+ }
+
+ if (conn.type === "bluetooth") {
+ if (!("bluetooth" in navigator)) {
+ throw new Error("Web Bluetooth not supported");
+ }
+ let bleDevice = live.current.bt.get(id);
+ if (!bleDevice) {
+ // Try to recover permitted devices
+ const getDevices = (
+ navigator.bluetooth as Navigator["bluetooth"] & {
+ getDevices?: () => Promise;
+ }
+ ).getDevices;
+
+ if (getDevices) {
+ const known = await getDevices();
+ if (known && known.length > 0 && conn.deviceId) {
+ bleDevice = known.find(
+ (d: BluetoothDevice) => d.id === conn.deviceId,
+ );
+ // If found, store it for future use
+ if (bleDevice) {
+ live.current.bt.set(id, bleDevice);
+ }
+ }
+ }
+ }
+ if (!bleDevice && opts?.allowPrompt) {
+ // Prompt user to reselect (filter by optional service if provided)
+ bleDevice = await navigator.bluetooth.requestDevice({
+ acceptAllDevices: !conn.gattServiceUUID,
+ optionalServices: conn.gattServiceUUID
+ ? [conn.gattServiceUUID]
+ : undefined,
+ filters: conn.gattServiceUUID
+ ? [{ services: [conn.gattServiceUUID] }]
+ : undefined,
+ });
+ }
+ if (!bleDevice) {
+ throw new Error(
+ "Bluetooth device not available. Re-select the device.",
+ );
+ }
+ live.current.bt.set(id, bleDevice);
+
+ const transport =
+ await TransportWebBluetooth.createFromDevice(bleDevice);
+ setupMeshDevice(id, transport, { setHeartbeat: true });
+
+ bleDevice.addEventListener("gattserverdisconnected", () => {
+ updateStatus(id, "disconnected");
+ });
+
+ updateStatus(id, "connected");
+ return true;
+ }
+
+ if (conn.type === "serial") {
+ if (!("serial" in navigator)) {
+ throw new Error("Web Serial not supported");
+ }
+ let port = live.current.serial.get(id);
+ if (!port) {
+ // Find a previously granted port by vendor/product
+ const ports: SerialPort[] = await (
+ navigator as Navigator & {
+ serial: { getPorts: () => Promise };
+ }
+ ).serial.getPorts();
+ if (ports && conn.usbVendorId && conn.usbProductId) {
+ port = ports.find((p: SerialPort) => {
+ const info =
+ (
+ p as SerialPort & {
+ getInfo?: () => {
+ usbVendorId?: number;
+ usbProductId?: number;
+ };
+ }
+ ).getInfo?.() ?? {};
+ return (
+ info.usbVendorId === conn.usbVendorId &&
+ info.usbProductId === conn.usbProductId
+ );
+ });
+ }
+ }
+ if (!port && opts?.allowPrompt) {
+ port = await (
+ navigator as Navigator & {
+ serial: {
+ requestPort: (
+ options: Record,
+ ) => Promise;
+ };
+ }
+ ).serial.requestPort({});
+ }
+ if (!port) {
+ throw new Error("Serial port not available. Re-select the port.");
+ }
+
+ // Ensure the port is closed before opening it
+ const portWithStreams = port as SerialPort & {
+ readable: ReadableStream | null;
+ writable: WritableStream | null;
+ close: () => Promise;
+ };
+ if (portWithStreams.readable || portWithStreams.writable) {
+ try {
+ await portWithStreams.close();
+ await new Promise((resolve) => setTimeout(resolve, 100));
+ } catch (err) {
+ console.warn("Error closing port before reconnect:", err);
+ }
+ }
+
+ live.current.serial.set(id, port);
+
+ const transport = await TransportWebSerial.createFromPort(port);
+ setupMeshDevice(id, transport, { setHeartbeat: true });
+ updateStatus(id, "connected");
+ return true;
+ }
+ } catch (err: unknown) {
+ const message = err instanceof Error ? err.message : String(err);
+ updateStatus(id, "error", message);
+ return false;
+ }
+ return false;
+ },
+ [connections, updateStatus, setupMeshDevice],
+ );
+
+ const disconnect = useCallback(
+ async (id: ConnectionId) => {
+ const conn = connections.find((c) => c.id === id);
+ if (!conn) {
+ return;
+ }
+ try {
+ // Disconnect MeshDevice first
+ const meshDevice = live.current.meshDevices.get(id);
+ if (meshDevice) {
+ try {
+ meshDevice.disconnect();
+ } catch {
+ // Ignore errors
+ }
+ live.current.meshDevices.delete(id);
+ }
+
+ if (conn.type === "bluetooth") {
+ const dev = live.current.bt.get(id);
+ if (dev?.gatt?.connected) {
+ dev.gatt.disconnect();
+ }
+ }
+ if (conn.type === "serial") {
+ const port = live.current.serial.get(id);
+ if (port) {
+ try {
+ const portWithClose = port as SerialPort & {
+ close: () => Promise;
+ readable: ReadableStream | null;
+ };
+ // Only close if the port is open (has readable stream)
+ if (portWithClose.readable) {
+ await portWithClose.close();
+ }
+ } catch (err) {
+ console.warn("Error closing serial port:", err);
+ }
+ live.current.serial.delete(id);
+ }
+ }
+ } finally {
+ updateSavedConnection(id, {
+ status: "disconnected",
+ error: undefined,
+ });
+ }
+ },
+ [connections, updateSavedConnection],
+ );
+
+ const addConnection = useCallback(
+ (input: NewConnection) => {
+ const conn = createConnectionFromInput(input);
+ addSavedConnection(conn);
+ return conn;
+ },
+ [addSavedConnection],
+ );
+
+ const addConnectionAndConnect = useCallback(
+ async (input: NewConnection, btDevice?: BluetoothDevice) => {
+ const conn = addConnection(input);
+ // If a Bluetooth device was provided, store it to avoid re-prompting
+ if (btDevice && conn.type === "bluetooth") {
+ live.current.bt.set(conn.id, btDevice);
+ }
+ await connect(conn.id, { allowPrompt: true });
+ // Get updated connection from store after connect
+ if (conn.id) {
+ return conn;
+ }
+ },
+ [addConnection, connect],
+ );
+
+ const refreshStatuses = useCallback(async () => {
+ // Check reachability/availability without auto-connecting
+ // HTTP: test endpoint reachability
+ // Bluetooth/Serial: check permission grants
+
+ // HTTP connections: test reachability if not already connected
+ const httpChecks = connections
+ .filter(
+ (c): c is Connection & { type: "http"; url: string } =>
+ c.type === "http" && c.status !== "connected",
+ )
+ .map(async (c) => {
+ const ok = await testHttpReachable(c.url);
+ updateSavedConnection(c.id, {
+ status: ok ? "online" : "error",
+ });
+ });
+
+ // Bluetooth connections: check permission grants
+ const btChecks = connections
+ .filter(
+ (c): c is Connection & { type: "bluetooth"; deviceId?: string } =>
+ c.type === "bluetooth" && c.status !== "connected",
+ )
+ .map(async (c) => {
+ if (!("bluetooth" in navigator)) {
+ return;
+ }
+ try {
+ const known = await (
+ navigator.bluetooth as Navigator["bluetooth"] & {
+ getDevices?: () => Promise;
+ }
+ ).getDevices?.();
+ const hasPermission = known?.some(
+ (d: BluetoothDevice) => d.id === c.deviceId,
+ );
+ updateSavedConnection(c.id, {
+ status: hasPermission ? "configured" : "disconnected",
+ });
+ } catch {
+ // getDevices not supported or failed
+ updateSavedConnection(c.id, { status: "disconnected" });
+ }
+ });
+
+ // Serial connections: check permission grants
+ const serialChecks = connections
+ .filter(
+ (
+ c,
+ ): c is Connection & {
+ type: "serial";
+ usbVendorId?: number;
+ usbProductId?: number;
+ } => c.type === "serial" && c.status !== "connected",
+ )
+ .map(async (c) => {
+ if (!("serial" in navigator)) {
+ return;
+ }
+ try {
+ const ports: SerialPort[] = await (
+ navigator as Navigator & {
+ serial: { getPorts: () => Promise };
+ }
+ ).serial.getPorts();
+ const hasPermission = ports.some((p: SerialPort) => {
+ const info =
+ (
+ p as SerialPort & {
+ getInfo?: () => {
+ usbVendorId?: number;
+ usbProductId?: number;
+ };
+ }
+ ).getInfo?.() ?? {};
+ return (
+ info.usbVendorId === c.usbVendorId &&
+ info.usbProductId === c.usbProductId
+ );
+ });
+ updateSavedConnection(c.id, {
+ status: hasPermission ? "configured" : "disconnected",
+ });
+ } catch {
+ // getPorts failed
+ updateSavedConnection(c.id, { status: "disconnected" });
+ }
+ });
+
+ await Promise.all([...httpChecks, ...btChecks, ...serialChecks]);
+ }, [connections, updateSavedConnection]);
+
+ return {
+ connections,
+ addConnection,
+ addConnectionAndConnect,
+ connect,
+ disconnect,
+ removeConnection,
+ setDefaultConnection,
+ refreshStatuses,
+ };
+}
diff --git a/packages/web/src/pages/Connections/utils.ts b/packages/web/src/pages/Connections/utils.ts
new file mode 100644
index 00000000..d55d9fd2
--- /dev/null
+++ b/packages/web/src/pages/Connections/utils.ts
@@ -0,0 +1,84 @@
+import type {
+ Connection,
+ ConnectionStatus,
+ ConnectionType,
+ NewConnection,
+} from "@app/core/stores/deviceStore/types";
+import { randId } from "@app/core/utils/randId";
+import { Bluetooth, Cable, Globe, type LucideIcon } from "lucide-react";
+
+export function createConnectionFromInput(input: NewConnection): Connection {
+ const base = {
+ id: randId(),
+ name: input.name,
+ createdAt: Date.now(),
+ status: "disconnected" as ConnectionStatus,
+ };
+ if (input.type === "http") {
+ return {
+ ...base,
+ type: "http",
+ url: input.url,
+ isDefault: false,
+ name: input.name.length === 0 ? input.url : input.name,
+ };
+ }
+ if (input.type === "bluetooth") {
+ return {
+ ...base,
+ type: "bluetooth",
+ deviceId: input.deviceId,
+ deviceName: input.deviceName,
+ gattServiceUUID: input.gattServiceUUID,
+ };
+ }
+ return {
+ ...base,
+ type: "serial",
+ usbVendorId: input.usbVendorId,
+ usbProductId: input.usbProductId,
+ };
+}
+
+export async function testHttpReachable(
+ url: string,
+ timeoutMs = 2500,
+): Promise {
+ try {
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
+ // Use no-cors to avoid CORS failure; opaque responses resolve but status is 0
+ await fetch(url, {
+ method: "GET",
+ mode: "no-cors",
+ cache: "no-store",
+ signal: controller.signal,
+ });
+ clearTimeout(timer);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+export function connectionTypeIcon(type: ConnectionType): LucideIcon {
+ if (type === "http") {
+ return Globe;
+ }
+ if (type === "bluetooth") {
+ return Bluetooth;
+ }
+ return Cable;
+}
+
+export function formatConnectionSubtext(conn: Connection): string {
+ if (conn.type === "http") {
+ return conn.url;
+ }
+ if (conn.type === "bluetooth") {
+ return conn.deviceName || conn.deviceId || "No device selected";
+ }
+ const v = conn.usbVendorId ? conn.usbVendorId.toString(16) : "?";
+ const p = conn.usbProductId ? conn.usbProductId.toString(16) : "?";
+ return `USB ${v}:${p}`;
+}
diff --git a/packages/web/src/pages/Dashboard/index.tsx b/packages/web/src/pages/Dashboard/index.tsx
deleted file mode 100644
index e075452b..00000000
--- a/packages/web/src/pages/Dashboard/index.tsx
+++ /dev/null
@@ -1,93 +0,0 @@
-import LanguageSwitcher from "@components/LanguageSwitcher.tsx";
-import { Button } from "@components/UI/Button.tsx";
-import { Separator } from "@components/UI/Separator.tsx";
-import { Heading } from "@components/UI/Typography/Heading.tsx";
-import { Subtle } from "@components/UI/Typography/Subtle.tsx";
-import {
- useAppStore /*, useDeviceStore, useNodeDBStore */,
-} from "@core/stores";
-import { ListPlusIcon, PlusIcon /*, UsersIcon */ } from "lucide-react";
-/* import { useMemo } from "react"; */
-import { useTranslation } from "react-i18next";
-
-export const Dashboard = () => {
- const { t } = useTranslation("dashboard");
- const { setConnectDialogOpen /*, setSelectedDevice*/ } = useAppStore();
- /*
- const { getDevices } = useDeviceStore();
- const { getNodeDB } = useNodeDBStore();
- const devices = useMemo(() => getDevices(), [getDevices]);
- */
-
- return (
-
-
-
- {t("dashboard.title")}
- {t("dashboard.description")}
-
-
-
-
-
-
-
- {
- /*devices.length ? (
-
- {devices.map((device) => {
- const nodeDB = getNodeDB(device.id);
- if (!nodeDB) {
- return undefined;
- }
-
- return (
- -
-
-
- );
- })}
-
- ) : */
-
-
{t("dashboard.noDevicesTitle")}
-
{t("dashboard.noDevicesDescription")}
-
-
- }
-
-
- );
-};
diff --git a/packages/web/src/pages/Nodes/index.tsx b/packages/web/src/pages/Nodes/index.tsx
index 7be9a7b8..2fcf3853 100644
--- a/packages/web/src/pages/Nodes/index.tsx
+++ b/packages/web/src/pages/Nodes/index.tsx
@@ -40,7 +40,7 @@ export interface DeleteNoteDialogProps {
const NodesPage = (): JSX.Element => {
const { t } = useTranslation("nodes");
- const { currentLanguage } = useLang();
+ const { current } = useLang();
const { hardware, connection, setDialogOpen } = useDevice();
const { setNodeNumDetails } = useAppStore();
@@ -200,7 +200,7 @@ const NodesPage = (): JSX.Element => {
) : (
)}
diff --git a/packages/web/src/routes.tsx b/packages/web/src/routes.tsx
index 11fb4edc..e32a47d6 100644
--- a/packages/web/src/routes.tsx
+++ b/packages/web/src/routes.tsx
@@ -1,6 +1,6 @@
import { DialogManager } from "@components/Dialog/DialogManager.tsx";
import type { useAppStore, useMessageStore } from "@core/stores";
-import { Dashboard } from "@pages/Dashboard/index.tsx";
+import { Connections } from "@pages/Connections/index.tsx";
import MapPage from "@pages/Map/index.tsx";
import MessagesPage from "@pages/Messages.tsx";
import NodesPage from "@pages/Nodes/index.tsx";
@@ -30,7 +30,7 @@ export const rootRoute = createRootRouteWithContext()({
const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/",
- component: Dashboard,
+ component: Connections,
loader: () => {
// Redirect to the broadcast messages page on initial load
return redirect({ to: "/messages/broadcast/0", replace: true });
@@ -151,6 +151,12 @@ const dialogWithParamsRoute = createRoute({
component: DialogManager,
});
+const connectionsRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: "/connections",
+ component: Connections,
+});
+
const routeTree = rootRoute.addChildren([
indexRoute,
messagesRoute,
@@ -160,6 +166,7 @@ const routeTree = rootRoute.addChildren([
settingsRoute.addChildren([radioRoute, deviceRoute, moduleRoute]),
nodesRoute,
dialogWithParamsRoute,
+ connectionsRoute,
]);
const router = createRouter({
diff --git a/packages/web/src/tests/setup.ts b/packages/web/src/tests/setup.ts
index 8d8b72f4..9763e8c6 100644
--- a/packages/web/src/tests/setup.ts
+++ b/packages/web/src/tests/setup.ts
@@ -15,7 +15,7 @@ import commonEN from "@public/i18n/locales/en/common.json" with {
import configEN from "@public/i18n/locales/en/config.json" with {
type: "json",
};
-import dashboardEN from "@public/i18n/locales/en/dashboard.json" with {
+import connectionsEN from "@public/i18n/locales/en/connections.json" with {
type: "json",
};
import dialogEN from "@public/i18n/locales/en/dialog.json" with {
@@ -55,7 +55,7 @@ const appNamespaces = [
"common",
"config",
"moduleConfig",
- "dashboard",
+ "connections",
"dialog",
"messages",
"nodes",
@@ -78,7 +78,7 @@ i18n.use(initReactI18next).init({
common: commonEN,
config: configEN,
moduleConfig: moduleConfigEN,
- dashboard: dashboardEN,
+ connections: connectionsEN,
dialog: dialogEN,
messages: messagesEN,
nodes: nodesEN,
diff --git a/packages/web/src/validation/config/user.ts b/packages/web/src/validation/config/user.ts
index db5109a7..59fac860 100644
--- a/packages/web/src/validation/config/user.ts
+++ b/packages/web/src/validation/config/user.ts
@@ -4,12 +4,12 @@ import { z } from "zod/v4";
export const UserValidationSchema = z.object({
longName: z
.string()
- .min(1, t("deviceName.validation.longNameMin"))
- .max(40, t("deviceName.validation.longNameMax")),
+ .min(1, t("user.longName.validation.min"))
+ .max(40, t("user.longName.validation.max")),
shortName: z
.string()
- .min(2, t("deviceName.validation.shortNameMin"))
- .max(4, t("deviceName.validation.shortNameMax")),
+ .min(2, t("user.shortName.validation.min"))
+ .max(4, t("user.shortName.validation.max")),
isUnmessageable: z.boolean().default(false),
isLicensed: z.boolean().default(false),
});
diff --git a/packages/web/vite.config.ts b/packages/web/vite.config.ts
index 70768672..6b0488d9 100644
--- a/packages/web/vite.config.ts
+++ b/packages/web/vite.config.ts
@@ -2,6 +2,7 @@ import { execSync } from "node:child_process";
import path from "node:path";
import process from "node:process";
import tailwindcss from "@tailwindcss/vite";
+import basicSsl from "@vitejs/plugin-basic-ssl";
import react from "@vitejs/plugin-react";
import { defineConfig, loadEnv } from "vite";
import { createHtmlPlugin } from "vite-plugin-html";
@@ -32,19 +33,21 @@ export default defineConfig(({ mode }) => {
const isProd = mode === "production";
const isTest = env.VITE_IS_TEST;
+ const useHTTPS = env.VITE_USE_HTTPS === "true";
return {
plugins: [
react(),
tailwindcss(),
- // This is for GDPR/CCPA compliance
+ ...(useHTTPS ? [basicSsl()] : []),
createHtmlPlugin({
inject: {
data: {
title: isTest ? "Meshtastic Web (TEST)" : "Meshtastic Web",
cookieYesScript:
isProd && env.VITE_COOKIEYES_CLIENT_ID
- ? ``
+ ? // This is for GDPR/CCPA compliance
+ ``
: "",
},
},
@@ -76,7 +79,7 @@ export default defineConfig(({ mode }) => {
server: {
port: 3000,
headers: {
- "content-security-policy": CONTENT_SECURITY_POLICY,
+ "Content-Security-Policy": CONTENT_SECURITY_POLICY,
"Cross-Origin-Opener-Policy": "same-origin",
"Cross-Origin-Embedder-Policy": "credentialless",
"X-Content-Type-Options": "nosniff",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index bc0c2403..e80ddc39 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -202,6 +202,9 @@ importers:
'@radix-ui/react-accordion':
specifier: ^1.2.12
version: 1.2.12(@types/react-dom@19.2.0(@types/react@19.2.1))(@types/react@19.2.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ '@radix-ui/react-alert-dialog':
+ specifier: ^1.1.15
+ version: 1.1.15(@types/react-dom@19.2.0(@types/react@19.2.1))(@types/react@19.2.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@radix-ui/react-checkbox':
specifier: ^1.3.3
version: 1.3.3(@types/react-dom@19.2.0(@types/react@19.2.1))(@types/react@19.2.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
@@ -386,6 +389,9 @@ importers:
'@types/w3c-web-serial':
specifier: ^1.0.8
version: 1.0.8
+ '@vitejs/plugin-basic-ssl':
+ specifier: ^2.1.0
+ version: 2.1.0(vite@7.1.11(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3))
'@vitejs/plugin-react':
specifier: ^5.0.4
version: 5.0.4(vite@7.1.11(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3))
@@ -1507,8 +1513,8 @@ packages:
'@microsoft/tsdoc@0.15.1':
resolution: {integrity: sha512-4aErSrCR/On/e5G2hDP0wjooqDdauzEbIq8hIkIe5pXV0rtWJZvdCEKL0ykZxex+IxIwBp0eGeV48hQN07dXtw==}
- '@napi-rs/wasm-runtime@1.0.7':
- resolution: {integrity: sha512-SeDnOO0Tk7Okiq6DbXmmBODgOAb9dp9gjlphokTUxmt8U3liIP1ZsozBahH69j/RJv+Rfs6IwUKHTgQYJ/HBAw==}
+ '@napi-rs/wasm-runtime@0.2.12':
+ resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==}
'@noble/curves@1.9.6':
resolution: {integrity: sha512-GIKz/j99FRthB8icyJQA51E8Uk5hXmdyThjgQXRKiv9h0zeRlzSCLIzFw6K1LotZ3XuB7yzlf76qk7uBmTdFqA==}
@@ -1530,8 +1536,12 @@ packages:
resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
engines: {node: '>= 8'}
- '@oxc-project/types@0.95.0':
- resolution: {integrity: sha512-vACy7vhpMPhjEJhULNxrdR0D943TkA/MigMpJCHmBHvMXxRStRi/dPtTlfQ3uDwWSzRpT8z+7ImjZVf8JWBocQ==}
+ '@oxc-project/runtime@0.71.0':
+ resolution: {integrity: sha512-QwoF5WUXIGFQ+hSxWEib4U/aeLoiDN9JlP18MnBgx9LLPRDfn1iICtcow7Jgey6HLH4XFceWXQD5WBJ39dyJcw==}
+ engines: {node: '>=6.9.0'}
+
+ '@oxc-project/types@0.71.0':
+ resolution: {integrity: sha512-5CwQ4MI+P4MQbjLWXgNurA+igGwu/opNetIE13LBs9+V93R64MLvDKOOLZIXSzEfovU3Zef3q3GjPnMTgJTn2w==}
'@publint/pack@0.1.2':
resolution: {integrity: sha512-S+9ANAvUmjutrshV4jZjaiG8XQyuJIZ8a4utWmN/vW1sgQ9IfBnPndwkmQYw53QmouOIytT874u65HEmu6H5jw==}
@@ -1559,6 +1569,19 @@ packages:
'@types/react-dom':
optional: true
+ '@radix-ui/react-alert-dialog@1.1.15':
+ resolution: {integrity: sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
'@radix-ui/react-arrow@1.1.7':
resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==}
peerDependencies:
@@ -2052,90 +2075,67 @@ packages:
'@radix-ui/rect@1.1.1':
resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==}
- '@rolldown/binding-android-arm64@1.0.0-beta.45':
- resolution: {integrity: sha512-bfgKYhFiXJALeA/riil908+2vlyWGdwa7Ju5S+JgWZYdR4jtiPOGdM6WLfso1dojCh+4ZWeiTwPeV9IKQEX+4g==}
- engines: {node: ^20.19.0 || >=22.12.0}
- cpu: [arm64]
- os: [android]
-
- '@rolldown/binding-darwin-arm64@1.0.0-beta.45':
- resolution: {integrity: sha512-xjCv4CRVsSnnIxTuyH1RDJl5OEQ1c9JYOwfDAHddjJDxCw46ZX9q80+xq7Eok7KC4bRSZudMJllkvOKv0T9SeA==}
- engines: {node: ^20.19.0 || >=22.12.0}
+ '@rolldown/binding-darwin-arm64@1.0.0-beta.9-commit.d91dfb5':
+ resolution: {integrity: sha512-Mp0/gqiPdepHjjVm7e0yL1acWvI0rJVVFQEADSezvAjon9sjQ7CEg9JnXICD4B1YrPmN9qV/e7cQZCp87tTV4w==}
cpu: [arm64]
os: [darwin]
- '@rolldown/binding-darwin-x64@1.0.0-beta.45':
- resolution: {integrity: sha512-ddcO9TD3D/CLUa/l8GO8LHzBOaZqWg5ClMy3jICoxwCuoz47h9dtqPsIeTiB6yR501LQTeDsjA4lIFd7u3Ljfw==}
- engines: {node: ^20.19.0 || >=22.12.0}
+ '@rolldown/binding-darwin-x64@1.0.0-beta.9-commit.d91dfb5':
+ resolution: {integrity: sha512-40re4rMNrsi57oavRzIOpRGmg3QRlW6Ea8Q3znaqgOuJuKVrrm2bIQInTfkZJG7a4/5YMX7T951d0+toGLTdCA==}
cpu: [x64]
os: [darwin]
- '@rolldown/binding-freebsd-x64@1.0.0-beta.45':
- resolution: {integrity: sha512-MBTWdrzW9w+UMYDUvnEuh0pQvLENkl2Sis15fHTfHVW7ClbGuez+RWopZudIDEGkpZXdeI4CkRXk+vdIIebrmg==}
- engines: {node: ^20.19.0 || >=22.12.0}
+ '@rolldown/binding-freebsd-x64@1.0.0-beta.9-commit.d91dfb5':
+ resolution: {integrity: sha512-8BDM939bbMariZupiHp3OmP5N+LXPT4mULA0hZjDaq970PCxv4krZOSMG+HkWUUwmuQROtV+/00xw39EO0P+8g==}
cpu: [x64]
os: [freebsd]
- '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.45':
- resolution: {integrity: sha512-4YgoCFiki1HR6oSg+GxxfzfnVCesQxLF1LEnw9uXS/MpBmuog0EOO2rYfy69rWP4tFZL9IWp6KEfGZLrZ7aUog==}
- engines: {node: ^20.19.0 || >=22.12.0}
+ '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.9-commit.d91dfb5':
+ resolution: {integrity: sha512-sntsPaPgrECpBB/+2xrQzVUt0r493TMPI+4kWRMhvMsmrxOqH1Ep5lM0Wua/ZdbfZNwm1aVa5pcESQfNfM4Fhw==}
cpu: [arm]
os: [linux]
- '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.45':
- resolution: {integrity: sha512-LE1gjAwQRrbCOorJJ7LFr10s5vqYf5a00V5Ea9wXcT2+56n5YosJkcp8eQ12FxRBv2YX8dsdQJb+ZTtYJwb6XQ==}
- engines: {node: ^20.19.0 || >=22.12.0}
+ '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.9-commit.d91dfb5':
+ resolution: {integrity: sha512-5clBW/I+er9F2uM1OFjJFWX86y7Lcy0M+NqsN4s3o07W+8467Zk8oQa4B45vdaXoNUF/yqIAgKkA/OEdQDxZqA==}
cpu: [arm64]
os: [linux]
libc: [glibc]
- '@rolldown/binding-linux-arm64-musl@1.0.0-beta.45':
- resolution: {integrity: sha512-tdy8ThO/fPp40B81v0YK3QC+KODOmzJzSUOO37DinQxzlTJ026gqUSOM8tzlVixRbQJltgVDCTYF8HNPRErQTA==}
- engines: {node: ^20.19.0 || >=22.12.0}
+ '@rolldown/binding-linux-arm64-musl@1.0.0-beta.9-commit.d91dfb5':
+ resolution: {integrity: sha512-wv+rnAfQDk9p/CheX8/Kmqk2o1WaFa4xhWI9gOyDMk/ljvOX0u0ubeM8nI1Qfox7Tnh71eV5AjzSePXUhFOyOg==}
cpu: [arm64]
os: [linux]
libc: [musl]
- '@rolldown/binding-linux-x64-gnu@1.0.0-beta.45':
- resolution: {integrity: sha512-lS082ROBWdmOyVY/0YB3JmsiClaWoxvC+dA8/rbhyB9VLkvVEaihLEOr4CYmrMse151C4+S6hCw6oa1iewox7g==}
- engines: {node: ^20.19.0 || >=22.12.0}
+ '@rolldown/binding-linux-x64-gnu@1.0.0-beta.9-commit.d91dfb5':
+ resolution: {integrity: sha512-gxD0/xhU4Py47IH3bKZbWtvB99tMkUPGPJFRfSc5UB9Osoje0l0j1PPbxpUtXIELurYCqwLBKXIMTQGifox1BQ==}
cpu: [x64]
os: [linux]
libc: [glibc]
- '@rolldown/binding-linux-x64-musl@1.0.0-beta.45':
- resolution: {integrity: sha512-Hi73aYY0cBkr1/SvNQqH8Cd+rSV6S9RB5izCv0ySBcRnd/Wfn5plguUoGYwBnhHgFbh6cPw9m2dUVBR6BG1gxA==}
- engines: {node: ^20.19.0 || >=22.12.0}
+ '@rolldown/binding-linux-x64-musl@1.0.0-beta.9-commit.d91dfb5':
+ resolution: {integrity: sha512-HotuVe3XUjDwqqEMbm3o3IRkP9gdm8raY/btd/6KE3JGLF/cv4+3ff1l6nOhAZI8wulWDPEXPtE7v+HQEaTXnA==}
cpu: [x64]
os: [linux]
libc: [musl]
- '@rolldown/binding-openharmony-arm64@1.0.0-beta.45':
- resolution: {integrity: sha512-fljEqbO7RHHogNDxYtTzr+GNjlfOx21RUyGmF+NrkebZ8emYYiIqzPxsaMZuRx0rgZmVmliOzEp86/CQFDKhJQ==}
- engines: {node: ^20.19.0 || >=22.12.0}
- cpu: [arm64]
- os: [openharmony]
-
- '@rolldown/binding-wasm32-wasi@1.0.0-beta.45':
- resolution: {integrity: sha512-ZJDB7lkuZE9XUnWQSYrBObZxczut+8FZ5pdanm8nNS1DAo8zsrPuvGwn+U3fwU98WaiFsNrA4XHngesCGr8tEQ==}
- engines: {node: '>=14.0.0'}
+ '@rolldown/binding-wasm32-wasi@1.0.0-beta.9-commit.d91dfb5':
+ resolution: {integrity: sha512-8Cx+ucbd8n2dIr21FqBh6rUvTVL0uTgEtKR7l+MUZ5BgY4dFh1e4mPVX8oqmoYwOxBiXrsD2JIOCz4AyKLKxWA==}
+ engines: {node: '>=14.21.3'}
cpu: [wasm32]
- '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.45':
- resolution: {integrity: sha512-zyzAjItHPUmxg6Z8SyRhLdXlJn3/D9KL5b9mObUrBHhWS/GwRH4665xCiFqeuktAhhWutqfc+rOV2LjK4VYQGQ==}
- engines: {node: ^20.19.0 || >=22.12.0}
+ '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.9-commit.d91dfb5':
+ resolution: {integrity: sha512-Vhq5vikrVDxAa75fxsyqj0c0Y/uti/TwshXI71Xb8IeUQJOBnmLUsn5dgYf5ljpYYkNa0z9BPAvUDIDMmyDi+w==}
cpu: [arm64]
os: [win32]
- '@rolldown/binding-win32-ia32-msvc@1.0.0-beta.45':
- resolution: {integrity: sha512-wODcGzlfxqS6D7BR0srkJk3drPwXYLu7jPHN27ce2c4PUnVVmJnp9mJzUQGT4LpmHmmVdMZ+P6hKvyTGBzc1CA==}
- engines: {node: ^20.19.0 || >=22.12.0}
+ '@rolldown/binding-win32-ia32-msvc@1.0.0-beta.9-commit.d91dfb5':
+ resolution: {integrity: sha512-lN7RIg9Iugn08zP2aZN9y/MIdG8iOOCE93M1UrFlrxMTqPf8X+fDzmR/OKhTSd1A2pYNipZHjyTcb5H8kyQSow==}
cpu: [ia32]
os: [win32]
- '@rolldown/binding-win32-x64-msvc@1.0.0-beta.45':
- resolution: {integrity: sha512-wiU40G1nQo9rtfvF9jLbl79lUgjfaD/LTyUEw2Wg/gdF5OhjzpKMVugZQngO+RNdwYaNj+Fs+kWBWfp4VXPMHA==}
- engines: {node: ^20.19.0 || >=22.12.0}
+ '@rolldown/binding-win32-x64-msvc@1.0.0-beta.9-commit.d91dfb5':
+ resolution: {integrity: sha512-7/7cLIn48Y+EpQ4CePvf8reFl63F15yPUlg4ZAhl+RXJIfydkdak1WD8Ir3AwAO+bJBXzrfNL+XQbxm0mcQZmw==}
cpu: [x64]
os: [win32]
@@ -2145,8 +2145,8 @@ packages:
'@rolldown/pluginutils@1.0.0-beta.38':
resolution: {integrity: sha512-N/ICGKleNhA5nc9XXQG/kkKHJ7S55u0x0XUJbbkmdCnFuoRkM1Il12q9q0eX19+M7KKUEPw/daUPIRnxhcxAIw==}
- '@rolldown/pluginutils@1.0.0-beta.45':
- resolution: {integrity: sha512-Le9ulGCrD8ggInzWw/k2J8QcbPz7eGIOWqfJ2L+1R0Opm7n6J37s2hiDWlh6LJN0Lk9L5sUzMvRHKW7UxBZsQA==}
+ '@rolldown/pluginutils@1.0.0-beta.9-commit.d91dfb5':
+ resolution: {integrity: sha512-8sExkWRK+zVybw3+2/kBkYBFeLnEUWz1fT7BLHplpzmtqkOfTbAQ9gkt4pzwGIIZmg4Qn5US5ACjUBenrhezwQ==}
'@rollup/plugin-babel@5.3.1':
resolution: {integrity: sha512-WFfdLWU/xVWKeRQnKmIAQULUI7Il0gZnBIH/ZFO069wYIfPu+8zrfp/KMW0atmELoRDq8FbiP3VCss9MhCut7Q==}
@@ -3209,6 +3209,12 @@ packages:
maplibre-gl:
optional: true
+ '@vitejs/plugin-basic-ssl@2.1.0':
+ resolution: {integrity: sha512-dOxxrhgyDIEUADhb/8OlV9JIqYLgos03YorAueTIeOUskLJSEsfwCByjbu98ctXitUN3znXKp0bYD/WHSudCeA==}
+ engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
+ peerDependencies:
+ vite: ^6.0.0 || ^7.0.0
+
'@vitejs/plugin-react@4.7.0':
resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==}
engines: {node: ^14.18.0 || >=16.0.0}
@@ -5057,9 +5063,8 @@ packages:
vue-tsc:
optional: true
- rolldown@1.0.0-beta.45:
- resolution: {integrity: sha512-iMmuD72XXLf26Tqrv1cryNYLX6NNPLhZ3AmNkSf8+xda0H+yijjGJ+wVT9UdBUHOpKzq9RjKtQKRCWoEKQQBZQ==}
- engines: {node: ^20.19.0 || >=22.12.0}
+ rolldown@1.0.0-beta.9-commit.d91dfb5:
+ resolution: {integrity: sha512-FHkj6gGEiEgmAXQchglofvUUdwj2Oiw603Rs+zgFAnn9Cb7T7z3fiaEc0DbN3ja4wYkW6sF2rzMEtC1V4BGx/g==}
hasBin: true
rollup@2.79.2:
@@ -7092,7 +7097,7 @@ snapshots:
'@microsoft/tsdoc@0.15.1': {}
- '@napi-rs/wasm-runtime@1.0.7':
+ '@napi-rs/wasm-runtime@0.2.12':
dependencies:
'@emnapi/core': 1.5.0
'@emnapi/runtime': 1.5.0
@@ -7117,7 +7122,9 @@ snapshots:
'@nodelib/fs.scandir': 2.1.5
fastq: 1.19.1
- '@oxc-project/types@0.95.0': {}
+ '@oxc-project/runtime@0.71.0': {}
+
+ '@oxc-project/types@0.71.0': {}
'@publint/pack@0.1.2': {}
@@ -7146,6 +7153,20 @@ snapshots:
'@types/react': 19.2.1
'@types/react-dom': 19.2.0(@types/react@19.2.1)
+ '@radix-ui/react-alert-dialog@1.1.15(@types/react-dom@19.2.0(@types/react@19.2.1))(@types/react@19.2.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
+ dependencies:
+ '@radix-ui/primitive': 1.1.3
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.1)(react@19.2.0)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.1)(react@19.2.0)
+ '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.0(@types/react@19.2.1))(@types/react@19.2.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.0(@types/react@19.2.1))(@types/react@19.2.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ '@radix-ui/react-slot': 1.2.3(@types/react@19.2.1)(react@19.2.0)
+ react: 19.2.0
+ react-dom: 19.2.0(react@19.2.0)
+ optionalDependencies:
+ '@types/react': 19.2.1
+ '@types/react-dom': 19.2.0(@types/react@19.2.1)
+
'@radix-ui/react-arrow@1.1.7(@types/react-dom@18.3.7(@types/react@18.3.26))(@types/react@18.3.26)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
dependencies:
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.26))(@types/react@18.3.26)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
@@ -7986,55 +8007,49 @@ snapshots:
'@radix-ui/rect@1.1.1': {}
- '@rolldown/binding-android-arm64@1.0.0-beta.45':
+ '@rolldown/binding-darwin-arm64@1.0.0-beta.9-commit.d91dfb5':
optional: true
- '@rolldown/binding-darwin-arm64@1.0.0-beta.45':
+ '@rolldown/binding-darwin-x64@1.0.0-beta.9-commit.d91dfb5':
optional: true
- '@rolldown/binding-darwin-x64@1.0.0-beta.45':
+ '@rolldown/binding-freebsd-x64@1.0.0-beta.9-commit.d91dfb5':
optional: true
- '@rolldown/binding-freebsd-x64@1.0.0-beta.45':
+ '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.9-commit.d91dfb5':
optional: true
- '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.45':
+ '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.9-commit.d91dfb5':
optional: true
- '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.45':
+ '@rolldown/binding-linux-arm64-musl@1.0.0-beta.9-commit.d91dfb5':
optional: true
- '@rolldown/binding-linux-arm64-musl@1.0.0-beta.45':
+ '@rolldown/binding-linux-x64-gnu@1.0.0-beta.9-commit.d91dfb5':
optional: true
- '@rolldown/binding-linux-x64-gnu@1.0.0-beta.45':
+ '@rolldown/binding-linux-x64-musl@1.0.0-beta.9-commit.d91dfb5':
optional: true
- '@rolldown/binding-linux-x64-musl@1.0.0-beta.45':
- optional: true
-
- '@rolldown/binding-openharmony-arm64@1.0.0-beta.45':
- optional: true
-
- '@rolldown/binding-wasm32-wasi@1.0.0-beta.45':
+ '@rolldown/binding-wasm32-wasi@1.0.0-beta.9-commit.d91dfb5':
dependencies:
- '@napi-rs/wasm-runtime': 1.0.7
+ '@napi-rs/wasm-runtime': 0.2.12
optional: true
- '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.45':
+ '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.9-commit.d91dfb5':
optional: true
- '@rolldown/binding-win32-ia32-msvc@1.0.0-beta.45':
+ '@rolldown/binding-win32-ia32-msvc@1.0.0-beta.9-commit.d91dfb5':
optional: true
- '@rolldown/binding-win32-x64-msvc@1.0.0-beta.45':
+ '@rolldown/binding-win32-x64-msvc@1.0.0-beta.9-commit.d91dfb5':
optional: true
'@rolldown/pluginutils@1.0.0-beta.27': {}
'@rolldown/pluginutils@1.0.0-beta.38': {}
- '@rolldown/pluginutils@1.0.0-beta.45': {}
+ '@rolldown/pluginutils@1.0.0-beta.9-commit.d91dfb5': {}
'@rollup/plugin-babel@5.3.1(@babel/core@7.28.4)(@types/babel__core@7.20.5)(rollup@2.79.2)':
dependencies:
@@ -9844,6 +9859,10 @@ snapshots:
optionalDependencies:
maplibre-gl: 5.8.0
+ '@vitejs/plugin-basic-ssl@2.1.0(vite@7.1.11(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3))':
+ dependencies:
+ vite: 7.1.11(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)
+
'@vitejs/plugin-react@4.7.0(vite@7.1.11(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3))':
dependencies:
'@babel/core': 7.28.4
@@ -11728,7 +11747,7 @@ snapshots:
robust-predicates@3.0.2: {}
- rolldown-plugin-dts@0.16.3(rolldown@1.0.0-beta.45)(typescript@5.9.2):
+ rolldown-plugin-dts@0.16.3(rolldown@1.0.0-beta.9-commit.d91dfb5)(typescript@5.9.2):
dependencies:
'@babel/generator': 7.28.3
'@babel/parser': 7.28.4
@@ -11738,32 +11757,32 @@ snapshots:
debug: 4.4.3
dts-resolver: 2.1.2
get-tsconfig: 4.10.1
- rolldown: 1.0.0-beta.45
+ rolldown: 1.0.0-beta.9-commit.d91dfb5
optionalDependencies:
typescript: 5.9.2
transitivePeerDependencies:
- oxc-resolver
- supports-color
- rolldown@1.0.0-beta.45:
+ rolldown@1.0.0-beta.9-commit.d91dfb5:
dependencies:
- '@oxc-project/types': 0.95.0
- '@rolldown/pluginutils': 1.0.0-beta.45
+ '@oxc-project/runtime': 0.71.0
+ '@oxc-project/types': 0.71.0
+ '@rolldown/pluginutils': 1.0.0-beta.9-commit.d91dfb5
+ ansis: 4.2.0
optionalDependencies:
- '@rolldown/binding-android-arm64': 1.0.0-beta.45
- '@rolldown/binding-darwin-arm64': 1.0.0-beta.45
- '@rolldown/binding-darwin-x64': 1.0.0-beta.45
- '@rolldown/binding-freebsd-x64': 1.0.0-beta.45
- '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-beta.45
- '@rolldown/binding-linux-arm64-gnu': 1.0.0-beta.45
- '@rolldown/binding-linux-arm64-musl': 1.0.0-beta.45
- '@rolldown/binding-linux-x64-gnu': 1.0.0-beta.45
- '@rolldown/binding-linux-x64-musl': 1.0.0-beta.45
- '@rolldown/binding-openharmony-arm64': 1.0.0-beta.45
- '@rolldown/binding-wasm32-wasi': 1.0.0-beta.45
- '@rolldown/binding-win32-arm64-msvc': 1.0.0-beta.45
- '@rolldown/binding-win32-ia32-msvc': 1.0.0-beta.45
- '@rolldown/binding-win32-x64-msvc': 1.0.0-beta.45
+ '@rolldown/binding-darwin-arm64': 1.0.0-beta.9-commit.d91dfb5
+ '@rolldown/binding-darwin-x64': 1.0.0-beta.9-commit.d91dfb5
+ '@rolldown/binding-freebsd-x64': 1.0.0-beta.9-commit.d91dfb5
+ '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-beta.9-commit.d91dfb5
+ '@rolldown/binding-linux-arm64-gnu': 1.0.0-beta.9-commit.d91dfb5
+ '@rolldown/binding-linux-arm64-musl': 1.0.0-beta.9-commit.d91dfb5
+ '@rolldown/binding-linux-x64-gnu': 1.0.0-beta.9-commit.d91dfb5
+ '@rolldown/binding-linux-x64-musl': 1.0.0-beta.9-commit.d91dfb5
+ '@rolldown/binding-wasm32-wasi': 1.0.0-beta.9-commit.d91dfb5
+ '@rolldown/binding-win32-arm64-msvc': 1.0.0-beta.9-commit.d91dfb5
+ '@rolldown/binding-win32-ia32-msvc': 1.0.0-beta.9-commit.d91dfb5
+ '@rolldown/binding-win32-x64-msvc': 1.0.0-beta.9-commit.d91dfb5
rollup@2.79.2:
optionalDependencies:
@@ -12228,8 +12247,8 @@ snapshots:
diff: 8.0.2
empathic: 2.0.0
hookable: 5.5.3
- rolldown: 1.0.0-beta.45
- rolldown-plugin-dts: 0.16.3(rolldown@1.0.0-beta.45)(typescript@5.9.2)
+ rolldown: 1.0.0-beta.9-commit.d91dfb5
+ rolldown-plugin-dts: 0.16.3(rolldown@1.0.0-beta.9-commit.d91dfb5)(typescript@5.9.2)
semver: 7.7.2
tinyexec: 1.0.1
tinyglobby: 0.2.15