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 <support+bot@crowdin.com> * 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 <support+bot@crowdin.com> Co-authored-by: Kamil Dzieniszewski <kamil.dzieniszewski@gmail.com> Co-authored-by: Azarattum <43073346+Azarattum@users.noreply.github.com> Co-authored-by: Ben Allfree <ben@benallfree.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
co-authored by
Copilot
github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Crowdin Bot
Kamil Dzieniszewski
Azarattum
Ben Allfree
parent
a1a646983e
commit
1af1295b8f
@@ -11,3 +11,8 @@ npm/
|
|||||||
**/LICENSE
|
**/LICENSE
|
||||||
|
|
||||||
packages/protobufs/packages/ts/dist
|
packages/protobufs/packages/ts/dist
|
||||||
|
|
||||||
|
# Local dev certs
|
||||||
|
*.pem
|
||||||
|
*.crt
|
||||||
|
*.key
|
||||||
@@ -46,6 +46,7 @@ export enum DeviceStatusEnum {
|
|||||||
DeviceConnected = 5,
|
DeviceConnected = 5,
|
||||||
DeviceConfiguring = 6,
|
DeviceConfiguring = 6,
|
||||||
DeviceConfigured = 7,
|
DeviceConfigured = 7,
|
||||||
|
DeviceError = 8,
|
||||||
}
|
}
|
||||||
|
|
||||||
export type LogEventPacket = LogEvent & { date: Date };
|
export type LogEventPacket = LogEvent & { date: Date };
|
||||||
|
|||||||
@@ -14,16 +14,17 @@
|
|||||||
"homepage": "https://meshtastic.org",
|
"homepage": "https://meshtastic.org",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"preinstall": "npx only-allow pnpm",
|
"preinstall": "npx only-allow pnpm",
|
||||||
|
"setup:certs": "mkcert localhost 127.0.0.1 ::1",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"build:analyze": "BUNDLE_ANALYZE=true bun run build",
|
"build:analyze": "BUNDLE_ANALYZE=true pnpm run build",
|
||||||
"check": "biome check src/",
|
"check": "biome check src/",
|
||||||
"check:fix": "biome check --write src/",
|
"check:fix": "biome check --write src/",
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
"dev:https": "USE_HTTPS=true vite",
|
||||||
"test": "vitest",
|
"test": "vitest",
|
||||||
"ts:check": "bun run tsc --noEmit",
|
"typecheck": "pnpm run tsc --noEmit",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"docker:build": "docker build -t meshtastic-web:latest -f ./infra/Containerfile .",
|
"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/ ."
|
"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": {
|
"dependencies": {
|
||||||
@@ -34,6 +35,7 @@
|
|||||||
"@meshtastic/transport-web-serial": "workspace:*",
|
"@meshtastic/transport-web-serial": "workspace:*",
|
||||||
"@noble/curves": "^1.9.2",
|
"@noble/curves": "^1.9.2",
|
||||||
"@radix-ui/react-accordion": "^1.2.12",
|
"@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-checkbox": "^1.3.3",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
@@ -97,6 +99,7 @@
|
|||||||
"@types/react-dom": "^19.2.0",
|
"@types/react-dom": "^19.2.0",
|
||||||
"@types/serviceworker": "^0.0.158",
|
"@types/serviceworker": "^0.0.158",
|
||||||
"@types/w3c-web-serial": "^1.0.8",
|
"@types/w3c-web-serial": "^1.0.8",
|
||||||
|
"@vitejs/plugin-basic-ssl": "^2.1.0",
|
||||||
"@vitejs/plugin-react": "^5.0.4",
|
"@vitejs/plugin-react": "^5.0.4",
|
||||||
"autoprefixer": "^10.4.21",
|
"autoprefixer": "^10.4.21",
|
||||||
"gzipper": "^8.2.1",
|
"gzipper": "^8.2.1",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,229 +1,228 @@
|
|||||||
{
|
{
|
||||||
"navigation": {
|
"navigation": {
|
||||||
"title": "Navigation",
|
"title": "Navigation",
|
||||||
"messages": "Messages",
|
"messages": "Messages",
|
||||||
"map": "Map",
|
"map": "Map",
|
||||||
"settings": "Settings",
|
"config": "Config",
|
||||||
"channels": "Channels",
|
"radioConfig": "Radio Config",
|
||||||
"radioConfig": "Radio Config",
|
"moduleConfig": "Module Config",
|
||||||
"deviceConfig": "Device Config",
|
"channels": "Channels",
|
||||||
"moduleConfig": "Module Config",
|
"nodes": "Nodes"
|
||||||
"nodes": "Nodes"
|
},
|
||||||
},
|
"app": {
|
||||||
"app": {
|
"title": "Meshtastic",
|
||||||
"title": "Meshtastic",
|
"logo": "Meshtastic Logo"
|
||||||
"logo": "Meshtastic Logo"
|
},
|
||||||
},
|
"sidebar": {
|
||||||
"sidebar": {
|
"collapseToggle": {
|
||||||
"collapseToggle": {
|
"button": {
|
||||||
"button": {
|
"open": "Open sidebar",
|
||||||
"open": "Open sidebar",
|
"close": "Close sidebar"
|
||||||
"close": "Close sidebar"
|
}
|
||||||
}
|
},
|
||||||
},
|
"deviceInfo": {
|
||||||
"deviceInfo": {
|
"volts": "{{voltage}} volts",
|
||||||
"volts": "{{voltage}} volts",
|
"firmware": {
|
||||||
"firmware": {
|
"title": "Firmware",
|
||||||
"title": "Firmware",
|
"version": "v{{version}}",
|
||||||
"version": "v{{version}}",
|
"buildDate": "Build date: {{date}}"
|
||||||
"buildDate": "Build date: {{date}}"
|
},
|
||||||
}
|
"deviceName": {
|
||||||
}
|
"title": "Device Name",
|
||||||
},
|
"changeName": "Change Device Name",
|
||||||
"batteryStatus": {
|
"placeholder": "Enter device name"
|
||||||
"charging": "{{level}}% charging",
|
},
|
||||||
"pluggedIn": "Plugged in",
|
"editDeviceName": "Edit device name"
|
||||||
"title": "Battery"
|
}
|
||||||
},
|
},
|
||||||
"search": {
|
"batteryStatus": {
|
||||||
"nodes": "Search nodes...",
|
"charging": "{{level}}% charging",
|
||||||
"channels": "Search channels...",
|
"pluggedIn": "Plugged in",
|
||||||
"commandPalette": "Search commands..."
|
"title": "Battery"
|
||||||
},
|
},
|
||||||
"toast": {
|
"search": {
|
||||||
"positionRequestSent": {
|
"nodes": "Search nodes...",
|
||||||
"title": "Position request sent."
|
"channels": "Search channels...",
|
||||||
},
|
"commandPalette": "Search commands..."
|
||||||
"requestingPosition": {
|
},
|
||||||
"title": "Requesting position, please wait..."
|
"toast": {
|
||||||
},
|
"positionRequestSent": {
|
||||||
"sendingTraceroute": {
|
"title": "Position request sent."
|
||||||
"title": "Sending Traceroute, please wait..."
|
},
|
||||||
},
|
"requestingPosition": {
|
||||||
"tracerouteSent": {
|
"title": "Requesting position, please wait..."
|
||||||
"title": "Traceroute sent."
|
},
|
||||||
},
|
"sendingTraceroute": {
|
||||||
"savedChannel": {
|
"title": "Sending Traceroute, please wait..."
|
||||||
"title": "Saved Channel: {{channelName}}"
|
},
|
||||||
},
|
"tracerouteSent": {
|
||||||
"messages": {
|
"title": "Traceroute sent."
|
||||||
"pkiEncryption": {
|
},
|
||||||
"title": "Chat is using PKI encryption."
|
"savedChannel": {
|
||||||
},
|
"title": "Saved Channel: {{channelName}}"
|
||||||
"pskEncryption": {
|
},
|
||||||
"title": "Chat is using PSK encryption."
|
"messages": {
|
||||||
}
|
"pkiEncryption": {
|
||||||
},
|
"title": "Chat is using PKI encryption."
|
||||||
"configSaveError": {
|
},
|
||||||
"title": "Error Saving Config",
|
"pskEncryption": {
|
||||||
"description": "An error occurred while saving the configuration."
|
"title": "Chat is using PSK encryption."
|
||||||
},
|
}
|
||||||
"validationError": {
|
},
|
||||||
"title": "Config Errors Exist",
|
"configSaveError": {
|
||||||
"description": "Please fix the configuration errors before saving."
|
"title": "Error Saving Config",
|
||||||
},
|
"description": "An error occurred while saving the configuration."
|
||||||
"saveSuccess": {
|
},
|
||||||
"title": "Saving Config",
|
"validationError": {
|
||||||
"description": "The configuration change {{case}} has been saved."
|
"title": "Config Errors Exist",
|
||||||
},
|
"description": "Please fix the configuration errors before saving."
|
||||||
"saveAllSuccess": {
|
},
|
||||||
"title": "Saved",
|
"saveSuccess": {
|
||||||
"description": "All configuration changes have been saved."
|
"title": "Saving Config",
|
||||||
},
|
"description": "The configuration change {{case}} has been saved."
|
||||||
"favoriteNode": {
|
},
|
||||||
"title": "{{action}} {{nodeName}} {{direction}} favorites.",
|
"favoriteNode": {
|
||||||
"action": {
|
"title": "{{action}} {{nodeName}} {{direction}} favorites.",
|
||||||
"added": "Added",
|
"action": {
|
||||||
"removed": "Removed",
|
"added": "Added",
|
||||||
"to": "to",
|
"removed": "Removed",
|
||||||
"from": "from"
|
"to": "to",
|
||||||
}
|
"from": "from"
|
||||||
},
|
}
|
||||||
"ignoreNode": {
|
},
|
||||||
"title": "{{action}} {{nodeName}} {{direction}} ignore list",
|
"ignoreNode": {
|
||||||
"action": {
|
"title": "{{action}} {{nodeName}} {{direction}} ignore list",
|
||||||
"added": "Added",
|
"action": {
|
||||||
"removed": "Removed",
|
"added": "Added",
|
||||||
"to": "to",
|
"removed": "Removed",
|
||||||
"from": "from"
|
"to": "to",
|
||||||
}
|
"from": "from"
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
"notifications": {
|
},
|
||||||
"copied": {
|
"notifications": {
|
||||||
"label": "Copied!"
|
"copied": {
|
||||||
},
|
"label": "Copied!"
|
||||||
"copyToClipboard": {
|
},
|
||||||
"label": "Copy to clipboard"
|
"copyToClipboard": {
|
||||||
},
|
"label": "Copy to clipboard"
|
||||||
"hidePassword": {
|
},
|
||||||
"label": "Hide password"
|
"hidePassword": {
|
||||||
},
|
"label": "Hide password"
|
||||||
"showPassword": {
|
},
|
||||||
"label": "Show password"
|
"showPassword": {
|
||||||
},
|
"label": "Show password"
|
||||||
"deliveryStatus": {
|
},
|
||||||
"delivered": "Delivered",
|
"deliveryStatus": {
|
||||||
"failed": "Delivery Failed",
|
"delivered": "Delivered",
|
||||||
"waiting": "Waiting",
|
"failed": "Delivery Failed",
|
||||||
"unknown": "Unknown"
|
"waiting": "Waiting",
|
||||||
}
|
"unknown": "Unknown"
|
||||||
},
|
}
|
||||||
"general": {
|
},
|
||||||
"label": "General"
|
"general": {
|
||||||
},
|
"label": "General"
|
||||||
"hardware": {
|
},
|
||||||
"label": "Hardware"
|
"hardware": {
|
||||||
},
|
"label": "Hardware"
|
||||||
"metrics": {
|
},
|
||||||
"label": "Metrics"
|
"metrics": {
|
||||||
},
|
"label": "Metrics"
|
||||||
"role": {
|
},
|
||||||
"label": "Role"
|
"role": {
|
||||||
},
|
"label": "Role"
|
||||||
"filter": {
|
},
|
||||||
"label": "Filter"
|
"filter": {
|
||||||
},
|
"label": "Filter"
|
||||||
"advanced": {
|
},
|
||||||
"label": "Advanced"
|
"advanced": {
|
||||||
},
|
"label": "Advanced"
|
||||||
"clearInput": {
|
},
|
||||||
"label": "Clear input"
|
"clearInput": {
|
||||||
},
|
"label": "Clear input"
|
||||||
"resetFilters": {
|
},
|
||||||
"label": "Reset Filters"
|
"resetFilters": {
|
||||||
},
|
"label": "Reset Filters"
|
||||||
"nodeName": {
|
},
|
||||||
"label": "Node name/number",
|
"nodeName": {
|
||||||
"placeholder": "Meshtastic 1234"
|
"label": "Node name/number",
|
||||||
},
|
"placeholder": "Meshtastic 1234"
|
||||||
"airtimeUtilization": {
|
},
|
||||||
"label": "Airtime Utilization (%)",
|
"airtimeUtilization": {
|
||||||
"short": "Airtime Util. (%)"
|
"label": "Airtime Utilization (%)"
|
||||||
},
|
},
|
||||||
"batteryLevel": {
|
"batteryLevel": {
|
||||||
"label": "Battery level (%)",
|
"label": "Battery level (%)",
|
||||||
"labelText": "Battery level (%): {{value}}"
|
"labelText": "Battery level (%): {{value}}"
|
||||||
},
|
},
|
||||||
"batteryVoltage": {
|
"batteryVoltage": {
|
||||||
"label": "Battery voltage (V)",
|
"label": "Battery voltage (V)",
|
||||||
"title": "Voltage"
|
"title": "Voltage"
|
||||||
},
|
},
|
||||||
"channelUtilization": {
|
"channelUtilization": {
|
||||||
"label": "Channel Utilization (%)",
|
"label": "Channel Utilization (%)"
|
||||||
"short": "Channel Util. (%)"
|
},
|
||||||
},
|
"hops": {
|
||||||
"hops": {
|
"direct": "Direct",
|
||||||
"direct": "Direct",
|
"label": "Number of hops",
|
||||||
"label": "Number of hops",
|
"text": "Number of hops: {{value}}"
|
||||||
"text": "Number of hops: {{value}}"
|
},
|
||||||
},
|
"lastHeard": {
|
||||||
"lastHeard": {
|
"label": "Last heard",
|
||||||
"label": "Last heard",
|
"labelText": "Last heard: {{value}}",
|
||||||
"labelText": "Last heard: {{value}}",
|
"nowLabel": "Now"
|
||||||
"nowLabel": "Now"
|
},
|
||||||
},
|
"snr": {
|
||||||
"snr": {
|
"label": "SNR (db)"
|
||||||
"label": "SNR (db)"
|
},
|
||||||
},
|
"favorites": {
|
||||||
"favorites": {
|
"label": "Favorites"
|
||||||
"label": "Favorites"
|
},
|
||||||
},
|
"hide": {
|
||||||
"hide": {
|
"label": "Hide"
|
||||||
"label": "Hide"
|
},
|
||||||
},
|
"showOnly": {
|
||||||
"showOnly": {
|
"label": "Show Only"
|
||||||
"label": "Show Only"
|
},
|
||||||
},
|
"viaMqtt": {
|
||||||
"viaMqtt": {
|
"label": "Connected via MQTT"
|
||||||
"label": "Connected via MQTT"
|
},
|
||||||
},
|
"hopsUnknown": {
|
||||||
"hopsUnknown": {
|
"label": "Unknown number of hops"
|
||||||
"label": "Unknown number of hops"
|
},
|
||||||
},
|
"showUnheard": {
|
||||||
"showUnheard": {
|
"label": "Never heard"
|
||||||
"label": "Never heard"
|
},
|
||||||
},
|
"language": {
|
||||||
"language": {
|
"label": "Language",
|
||||||
"label": "Language",
|
"changeLanguage": "Change Language"
|
||||||
"changeLanguage": "Change Language"
|
},
|
||||||
},
|
"theme": {
|
||||||
"theme": {
|
"dark": "Dark",
|
||||||
"dark": "Dark",
|
"light": "Light",
|
||||||
"light": "Light",
|
"system": "Automatic",
|
||||||
"system": "Automatic",
|
"changeTheme": "Change Color Scheme"
|
||||||
"changeTheme": "Change Color Scheme"
|
},
|
||||||
},
|
"errorPage": {
|
||||||
"errorPage": {
|
"title": "This is a little embarrassing...",
|
||||||
"title": "This is a little embarrassing...",
|
"description1": "We are really sorry but an error occurred in the web client that caused it to crash. <br /> This is not supposed to happen, and we are working hard to fix it.",
|
||||||
"description1": "We are really sorry but an error occurred in the web client that caused it to crash. <br /> 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.",
|
||||||
"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:",
|
||||||
"reportInstructions": "Please include the following information in your report:",
|
"reportSteps": {
|
||||||
"reportSteps": {
|
"step1": "What you were doing when the error occurred",
|
||||||
"step1": "What you were doing when the error occurred",
|
"step2": "What you expected to happen",
|
||||||
"step2": "What you expected to happen",
|
"step3": "What actually happened",
|
||||||
"step3": "What actually happened",
|
"step4": "Any other relevant information"
|
||||||
"step4": "Any other relevant information"
|
},
|
||||||
},
|
"reportLink": "You can report the issue to our <0>GitHub</0>",
|
||||||
"reportLink": "You can report the issue to our <0>GitHub</0>",
|
"connectionsLink": "Return to the <0>connections</0>",
|
||||||
"dashboardLink": "Return to the <0>dashboard</0>",
|
"detailsSummary": "Error Details",
|
||||||
"detailsSummary": "Error Details",
|
"errorMessageLabel": "Error message:",
|
||||||
"errorMessageLabel": "Error message:",
|
"stackTraceLabel": "Stack trace:",
|
||||||
"stackTraceLabel": "Stack trace:",
|
"fallbackError": "{{error}}"
|
||||||
"fallbackError": "{{error}}"
|
},
|
||||||
},
|
"footer": {
|
||||||
"footer": {
|
"text": "Powered by <0>▲ Vercel</0> | Meshtastic® is a registered trademark of Meshtastic LLC. | <1>Legal Information</1>",
|
||||||
"text": "Powered by <0>▲ Vercel</0> | Meshtastic® is a registered trademark of Meshtastic LLC. | <1>Legal Information</1>",
|
"commitSha": "Commit SHA: {{sha}}"
|
||||||
"commitSha": "Commit SHA: {{sha}}"
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"connections": {
|
||||||
|
"title": "Свързани устройства",
|
||||||
|
"description": "Управлявайте свързаните си устройства Meshtastic.",
|
||||||
|
"connectionType_ble": "BLE",
|
||||||
|
"connectionType_serial": "Серийна",
|
||||||
|
"connectionType_network": "Мрежа",
|
||||||
|
"noDevicesTitle": "Няма свързани устройства",
|
||||||
|
"noDevicesDescription": "Свържете ново устройство, за да започнете.",
|
||||||
|
"button_newConnection": "Нова връзка"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
{
|
|
||||||
"dashboard": {
|
|
||||||
"title": "Свързани устройства",
|
|
||||||
"description": "Управлявайте свързаните си устройства Meshtastic.",
|
|
||||||
"connectionType_ble": "BLE",
|
|
||||||
"connectionType_serial": "Серийна",
|
|
||||||
"connectionType_network": "Мрежа",
|
|
||||||
"noDevicesTitle": "Няма свързани устройства",
|
|
||||||
"noDevicesDescription": "Свържете ново устройство, за да започнете.",
|
|
||||||
"button_newConnection": "Нова връзка"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,229 +1,228 @@
|
|||||||
{
|
{
|
||||||
"navigation": {
|
"navigation": {
|
||||||
"title": "Навигация",
|
"title": "Навигация",
|
||||||
"messages": "Съобщения",
|
"messages": "Съобщения",
|
||||||
"map": "Карта",
|
"map": "Карта",
|
||||||
"settings": "Настройки",
|
"config": "Конфигурация",
|
||||||
"channels": "Канали",
|
"radioConfig": "Конфигурация на радиото",
|
||||||
"radioConfig": "Конфигурация на радиото",
|
"moduleConfig": "Конфигурация на модула",
|
||||||
"deviceConfig": "Конфигуриране на устройството",
|
"channels": "Канали",
|
||||||
"moduleConfig": "Конфигурация на модула",
|
"nodes": "Възли"
|
||||||
"nodes": "Възли"
|
},
|
||||||
},
|
"app": {
|
||||||
"app": {
|
"title": "Meshtastic",
|
||||||
"title": "Meshtastic",
|
"logo": "Лого на Meshtastic"
|
||||||
"logo": "Лого на Meshtastic"
|
},
|
||||||
},
|
"sidebar": {
|
||||||
"sidebar": {
|
"collapseToggle": {
|
||||||
"collapseToggle": {
|
"button": {
|
||||||
"button": {
|
"open": "Отваряне на страничната лента",
|
||||||
"open": "Отваряне на страничната лента",
|
"close": "Затваряне на страничната лента"
|
||||||
"close": "Затваряне на страничната лента"
|
}
|
||||||
}
|
},
|
||||||
},
|
"deviceInfo": {
|
||||||
"deviceInfo": {
|
"volts": "{{voltage}} волта",
|
||||||
"volts": "{{voltage}} волта",
|
"firmware": {
|
||||||
"firmware": {
|
"title": "Фърмуер",
|
||||||
"title": "Фърмуер",
|
"version": "v{{version}}",
|
||||||
"version": "v{{version}}",
|
"buildDate": "Дата на компилация: {{date}}"
|
||||||
"buildDate": "Дата на компилация: {{date}}"
|
},
|
||||||
}
|
"deviceName": {
|
||||||
}
|
"title": "Име на устройството",
|
||||||
},
|
"changeName": "Промяна на името на устройството",
|
||||||
"batteryStatus": {
|
"placeholder": "Въвеждане на име на устройството"
|
||||||
"charging": "{{level}}% зареждане",
|
},
|
||||||
"pluggedIn": "Включен в ел. мрежа",
|
"editDeviceName": "Редактиране на името на устройство"
|
||||||
"title": "Батерия"
|
}
|
||||||
},
|
},
|
||||||
"search": {
|
"batteryStatus": {
|
||||||
"nodes": "Търсене на възли...",
|
"charging": "{{level}}% зареждане",
|
||||||
"channels": "Търсене на канали...",
|
"pluggedIn": "Включен в ел. мрежа",
|
||||||
"commandPalette": "Търсене на команди..."
|
"title": "Батерия"
|
||||||
},
|
},
|
||||||
"toast": {
|
"search": {
|
||||||
"positionRequestSent": {
|
"nodes": "Търсене на възли...",
|
||||||
"title": "Заявката за позиция е изпратена."
|
"channels": "Търсене на канали...",
|
||||||
},
|
"commandPalette": "Търсене на команди..."
|
||||||
"requestingPosition": {
|
},
|
||||||
"title": "Запитване за позиция, моля изчакайте..."
|
"toast": {
|
||||||
},
|
"positionRequestSent": {
|
||||||
"sendingTraceroute": {
|
"title": "Заявката за позиция е изпратена."
|
||||||
"title": "Sending Traceroute, please wait..."
|
},
|
||||||
},
|
"requestingPosition": {
|
||||||
"tracerouteSent": {
|
"title": "Запитване за позиция, моля изчакайте..."
|
||||||
"title": "Traceroute sent."
|
},
|
||||||
},
|
"sendingTraceroute": {
|
||||||
"savedChannel": {
|
"title": "Sending Traceroute, please wait..."
|
||||||
"title": "Запазен канал: {{channelName}}"
|
},
|
||||||
},
|
"tracerouteSent": {
|
||||||
"messages": {
|
"title": "Traceroute sent."
|
||||||
"pkiEncryption": {
|
},
|
||||||
"title": "Чатът използва PKI криптиране."
|
"savedChannel": {
|
||||||
},
|
"title": "Запазен канал: {{channelName}}"
|
||||||
"pskEncryption": {
|
},
|
||||||
"title": "Чатът използва PSK криптиране."
|
"messages": {
|
||||||
}
|
"pkiEncryption": {
|
||||||
},
|
"title": "Чатът използва PKI криптиране."
|
||||||
"configSaveError": {
|
},
|
||||||
"title": "Грешка при запазване на конфигурацията",
|
"pskEncryption": {
|
||||||
"description": "Възникна грешка при запазване на конфигурацията."
|
"title": "Чатът използва PSK криптиране."
|
||||||
},
|
}
|
||||||
"validationError": {
|
},
|
||||||
"title": "Съществуват грешки в конфигурацията",
|
"configSaveError": {
|
||||||
"description": "Моля, коригирайте грешките в конфигурацията, преди да я запазите."
|
"title": "Грешка при запазване на конфигурацията",
|
||||||
},
|
"description": "Възникна грешка при запазване на конфигурацията."
|
||||||
"saveSuccess": {
|
},
|
||||||
"title": "Запазване на конфигурацията",
|
"validationError": {
|
||||||
"description": "Промяната в конфигурацията {{case}} е запазена."
|
"title": "Съществуват грешки в конфигурацията",
|
||||||
},
|
"description": "Моля, коригирайте грешките в конфигурацията, преди да я запазите."
|
||||||
"saveAllSuccess": {
|
},
|
||||||
"title": "Saved",
|
"saveSuccess": {
|
||||||
"description": "All configuration changes have been saved."
|
"title": "Запазване на конфигурацията",
|
||||||
},
|
"description": "Промяната в конфигурацията {{case}} е запазена."
|
||||||
"favoriteNode": {
|
},
|
||||||
"title": "{{action}} {{nodeName}} {{direction}} любими.",
|
"favoriteNode": {
|
||||||
"action": {
|
"title": "{{action}} {{nodeName}} {{direction}} любими.",
|
||||||
"added": "Добавен",
|
"action": {
|
||||||
"removed": "Премахнат",
|
"added": "Добавен",
|
||||||
"to": "в",
|
"removed": "Премахнат",
|
||||||
"from": "от"
|
"to": "в",
|
||||||
}
|
"from": "от"
|
||||||
},
|
}
|
||||||
"ignoreNode": {
|
},
|
||||||
"title": "{{action}} {{nodeName}} {{direction}} списък с игнорирани",
|
"ignoreNode": {
|
||||||
"action": {
|
"title": "{{action}} {{nodeName}} {{direction}} списък с игнорирани",
|
||||||
"added": "Добавен",
|
"action": {
|
||||||
"removed": "Премахнат",
|
"added": "Добавен",
|
||||||
"to": "в",
|
"removed": "Премахнат",
|
||||||
"from": "от"
|
"to": "в",
|
||||||
}
|
"from": "от"
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
"notifications": {
|
},
|
||||||
"copied": {
|
"notifications": {
|
||||||
"label": "Копирано!"
|
"copied": {
|
||||||
},
|
"label": "Копирано!"
|
||||||
"copyToClipboard": {
|
},
|
||||||
"label": "Копиране в клипборда"
|
"copyToClipboard": {
|
||||||
},
|
"label": "Копиране в клипборда"
|
||||||
"hidePassword": {
|
},
|
||||||
"label": "Скриване на паролата"
|
"hidePassword": {
|
||||||
},
|
"label": "Скриване на паролата"
|
||||||
"showPassword": {
|
},
|
||||||
"label": "Показване на паролата"
|
"showPassword": {
|
||||||
},
|
"label": "Показване на паролата"
|
||||||
"deliveryStatus": {
|
},
|
||||||
"delivered": "Доставено",
|
"deliveryStatus": {
|
||||||
"failed": "Неуспешна доставка",
|
"delivered": "Доставено",
|
||||||
"waiting": "Изчакване",
|
"failed": "Неуспешна доставка",
|
||||||
"unknown": "Неизвестно"
|
"waiting": "Изчакване",
|
||||||
}
|
"unknown": "Неизвестно"
|
||||||
},
|
}
|
||||||
"general": {
|
},
|
||||||
"label": "General"
|
"general": {
|
||||||
},
|
"label": "General"
|
||||||
"hardware": {
|
},
|
||||||
"label": "Хардуер"
|
"hardware": {
|
||||||
},
|
"label": "Хардуер"
|
||||||
"metrics": {
|
},
|
||||||
"label": "Метрики"
|
"metrics": {
|
||||||
},
|
"label": "Метрики"
|
||||||
"role": {
|
},
|
||||||
"label": "Роля"
|
"role": {
|
||||||
},
|
"label": "Роля"
|
||||||
"filter": {
|
},
|
||||||
"label": "Филтър"
|
"filter": {
|
||||||
},
|
"label": "Филтър"
|
||||||
"advanced": {
|
},
|
||||||
"label": "Разширени"
|
"advanced": {
|
||||||
},
|
"label": "Разширени"
|
||||||
"clearInput": {
|
},
|
||||||
"label": "Clear input"
|
"clearInput": {
|
||||||
},
|
"label": "Clear input"
|
||||||
"resetFilters": {
|
},
|
||||||
"label": "Нулиране на филтрите"
|
"resetFilters": {
|
||||||
},
|
"label": "Нулиране на филтрите"
|
||||||
"nodeName": {
|
},
|
||||||
"label": "Име/номер на възел",
|
"nodeName": {
|
||||||
"placeholder": "Meshtastic 1234"
|
"label": "Име/номер на възел",
|
||||||
},
|
"placeholder": "Meshtastic 1234"
|
||||||
"airtimeUtilization": {
|
},
|
||||||
"label": "Използване на ефира (%)",
|
"airtimeUtilization": {
|
||||||
"short": "Airtime Util. (%)"
|
"label": "Използване на ефира (%)"
|
||||||
},
|
},
|
||||||
"batteryLevel": {
|
"batteryLevel": {
|
||||||
"label": "Ниво на батерията (%)",
|
"label": "Ниво на батерията (%)",
|
||||||
"labelText": "Ниво на батерията (%): {{value}}"
|
"labelText": "Ниво на батерията (%): {{value}}"
|
||||||
},
|
},
|
||||||
"batteryVoltage": {
|
"batteryVoltage": {
|
||||||
"label": "Напрежение на батерията (V)",
|
"label": "Напрежение на батерията (V)",
|
||||||
"title": "Напрежение"
|
"title": "Напрежение"
|
||||||
},
|
},
|
||||||
"channelUtilization": {
|
"channelUtilization": {
|
||||||
"label": "Използване на канала (%)",
|
"label": "Използване на канала (%)"
|
||||||
"short": "Channel Util. (%)"
|
},
|
||||||
},
|
"hops": {
|
||||||
"hops": {
|
"direct": "Директно",
|
||||||
"direct": "Директно",
|
"label": "Брой хопове",
|
||||||
"label": "Брой хопове",
|
"text": "Брой хопове: {{value}}"
|
||||||
"text": "Брой хопове: {{value}}"
|
},
|
||||||
},
|
"lastHeard": {
|
||||||
"lastHeard": {
|
"label": "Последно чут",
|
||||||
"label": "Последно чут",
|
"labelText": "Последно чут: {{value}}",
|
||||||
"labelText": "Последно чут: {{value}}",
|
"nowLabel": "Сега"
|
||||||
"nowLabel": "Сега"
|
},
|
||||||
},
|
"snr": {
|
||||||
"snr": {
|
"label": "SNR (db)"
|
||||||
"label": "SNR (db)"
|
},
|
||||||
},
|
"favorites": {
|
||||||
"favorites": {
|
"label": "Любими"
|
||||||
"label": "Любими"
|
},
|
||||||
},
|
"hide": {
|
||||||
"hide": {
|
"label": "Скриване"
|
||||||
"label": "Скриване"
|
},
|
||||||
},
|
"showOnly": {
|
||||||
"showOnly": {
|
"label": "Показване само"
|
||||||
"label": "Показване само"
|
},
|
||||||
},
|
"viaMqtt": {
|
||||||
"viaMqtt": {
|
"label": "Свързан чрез MQTT"
|
||||||
"label": "Свързан чрез MQTT"
|
},
|
||||||
},
|
"hopsUnknown": {
|
||||||
"hopsUnknown": {
|
"label": "Неизвестен брой хопове"
|
||||||
"label": "Неизвестен брой хопове"
|
},
|
||||||
},
|
"showUnheard": {
|
||||||
"showUnheard": {
|
"label": "Никога не е чуван"
|
||||||
"label": "Никога не е чуван"
|
},
|
||||||
},
|
"language": {
|
||||||
"language": {
|
"label": "Език",
|
||||||
"label": "Език",
|
"changeLanguage": "Промяна на езика"
|
||||||
"changeLanguage": "Промяна на езика"
|
},
|
||||||
},
|
"theme": {
|
||||||
"theme": {
|
"dark": "Тъмна",
|
||||||
"dark": "Тъмна",
|
"light": "Светла",
|
||||||
"light": "Светла",
|
"system": "Автоматично",
|
||||||
"system": "Автоматично",
|
"changeTheme": "Промяна на цветовата схема"
|
||||||
"changeTheme": "Промяна на цветовата схема"
|
},
|
||||||
},
|
"errorPage": {
|
||||||
"errorPage": {
|
"title": "Това е малко смущаващо...",
|
||||||
"title": "Това е малко смущаващо...",
|
"description1": "Наистина съжаляваме, но възникна грешка в web клиента, която доведе до срив. <br /> Това не би трябвало да се случва и работим усилено, за да го поправим.",
|
||||||
"description1": "Наистина съжаляваме, но възникна грешка в web клиента, която доведе до срив. <br /> Това не би трябвало да се случва и работим усилено, за да го поправим.",
|
"description2": "Най-добрият начин да предотвратите това да се случи отново с вас или с някой друг е да ни съобщите за проблема.",
|
||||||
"description2": "Най-добрият начин да предотвратите това да се случи отново с вас или с някой друг е да ни съобщите за проблема.",
|
"reportInstructions": "Моля, включете следната информация в доклада си:",
|
||||||
"reportInstructions": "Моля, включете следната информация в доклада си:",
|
"reportSteps": {
|
||||||
"reportSteps": {
|
"step1": "Какво правехте, когато възникна грешката",
|
||||||
"step1": "Какво правехте, когато възникна грешката",
|
"step2": "Какво очаквахте да се случи",
|
||||||
"step2": "Какво очаквахте да се случи",
|
"step3": "Какво всъщност се случи",
|
||||||
"step3": "Какво всъщност се случи",
|
"step4": "Всяка друга подходяща информация"
|
||||||
"step4": "Всяка друга подходяща информация"
|
},
|
||||||
},
|
"reportLink": "Можете да съобщите за проблема в нашия <0>GitHub</0>",
|
||||||
"reportLink": "Можете да съобщите за проблема в нашия <0>GitHub</0>",
|
"connectionsLink": "Връщане към <0>таблото</0>",
|
||||||
"dashboardLink": "Връщане към <0>таблото</0>",
|
"detailsSummary": "Подробности за грешката",
|
||||||
"detailsSummary": "Подробности за грешката",
|
"errorMessageLabel": "Съобщение за грешка:",
|
||||||
"errorMessageLabel": "Съобщение за грешка:",
|
"stackTraceLabel": "Stack trace:",
|
||||||
"stackTraceLabel": "Stack trace:",
|
"fallbackError": "{{error}}"
|
||||||
"fallbackError": "{{error}}"
|
},
|
||||||
},
|
"footer": {
|
||||||
"footer": {
|
"text": "Задвижвано от <0>▲ Vercel</0> | Meshtastic® е регистрирана търговска марка на Meshtastic LLC. | <1>Правна информация</1>",
|
||||||
"text": "Задвижвано от <0>▲ Vercel</0> | Meshtastic® е регистрирана търговска марка на Meshtastic LLC. | <1>Правна информация</1>",
|
"commitSha": "Commit SHA: {{sha}}"
|
||||||
"commitSha": "Commit SHA: {{sha}}"
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,229 +1,228 @@
|
|||||||
{
|
{
|
||||||
"navigation": {
|
"navigation": {
|
||||||
"title": "Navigation",
|
"title": "Navigation",
|
||||||
"messages": "Zprávy",
|
"messages": "Zprávy",
|
||||||
"map": "Mapa",
|
"map": "Mapa",
|
||||||
"settings": "Nastavení",
|
"config": "Config",
|
||||||
"channels": "Kanály",
|
"radioConfig": "Radio Config",
|
||||||
"radioConfig": "Radio Config",
|
"moduleConfig": "Module Config",
|
||||||
"deviceConfig": "Nastavení zařízení",
|
"channels": "Kanály",
|
||||||
"moduleConfig": "Module Config",
|
"nodes": "Uzly"
|
||||||
"nodes": "Uzly"
|
},
|
||||||
},
|
"app": {
|
||||||
"app": {
|
"title": "Meshtastic",
|
||||||
"title": "Meshtastic",
|
"logo": "Meshtastic Logo"
|
||||||
"logo": "Meshtastic Logo"
|
},
|
||||||
},
|
"sidebar": {
|
||||||
"sidebar": {
|
"collapseToggle": {
|
||||||
"collapseToggle": {
|
"button": {
|
||||||
"button": {
|
"open": "Open sidebar",
|
||||||
"open": "Open sidebar",
|
"close": "Close sidebar"
|
||||||
"close": "Close sidebar"
|
}
|
||||||
}
|
},
|
||||||
},
|
"deviceInfo": {
|
||||||
"deviceInfo": {
|
"volts": "{{voltage}} volts",
|
||||||
"volts": "{{voltage}} volts",
|
"firmware": {
|
||||||
"firmware": {
|
"title": "Firmware",
|
||||||
"title": "Firmware",
|
"version": "v{{version}}",
|
||||||
"version": "v{{version}}",
|
"buildDate": "Build date: {{date}}"
|
||||||
"buildDate": "Build date: {{date}}"
|
},
|
||||||
}
|
"deviceName": {
|
||||||
}
|
"title": "Device Name",
|
||||||
},
|
"changeName": "Change Device Name",
|
||||||
"batteryStatus": {
|
"placeholder": "Enter device name"
|
||||||
"charging": "{{level}}% charging",
|
},
|
||||||
"pluggedIn": "Plugged in",
|
"editDeviceName": "Edit device name"
|
||||||
"title": "Baterie"
|
}
|
||||||
},
|
},
|
||||||
"search": {
|
"batteryStatus": {
|
||||||
"nodes": "Search nodes...",
|
"charging": "{{level}}% charging",
|
||||||
"channels": "Search channels...",
|
"pluggedIn": "Plugged in",
|
||||||
"commandPalette": "Search commands..."
|
"title": "Baterie"
|
||||||
},
|
},
|
||||||
"toast": {
|
"search": {
|
||||||
"positionRequestSent": {
|
"nodes": "Search nodes...",
|
||||||
"title": "Position request sent."
|
"channels": "Search channels...",
|
||||||
},
|
"commandPalette": "Search commands..."
|
||||||
"requestingPosition": {
|
},
|
||||||
"title": "Requesting position, please wait..."
|
"toast": {
|
||||||
},
|
"positionRequestSent": {
|
||||||
"sendingTraceroute": {
|
"title": "Position request sent."
|
||||||
"title": "Sending Traceroute, please wait..."
|
},
|
||||||
},
|
"requestingPosition": {
|
||||||
"tracerouteSent": {
|
"title": "Requesting position, please wait..."
|
||||||
"title": "Traceroute sent."
|
},
|
||||||
},
|
"sendingTraceroute": {
|
||||||
"savedChannel": {
|
"title": "Sending Traceroute, please wait..."
|
||||||
"title": "Saved Channel: {{channelName}}"
|
},
|
||||||
},
|
"tracerouteSent": {
|
||||||
"messages": {
|
"title": "Traceroute sent."
|
||||||
"pkiEncryption": {
|
},
|
||||||
"title": "Chat is using PKI encryption."
|
"savedChannel": {
|
||||||
},
|
"title": "Saved Channel: {{channelName}}"
|
||||||
"pskEncryption": {
|
},
|
||||||
"title": "Chat is using PSK encryption."
|
"messages": {
|
||||||
}
|
"pkiEncryption": {
|
||||||
},
|
"title": "Chat is using PKI encryption."
|
||||||
"configSaveError": {
|
},
|
||||||
"title": "Error Saving Config",
|
"pskEncryption": {
|
||||||
"description": "An error occurred while saving the configuration."
|
"title": "Chat is using PSK encryption."
|
||||||
},
|
}
|
||||||
"validationError": {
|
},
|
||||||
"title": "Config Errors Exist",
|
"configSaveError": {
|
||||||
"description": "Please fix the configuration errors before saving."
|
"title": "Error Saving Config",
|
||||||
},
|
"description": "An error occurred while saving the configuration."
|
||||||
"saveSuccess": {
|
},
|
||||||
"title": "Saving Config",
|
"validationError": {
|
||||||
"description": "The configuration change {{case}} has been saved."
|
"title": "Config Errors Exist",
|
||||||
},
|
"description": "Please fix the configuration errors before saving."
|
||||||
"saveAllSuccess": {
|
},
|
||||||
"title": "Saved",
|
"saveSuccess": {
|
||||||
"description": "All configuration changes have been saved."
|
"title": "Saving Config",
|
||||||
},
|
"description": "The configuration change {{case}} has been saved."
|
||||||
"favoriteNode": {
|
},
|
||||||
"title": "{{action}} {{nodeName}} {{direction}} favorites.",
|
"favoriteNode": {
|
||||||
"action": {
|
"title": "{{action}} {{nodeName}} {{direction}} favorites.",
|
||||||
"added": "Added",
|
"action": {
|
||||||
"removed": "Removed",
|
"added": "Added",
|
||||||
"to": "to",
|
"removed": "Removed",
|
||||||
"from": "from"
|
"to": "to",
|
||||||
}
|
"from": "from"
|
||||||
},
|
}
|
||||||
"ignoreNode": {
|
},
|
||||||
"title": "{{action}} {{nodeName}} {{direction}} ignore list",
|
"ignoreNode": {
|
||||||
"action": {
|
"title": "{{action}} {{nodeName}} {{direction}} ignore list",
|
||||||
"added": "Added",
|
"action": {
|
||||||
"removed": "Removed",
|
"added": "Added",
|
||||||
"to": "to",
|
"removed": "Removed",
|
||||||
"from": "from"
|
"to": "to",
|
||||||
}
|
"from": "from"
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
"notifications": {
|
},
|
||||||
"copied": {
|
"notifications": {
|
||||||
"label": "Copied!"
|
"copied": {
|
||||||
},
|
"label": "Copied!"
|
||||||
"copyToClipboard": {
|
},
|
||||||
"label": "Copy to clipboard"
|
"copyToClipboard": {
|
||||||
},
|
"label": "Copy to clipboard"
|
||||||
"hidePassword": {
|
},
|
||||||
"label": "Skrýt heslo"
|
"hidePassword": {
|
||||||
},
|
"label": "Skrýt heslo"
|
||||||
"showPassword": {
|
},
|
||||||
"label": "Zobrazit heslo"
|
"showPassword": {
|
||||||
},
|
"label": "Zobrazit heslo"
|
||||||
"deliveryStatus": {
|
},
|
||||||
"delivered": "Delivered",
|
"deliveryStatus": {
|
||||||
"failed": "Delivery Failed",
|
"delivered": "Delivered",
|
||||||
"waiting": "Waiting",
|
"failed": "Delivery Failed",
|
||||||
"unknown": "Unknown"
|
"waiting": "Waiting",
|
||||||
}
|
"unknown": "Unknown"
|
||||||
},
|
}
|
||||||
"general": {
|
},
|
||||||
"label": "General"
|
"general": {
|
||||||
},
|
"label": "General"
|
||||||
"hardware": {
|
},
|
||||||
"label": "Hardware"
|
"hardware": {
|
||||||
},
|
"label": "Hardware"
|
||||||
"metrics": {
|
},
|
||||||
"label": "Metriky"
|
"metrics": {
|
||||||
},
|
"label": "Metriky"
|
||||||
"role": {
|
},
|
||||||
"label": "Role"
|
"role": {
|
||||||
},
|
"label": "Role"
|
||||||
"filter": {
|
},
|
||||||
"label": "Filtr"
|
"filter": {
|
||||||
},
|
"label": "Filtr"
|
||||||
"advanced": {
|
},
|
||||||
"label": "Advanced"
|
"advanced": {
|
||||||
},
|
"label": "Advanced"
|
||||||
"clearInput": {
|
},
|
||||||
"label": "Clear input"
|
"clearInput": {
|
||||||
},
|
"label": "Clear input"
|
||||||
"resetFilters": {
|
},
|
||||||
"label": "Reset Filters"
|
"resetFilters": {
|
||||||
},
|
"label": "Reset Filters"
|
||||||
"nodeName": {
|
},
|
||||||
"label": "Node name/number",
|
"nodeName": {
|
||||||
"placeholder": "Meshtastic 1234"
|
"label": "Node name/number",
|
||||||
},
|
"placeholder": "Meshtastic 1234"
|
||||||
"airtimeUtilization": {
|
},
|
||||||
"label": "Airtime Utilization (%)",
|
"airtimeUtilization": {
|
||||||
"short": "Airtime Util. (%)"
|
"label": "Airtime Utilization (%)"
|
||||||
},
|
},
|
||||||
"batteryLevel": {
|
"batteryLevel": {
|
||||||
"label": "Battery level (%)",
|
"label": "Battery level (%)",
|
||||||
"labelText": "Battery level (%): {{value}}"
|
"labelText": "Battery level (%): {{value}}"
|
||||||
},
|
},
|
||||||
"batteryVoltage": {
|
"batteryVoltage": {
|
||||||
"label": "Battery voltage (V)",
|
"label": "Battery voltage (V)",
|
||||||
"title": "Napětí"
|
"title": "Napětí"
|
||||||
},
|
},
|
||||||
"channelUtilization": {
|
"channelUtilization": {
|
||||||
"label": "Channel Utilization (%)",
|
"label": "Channel Utilization (%)"
|
||||||
"short": "Channel Util. (%)"
|
},
|
||||||
},
|
"hops": {
|
||||||
"hops": {
|
"direct": "Přímý",
|
||||||
"direct": "Přímý",
|
"label": "Number of hops",
|
||||||
"label": "Number of hops",
|
"text": "Number of hops: {{value}}"
|
||||||
"text": "Number of hops: {{value}}"
|
},
|
||||||
},
|
"lastHeard": {
|
||||||
"lastHeard": {
|
"label": "Naposledy slyšen",
|
||||||
"label": "Naposledy slyšen",
|
"labelText": "Last heard: {{value}}",
|
||||||
"labelText": "Last heard: {{value}}",
|
"nowLabel": "Now"
|
||||||
"nowLabel": "Now"
|
},
|
||||||
},
|
"snr": {
|
||||||
"snr": {
|
"label": "SNR (db)"
|
||||||
"label": "SNR (db)"
|
},
|
||||||
},
|
"favorites": {
|
||||||
"favorites": {
|
"label": "Favorites"
|
||||||
"label": "Favorites"
|
},
|
||||||
},
|
"hide": {
|
||||||
"hide": {
|
"label": "Hide"
|
||||||
"label": "Hide"
|
},
|
||||||
},
|
"showOnly": {
|
||||||
"showOnly": {
|
"label": "Show Only"
|
||||||
"label": "Show Only"
|
},
|
||||||
},
|
"viaMqtt": {
|
||||||
"viaMqtt": {
|
"label": "Connected via MQTT"
|
||||||
"label": "Connected via MQTT"
|
},
|
||||||
},
|
"hopsUnknown": {
|
||||||
"hopsUnknown": {
|
"label": "Unknown number of hops"
|
||||||
"label": "Unknown number of hops"
|
},
|
||||||
},
|
"showUnheard": {
|
||||||
"showUnheard": {
|
"label": "Never heard"
|
||||||
"label": "Never heard"
|
},
|
||||||
},
|
"language": {
|
||||||
"language": {
|
"label": "Jazyk",
|
||||||
"label": "Jazyk",
|
"changeLanguage": "Change Language"
|
||||||
"changeLanguage": "Change Language"
|
},
|
||||||
},
|
"theme": {
|
||||||
"theme": {
|
"dark": "Tmavý",
|
||||||
"dark": "Tmavý",
|
"light": "Světlý",
|
||||||
"light": "Světlý",
|
"system": "Automatic",
|
||||||
"system": "Automatic",
|
"changeTheme": "Change Color Scheme"
|
||||||
"changeTheme": "Change Color Scheme"
|
},
|
||||||
},
|
"errorPage": {
|
||||||
"errorPage": {
|
"title": "This is a little embarrassing...",
|
||||||
"title": "This is a little embarrassing...",
|
"description1": "We are really sorry but an error occurred in the web client that caused it to crash. <br /> This is not supposed to happen, and we are working hard to fix it.",
|
||||||
"description1": "We are really sorry but an error occurred in the web client that caused it to crash. <br /> 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.",
|
||||||
"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:",
|
||||||
"reportInstructions": "Please include the following information in your report:",
|
"reportSteps": {
|
||||||
"reportSteps": {
|
"step1": "What you were doing when the error occurred",
|
||||||
"step1": "What you were doing when the error occurred",
|
"step2": "What you expected to happen",
|
||||||
"step2": "What you expected to happen",
|
"step3": "What actually happened",
|
||||||
"step3": "What actually happened",
|
"step4": "Any other relevant information"
|
||||||
"step4": "Any other relevant information"
|
},
|
||||||
},
|
"reportLink": "You can report the issue to our <0>GitHub</0>",
|
||||||
"reportLink": "You can report the issue to our <0>GitHub</0>",
|
"connectionsLink": "Return to the <0>connections</0>",
|
||||||
"dashboardLink": "Return to the <0>dashboard</0>",
|
"detailsSummary": "Error Details",
|
||||||
"detailsSummary": "Error Details",
|
"errorMessageLabel": "Error message:",
|
||||||
"errorMessageLabel": "Error message:",
|
"stackTraceLabel": "Stack trace:",
|
||||||
"stackTraceLabel": "Stack trace:",
|
"fallbackError": "{{error}}"
|
||||||
"fallbackError": "{{error}}"
|
},
|
||||||
},
|
"footer": {
|
||||||
"footer": {
|
"text": "Powered by <0>▲ Vercel</0> | Meshtastic® is a registered trademark of Meshtastic LLC. | <1>Legal Information</1>",
|
||||||
"text": "Powered by <0>▲ Vercel</0> | Meshtastic® is a registered trademark of Meshtastic LLC. | <1>Legal Information</1>",
|
"commitSha": "Commit SHA: {{sha}}"
|
||||||
"commitSha": "Commit SHA: {{sha}}"
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
{
|
{
|
||||||
"button": {
|
"button": {
|
||||||
"apply": "Apply",
|
"apply": "Apply",
|
||||||
|
"addConnection": "Add Connection",
|
||||||
|
"saveConnection": "Save connection",
|
||||||
"backupKey": "Backup Key",
|
"backupKey": "Backup Key",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
|
"connect": "Connect",
|
||||||
"clearMessages": "Clear Messages",
|
"clearMessages": "Clear Messages",
|
||||||
"close": "Close",
|
"close": "Close",
|
||||||
"confirm": "Confirm",
|
"confirm": "Confirm",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"dismiss": "Dismiss",
|
"dismiss": "Dismiss",
|
||||||
"download": "Download",
|
"download": "Download",
|
||||||
|
"disconnect": "Disconnect",
|
||||||
"export": "Export",
|
"export": "Export",
|
||||||
"generate": "Generate",
|
"generate": "Generate",
|
||||||
"regenerate": "Regenerate",
|
"regenerate": "Regenerate",
|
||||||
@@ -21,7 +25,10 @@
|
|||||||
"requestNewKeys": "Request New Keys",
|
"requestNewKeys": "Request New Keys",
|
||||||
"requestPosition": "Request Position",
|
"requestPosition": "Request Position",
|
||||||
"reset": "Reset",
|
"reset": "Reset",
|
||||||
|
"retry": "Retry",
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
|
"setDefault": "Set as default",
|
||||||
|
"unsetDefault": "Unset default",
|
||||||
"scanQr": "Scan QR Code",
|
"scanQr": "Scan QR Code",
|
||||||
"traceRoute": "Trace Route",
|
"traceRoute": "Trace Route",
|
||||||
"submit": "Submit"
|
"submit": "Submit"
|
||||||
|
|||||||
@@ -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."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,18 +3,6 @@
|
|||||||
"description": "This action will clear all message history. This cannot be undone. Are you sure you want to continue?",
|
"description": "This action will clear all message history. This cannot be undone. Are you sure you want to continue?",
|
||||||
"title": "Clear All Messages"
|
"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": {
|
"import": {
|
||||||
"description": "Import a Channel Set from a Meshtastic URL. <br />Valid Meshtasic URLs start with \"<italic>https://meshtastic.org/e/...</italic>\"",
|
"description": "Import a Channel Set from a Meshtastic URL. <br />Valid Meshtasic URLs start with \"<italic>https://meshtastic.org/e/...</italic>\"",
|
||||||
"error": {
|
"error": {
|
||||||
@@ -41,49 +29,77 @@
|
|||||||
"description": "Are you sure you want to regenerate the pre-shared key?",
|
"description": "Are you sure you want to regenerate the pre-shared key?",
|
||||||
"regenerate": "Regenerate"
|
"regenerate": "Regenerate"
|
||||||
},
|
},
|
||||||
"newDeviceDialog": {
|
"addConnection": {
|
||||||
"title": "Connect New Device",
|
"title": "Add connection",
|
||||||
"https": "https",
|
"description": "Choose a connection type and fill in the details",
|
||||||
"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"
|
|
||||||
},
|
|
||||||
"validation": {
|
"validation": {
|
||||||
"requiresWebBluetooth": "This connection type requires <0>Web Bluetooth</0>. Please use a supported browser, like Chrome or Edge.",
|
"requiresWebBluetooth": "This connection type requires <0>Web Bluetooth</0>. Please use a supported browser, like Chrome or Edge.",
|
||||||
"requiresWebSerial": "This connection type requires <0>Web Serial</0>. Please use a supported browser, like Chrome or Edge.",
|
"requiresWebSerial": "This connection type requires <0>Web Serial</0>. Please use a supported browser, like Chrome or Edge.",
|
||||||
"requiresSecureContext": "This application requires a <0>secure context</0>. Please connect using HTTPS or localhost.",
|
"requiresSecureContext": "This application requires a <0>secure context</0>. Please connect using HTTPS or localhost.",
|
||||||
"additionallyRequiresSecureContext": "Additionally, it requires a <0>secure context</0>. Please connect using HTTPS or localhost."
|
"additionallyRequiresSecureContext": "Additionally, it requires a <0>secure context</0>. 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": {
|
"nodeDetails": {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
"radioConfig": "Radio Config",
|
"radioConfig": "Radio Config",
|
||||||
"deviceConfig": "Device Config",
|
"deviceConfig": "Device Config",
|
||||||
"moduleConfig": "Module Config",
|
"moduleConfig": "Module Config",
|
||||||
|
"manageConnections": "Manage Connections",
|
||||||
"nodes": "Nodes"
|
"nodes": "Nodes"
|
||||||
},
|
},
|
||||||
"app": {
|
"app": {
|
||||||
@@ -202,7 +203,7 @@
|
|||||||
"step4": "Any other relevant information"
|
"step4": "Any other relevant information"
|
||||||
},
|
},
|
||||||
"reportLink": "You can report the issue to our <0>GitHub</0>",
|
"reportLink": "You can report the issue to our <0>GitHub</0>",
|
||||||
"dashboardLink": "Return to the <0>dashboard</0>",
|
"connectionsLink": "Return to the <0>connections</0>",
|
||||||
"detailsSummary": "Error Details",
|
"detailsSummary": "Error Details",
|
||||||
"errorMessageLabel": "Error message:",
|
"errorMessageLabel": "Error message:",
|
||||||
"stackTraceLabel": "Stack trace:",
|
"stackTraceLabel": "Stack trace:",
|
||||||
|
|||||||
@@ -1,229 +1,228 @@
|
|||||||
{
|
{
|
||||||
"navigation": {
|
"navigation": {
|
||||||
"title": "Navigation",
|
"title": "Navigation",
|
||||||
"messages": "消息",
|
"messages": "消息",
|
||||||
"map": "地图",
|
"map": "地图",
|
||||||
"settings": "设置",
|
"config": "配置",
|
||||||
"channels": "频道",
|
"radioConfig": "Radio Config",
|
||||||
"radioConfig": "Radio Config",
|
"moduleConfig": "Module Config",
|
||||||
"deviceConfig": "设备配置",
|
"channels": "频道",
|
||||||
"moduleConfig": "Module Config",
|
"nodes": "节点"
|
||||||
"nodes": "节点"
|
},
|
||||||
},
|
"app": {
|
||||||
"app": {
|
"title": "Meshtastic",
|
||||||
"title": "Meshtastic",
|
"logo": "Meshtastic Logo"
|
||||||
"logo": "Meshtastic Logo"
|
},
|
||||||
},
|
"sidebar": {
|
||||||
"sidebar": {
|
"collapseToggle": {
|
||||||
"collapseToggle": {
|
"button": {
|
||||||
"button": {
|
"open": "Open sidebar",
|
||||||
"open": "Open sidebar",
|
"close": "Close sidebar"
|
||||||
"close": "Close sidebar"
|
}
|
||||||
}
|
},
|
||||||
},
|
"deviceInfo": {
|
||||||
"deviceInfo": {
|
"volts": "{{voltage}} volts",
|
||||||
"volts": "{{voltage}} volts",
|
"firmware": {
|
||||||
"firmware": {
|
"title": "固件",
|
||||||
"title": "固件",
|
"version": "v{{version}}",
|
||||||
"version": "v{{version}}",
|
"buildDate": "Build date: {{date}}"
|
||||||
"buildDate": "Build date: {{date}}"
|
},
|
||||||
}
|
"deviceName": {
|
||||||
}
|
"title": "Device Name",
|
||||||
},
|
"changeName": "Change Device Name",
|
||||||
"batteryStatus": {
|
"placeholder": "Enter device name"
|
||||||
"charging": "{{level}}% charging",
|
},
|
||||||
"pluggedIn": "Plugged in",
|
"editDeviceName": "Edit device name"
|
||||||
"title": "电池"
|
}
|
||||||
},
|
},
|
||||||
"search": {
|
"batteryStatus": {
|
||||||
"nodes": "Search nodes...",
|
"charging": "{{level}}% charging",
|
||||||
"channels": "Search channels...",
|
"pluggedIn": "Plugged in",
|
||||||
"commandPalette": "Search commands..."
|
"title": "电池"
|
||||||
},
|
},
|
||||||
"toast": {
|
"search": {
|
||||||
"positionRequestSent": {
|
"nodes": "Search nodes...",
|
||||||
"title": "Position request sent."
|
"channels": "Search channels...",
|
||||||
},
|
"commandPalette": "Search commands..."
|
||||||
"requestingPosition": {
|
},
|
||||||
"title": "Requesting position, please wait..."
|
"toast": {
|
||||||
},
|
"positionRequestSent": {
|
||||||
"sendingTraceroute": {
|
"title": "Position request sent."
|
||||||
"title": "Sending Traceroute, please wait..."
|
},
|
||||||
},
|
"requestingPosition": {
|
||||||
"tracerouteSent": {
|
"title": "Requesting position, please wait..."
|
||||||
"title": "Traceroute sent."
|
},
|
||||||
},
|
"sendingTraceroute": {
|
||||||
"savedChannel": {
|
"title": "Sending Traceroute, please wait..."
|
||||||
"title": "Saved Channel: {{channelName}}"
|
},
|
||||||
},
|
"tracerouteSent": {
|
||||||
"messages": {
|
"title": "Traceroute sent."
|
||||||
"pkiEncryption": {
|
},
|
||||||
"title": "Chat is using PKI encryption."
|
"savedChannel": {
|
||||||
},
|
"title": "Saved Channel: {{channelName}}"
|
||||||
"pskEncryption": {
|
},
|
||||||
"title": "Chat is using PSK encryption."
|
"messages": {
|
||||||
}
|
"pkiEncryption": {
|
||||||
},
|
"title": "Chat is using PKI encryption."
|
||||||
"configSaveError": {
|
},
|
||||||
"title": "Error Saving Config",
|
"pskEncryption": {
|
||||||
"description": "An error occurred while saving the configuration."
|
"title": "Chat is using PSK encryption."
|
||||||
},
|
}
|
||||||
"validationError": {
|
},
|
||||||
"title": "Config Errors Exist",
|
"configSaveError": {
|
||||||
"description": "Please fix the configuration errors before saving."
|
"title": "Error Saving Config",
|
||||||
},
|
"description": "An error occurred while saving the configuration."
|
||||||
"saveSuccess": {
|
},
|
||||||
"title": "Saving Config",
|
"validationError": {
|
||||||
"description": "The configuration change {{case}} has been saved."
|
"title": "Config Errors Exist",
|
||||||
},
|
"description": "Please fix the configuration errors before saving."
|
||||||
"saveAllSuccess": {
|
},
|
||||||
"title": "Saved",
|
"saveSuccess": {
|
||||||
"description": "All configuration changes have been saved."
|
"title": "Saving Config",
|
||||||
},
|
"description": "The configuration change {{case}} has been saved."
|
||||||
"favoriteNode": {
|
},
|
||||||
"title": "{{action}} {{nodeName}} {{direction}} favorites.",
|
"favoriteNode": {
|
||||||
"action": {
|
"title": "{{action}} {{nodeName}} {{direction}} favorites.",
|
||||||
"added": "Added",
|
"action": {
|
||||||
"removed": "Removed",
|
"added": "Added",
|
||||||
"to": "to",
|
"removed": "Removed",
|
||||||
"from": "from"
|
"to": "to",
|
||||||
}
|
"from": "from"
|
||||||
},
|
}
|
||||||
"ignoreNode": {
|
},
|
||||||
"title": "{{action}} {{nodeName}} {{direction}} ignore list",
|
"ignoreNode": {
|
||||||
"action": {
|
"title": "{{action}} {{nodeName}} {{direction}} ignore list",
|
||||||
"added": "Added",
|
"action": {
|
||||||
"removed": "Removed",
|
"added": "Added",
|
||||||
"to": "to",
|
"removed": "Removed",
|
||||||
"from": "from"
|
"to": "to",
|
||||||
}
|
"from": "from"
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
"notifications": {
|
},
|
||||||
"copied": {
|
"notifications": {
|
||||||
"label": "Copied!"
|
"copied": {
|
||||||
},
|
"label": "Copied!"
|
||||||
"copyToClipboard": {
|
},
|
||||||
"label": "Copy to clipboard"
|
"copyToClipboard": {
|
||||||
},
|
"label": "Copy to clipboard"
|
||||||
"hidePassword": {
|
},
|
||||||
"label": "隐藏密码"
|
"hidePassword": {
|
||||||
},
|
"label": "隐藏密码"
|
||||||
"showPassword": {
|
},
|
||||||
"label": "显示密码"
|
"showPassword": {
|
||||||
},
|
"label": "显示密码"
|
||||||
"deliveryStatus": {
|
},
|
||||||
"delivered": "Delivered",
|
"deliveryStatus": {
|
||||||
"failed": "Delivery Failed",
|
"delivered": "Delivered",
|
||||||
"waiting": "等待中...",
|
"failed": "Delivery Failed",
|
||||||
"unknown": "未知"
|
"waiting": "等待中...",
|
||||||
}
|
"unknown": "未知"
|
||||||
},
|
}
|
||||||
"general": {
|
},
|
||||||
"label": "General"
|
"general": {
|
||||||
},
|
"label": "General"
|
||||||
"hardware": {
|
},
|
||||||
"label": "硬件"
|
"hardware": {
|
||||||
},
|
"label": "硬件"
|
||||||
"metrics": {
|
},
|
||||||
"label": "Metrics"
|
"metrics": {
|
||||||
},
|
"label": "Metrics"
|
||||||
"role": {
|
},
|
||||||
"label": "角色"
|
"role": {
|
||||||
},
|
"label": "角色"
|
||||||
"filter": {
|
},
|
||||||
"label": "筛选器csvfganw"
|
"filter": {
|
||||||
},
|
"label": "筛选器csvfganw"
|
||||||
"advanced": {
|
},
|
||||||
"label": "高级"
|
"advanced": {
|
||||||
},
|
"label": "高级"
|
||||||
"clearInput": {
|
},
|
||||||
"label": "Clear input"
|
"clearInput": {
|
||||||
},
|
"label": "Clear input"
|
||||||
"resetFilters": {
|
},
|
||||||
"label": "Reset Filters"
|
"resetFilters": {
|
||||||
},
|
"label": "Reset Filters"
|
||||||
"nodeName": {
|
},
|
||||||
"label": "Node name/number",
|
"nodeName": {
|
||||||
"placeholder": "Meshtastic 1234"
|
"label": "Node name/number",
|
||||||
},
|
"placeholder": "Meshtastic 1234"
|
||||||
"airtimeUtilization": {
|
},
|
||||||
"label": "Airtime Utilization (%)",
|
"airtimeUtilization": {
|
||||||
"short": "Airtime Util. (%)"
|
"label": "Airtime Utilization (%)"
|
||||||
},
|
},
|
||||||
"batteryLevel": {
|
"batteryLevel": {
|
||||||
"label": "Battery level (%)",
|
"label": "Battery level (%)",
|
||||||
"labelText": "Battery level (%): {{value}}"
|
"labelText": "Battery level (%): {{value}}"
|
||||||
},
|
},
|
||||||
"batteryVoltage": {
|
"batteryVoltage": {
|
||||||
"label": "Battery voltage (V)",
|
"label": "Battery voltage (V)",
|
||||||
"title": "电压"
|
"title": "电压"
|
||||||
},
|
},
|
||||||
"channelUtilization": {
|
"channelUtilization": {
|
||||||
"label": "Channel Utilization (%)",
|
"label": "Channel Utilization (%)"
|
||||||
"short": "Channel Util. (%)"
|
},
|
||||||
},
|
"hops": {
|
||||||
"hops": {
|
"direct": "直频",
|
||||||
"direct": "直频",
|
"label": "Number of hops",
|
||||||
"label": "Number of hops",
|
"text": "Number of hops: {{value}}"
|
||||||
"text": "Number of hops: {{value}}"
|
},
|
||||||
},
|
"lastHeard": {
|
||||||
"lastHeard": {
|
"label": "最后听到",
|
||||||
"label": "最后听到",
|
"labelText": "Last heard: {{value}}",
|
||||||
"labelText": "Last heard: {{value}}",
|
"nowLabel": "Now"
|
||||||
"nowLabel": "Now"
|
},
|
||||||
},
|
"snr": {
|
||||||
"snr": {
|
"label": "SNR (db)"
|
||||||
"label": "SNR (db)"
|
},
|
||||||
},
|
"favorites": {
|
||||||
"favorites": {
|
"label": "Favorites"
|
||||||
"label": "Favorites"
|
},
|
||||||
},
|
"hide": {
|
||||||
"hide": {
|
"label": "Hide"
|
||||||
"label": "Hide"
|
},
|
||||||
},
|
"showOnly": {
|
||||||
"showOnly": {
|
"label": "Show Only"
|
||||||
"label": "Show Only"
|
},
|
||||||
},
|
"viaMqtt": {
|
||||||
"viaMqtt": {
|
"label": "Connected via MQTT"
|
||||||
"label": "Connected via MQTT"
|
},
|
||||||
},
|
"hopsUnknown": {
|
||||||
"hopsUnknown": {
|
"label": "Unknown number of hops"
|
||||||
"label": "Unknown number of hops"
|
},
|
||||||
},
|
"showUnheard": {
|
||||||
"showUnheard": {
|
"label": "Never heard"
|
||||||
"label": "Never heard"
|
},
|
||||||
},
|
"language": {
|
||||||
"language": {
|
"label": "语言",
|
||||||
"label": "语言",
|
"changeLanguage": "Change Language"
|
||||||
"changeLanguage": "Change Language"
|
},
|
||||||
},
|
"theme": {
|
||||||
"theme": {
|
"dark": "深色",
|
||||||
"dark": "深色",
|
"light": "浅色",
|
||||||
"light": "浅色",
|
"system": "Automatic",
|
||||||
"system": "Automatic",
|
"changeTheme": "Change Color Scheme"
|
||||||
"changeTheme": "Change Color Scheme"
|
},
|
||||||
},
|
"errorPage": {
|
||||||
"errorPage": {
|
"title": "This is a little embarrassing...",
|
||||||
"title": "This is a little embarrassing...",
|
"description1": "We are really sorry but an error occurred in the web client that caused it to crash. <br /> This is not supposed to happen, and we are working hard to fix it.",
|
||||||
"description1": "We are really sorry but an error occurred in the web client that caused it to crash. <br /> 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.",
|
||||||
"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:",
|
||||||
"reportInstructions": "Please include the following information in your report:",
|
"reportSteps": {
|
||||||
"reportSteps": {
|
"step1": "What you were doing when the error occurred",
|
||||||
"step1": "What you were doing when the error occurred",
|
"step2": "What you expected to happen",
|
||||||
"step2": "What you expected to happen",
|
"step3": "What actually happened",
|
||||||
"step3": "What actually happened",
|
"step4": "Any other relevant information"
|
||||||
"step4": "Any other relevant information"
|
},
|
||||||
},
|
"reportLink": "You can report the issue to our <0>GitHub</0>",
|
||||||
"reportLink": "You can report the issue to our <0>GitHub</0>",
|
"connectionsLink": "Return to the <0>connections</0>",
|
||||||
"dashboardLink": "Return to the <0>dashboard</0>",
|
"detailsSummary": "Error Details",
|
||||||
"detailsSummary": "Error Details",
|
"errorMessageLabel": "Error message:",
|
||||||
"errorMessageLabel": "Error message:",
|
"stackTraceLabel": "Stack trace:",
|
||||||
"stackTraceLabel": "Stack trace:",
|
"fallbackError": "{{error}}"
|
||||||
"fallbackError": "{{error}}"
|
},
|
||||||
},
|
"footer": {
|
||||||
"footer": {
|
"text": "Powered by <0>▲ Vercel</0> | Meshtastic® is a registered trademark of Meshtastic LLC. | <1>Legal Information</1>",
|
||||||
"text": "Powered by <0>▲ Vercel</0> | Meshtastic® is a registered trademark of Meshtastic LLC. | <1>Legal Information</1>",
|
"commitSha": "Commit SHA: {{sha}}"
|
||||||
"commitSha": "Commit SHA: {{sha}}"
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
import { DeviceWrapper } from "@app/DeviceWrapper.tsx";
|
import { DeviceWrapper } from "@app/DeviceWrapper.tsx";
|
||||||
import { CommandPalette } from "@components/CommandPalette/index.tsx";
|
import { CommandPalette } from "@components/CommandPalette/index.tsx";
|
||||||
import { DialogManager } from "@components/Dialog/DialogManager.tsx";
|
import { DialogManager } from "@components/Dialog/DialogManager.tsx";
|
||||||
import { NewDeviceDialog } from "@components/Dialog/NewDeviceDialog.tsx";
|
|
||||||
import { KeyBackupReminder } from "@components/KeyBackupReminder.tsx";
|
import { KeyBackupReminder } from "@components/KeyBackupReminder.tsx";
|
||||||
import { Toaster } from "@components/Toaster.tsx";
|
import { Toaster } from "@components/Toaster.tsx";
|
||||||
import { ErrorPage } from "@components/UI/ErrorPage.tsx";
|
import { ErrorPage } from "@components/UI/ErrorPage.tsx";
|
||||||
import Footer from "@components/UI/Footer.tsx";
|
import Footer from "@components/UI/Footer.tsx";
|
||||||
import { useTheme } from "@core/hooks/useTheme.ts";
|
import { useTheme } from "@core/hooks/useTheme.ts";
|
||||||
import { SidebarProvider, useAppStore, useDeviceStore } from "@core/stores";
|
import { SidebarProvider, useAppStore, useDeviceStore } from "@core/stores";
|
||||||
// import { SidebarProvider, ThemeProvider } from "@meshtastic/ui";
|
import { Connections } from "@pages/Connections/index.tsx";
|
||||||
import { Dashboard } from "@pages/Dashboard/index.tsx";
|
|
||||||
import { Outlet } from "@tanstack/react-router";
|
import { Outlet } from "@tanstack/react-router";
|
||||||
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
|
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
|
||||||
import { ErrorBoundary } from "react-error-boundary";
|
import { ErrorBoundary } from "react-error-boundary";
|
||||||
@@ -19,20 +17,19 @@ export function App() {
|
|||||||
useTheme();
|
useTheme();
|
||||||
|
|
||||||
const { getDevice } = useDeviceStore();
|
const { getDevice } = useDeviceStore();
|
||||||
const { selectedDeviceId, setConnectDialogOpen, connectDialogOpen } =
|
const { selectedDeviceId } = useAppStore();
|
||||||
useAppStore();
|
|
||||||
|
|
||||||
const device = getDevice(selectedDeviceId);
|
const device = getDevice(selectedDeviceId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// <ThemeProvider defaultTheme="system" storageKey="theme">
|
// <ThemeProvider defaultTheme="system" storageKey="theme">
|
||||||
<ErrorBoundary FallbackComponent={ErrorPage}>
|
<ErrorBoundary FallbackComponent={ErrorPage}>
|
||||||
<NewDeviceDialog
|
{/* <NewDeviceDialog
|
||||||
open={connectDialogOpen}
|
open={connectDialogOpen}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
setConnectDialogOpen(open);
|
setConnectDialogOpen(open);
|
||||||
}}
|
}}
|
||||||
/>
|
/> */}
|
||||||
<Toaster />
|
<Toaster />
|
||||||
<TanStackRouterDevtools position="bottom-right" />
|
<TanStackRouterDevtools position="bottom-right" />
|
||||||
<DeviceWrapper deviceId={selectedDeviceId}>
|
<DeviceWrapper deviceId={selectedDeviceId}>
|
||||||
@@ -53,7 +50,7 @@ export function App() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Dashboard />
|
<Connections />
|
||||||
<Footer />
|
<Footer />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { Badge } from "../UI/Badge.tsx";
|
||||||
|
|
||||||
|
export function SupportBadge({
|
||||||
|
supported,
|
||||||
|
labelSupported,
|
||||||
|
labelUnsupported,
|
||||||
|
}: {
|
||||||
|
supported: boolean;
|
||||||
|
labelSupported: string;
|
||||||
|
labelUnsupported: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant={supported ? "secondary" : "destructive"}>
|
||||||
|
{supported ? labelSupported : labelUnsupported}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
|
import type { ConnectionStatus } from "@app/core/stores/deviceStore/types.ts";
|
||||||
import { cn } from "@core/utils/cn.ts";
|
import { cn } from "@core/utils/cn.ts";
|
||||||
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
import {
|
import {
|
||||||
|
ChevronRight,
|
||||||
CpuIcon,
|
CpuIcon,
|
||||||
Languages,
|
Languages,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
@@ -29,6 +32,8 @@ interface DeviceInfoPanelProps {
|
|||||||
setDialogOpen: () => void;
|
setDialogOpen: () => void;
|
||||||
setCommandPaletteOpen: () => void;
|
setCommandPaletteOpen: () => void;
|
||||||
disableHover?: boolean;
|
disableHover?: boolean;
|
||||||
|
connectionStatus?: ConnectionStatus;
|
||||||
|
connectionName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface InfoDisplayItem {
|
interface InfoDisplayItem {
|
||||||
@@ -54,10 +59,36 @@ export const DeviceInfoPanel = ({
|
|||||||
isCollapsed,
|
isCollapsed,
|
||||||
setCommandPaletteOpen,
|
setCommandPaletteOpen,
|
||||||
disableHover = false,
|
disableHover = false,
|
||||||
|
connectionStatus,
|
||||||
|
connectionName,
|
||||||
}: DeviceInfoPanelProps) => {
|
}: DeviceInfoPanelProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate({ from: "/" });
|
||||||
const { batteryLevel, voltage } = deviceMetrics;
|
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[] = [
|
const deviceInfoItems: InfoDisplayItem[] = [
|
||||||
{
|
{
|
||||||
id: "battery",
|
id: "battery",
|
||||||
@@ -129,6 +160,45 @@ export const DeviceInfoPanel = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{connectionStatus && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate({ to: "/connections" })}
|
||||||
|
aria-label={t("navigation.manageConnections", "Manage connections")}
|
||||||
|
className={cn(
|
||||||
|
"group flex items-center gap-2 px-1 py-2 flex-shrink-0 rounded-md w-full",
|
||||||
|
"transition-colors duration-150",
|
||||||
|
"hover:bg-gray-100 dark:hover:bg-gray-700",
|
||||||
|
"focus:outline-none focus:ring-2 focus:ring-accent",
|
||||||
|
isCollapsed && "justify-center",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"h-2.5 w-2.5 ml-2 rounded-full flex-shrink-0",
|
||||||
|
getStatusColor(connectionStatus),
|
||||||
|
)}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
{!isCollapsed && (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col min-w-0 flex-1 text-left">
|
||||||
|
<Subtle className="text-xs truncate transition-colors duration-150 group-hover:text-gray-800 dark:group-hover:text-gray-100">
|
||||||
|
{connectionName || "Connection"}
|
||||||
|
</Subtle>
|
||||||
|
<Subtle className="text-xs capitalize text-gray-500 dark:text-gray-400 transition-colors duration-150">
|
||||||
|
{getStatusLabel(connectionStatus)}
|
||||||
|
</Subtle>
|
||||||
|
</div>
|
||||||
|
<ChevronRight
|
||||||
|
size={14}
|
||||||
|
className="flex-shrink-0 text-gray-400 dark:text-gray-500 transition-colors duration-150 group-hover:text-gray-600 dark:group-hover:text-gray-300"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{!isCollapsed && (
|
{!isCollapsed && (
|
||||||
<div className="my-2 h-px bg-gray-200 dark:bg-gray-700 flex-shrink-0" />
|
<div className="my-2 h-px bg-gray-200 dark:bg-gray-700 flex-shrink-0" />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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<BrowserFeature, { href: string; i18nKey: string }> =
|
||||||
|
{
|
||||||
|
"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 (
|
||||||
|
<div className="flex flex-col items-start gap-2 bg-red-500 p-4 rounded-md text-sm mt-4">
|
||||||
|
<div className="flex items-center gap-2 w-full">
|
||||||
|
<AlertCircle size={40} className="mr-2 shrink-0 text-white" />
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="text-sm text-white">
|
||||||
|
{needsFeature && (
|
||||||
|
<Trans
|
||||||
|
i18nKey={featureErrors[needsFeature].i18nKey}
|
||||||
|
components={[
|
||||||
|
<Link
|
||||||
|
key="0"
|
||||||
|
href={featureErrors[needsFeature].href}
|
||||||
|
className="underline hover:text-slate-200 text-white dark:text-white dark:hover:text-slate-300"
|
||||||
|
/>,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{needsFeature && needsSecureContext && " "}
|
||||||
|
{needsSecureContext && (
|
||||||
|
<Trans
|
||||||
|
i18nKey={
|
||||||
|
browserFeatures.length > 0
|
||||||
|
? "addConnection.validation.additionallyRequiresSecureContext"
|
||||||
|
: "addConnection.validation.requiresSecureContext"
|
||||||
|
}
|
||||||
|
components={{
|
||||||
|
"0": (
|
||||||
|
<Link
|
||||||
|
href={featureErrors["Secure Context"].href}
|
||||||
|
className="underline hover:text-slate-200 text-white dark:text-white dark:hover:text-slate-300"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const initialState: DialogState = {
|
||||||
|
tab: "http",
|
||||||
|
name: "",
|
||||||
|
protocol: "http",
|
||||||
|
url: "",
|
||||||
|
testStatus: "idle",
|
||||||
|
btSelected: undefined,
|
||||||
|
serialSelected: undefined,
|
||||||
|
};
|
||||||
|
export const createInitialDialogState = (
|
||||||
|
overrides?: Partial<DialogState>,
|
||||||
|
): DialogState => {
|
||||||
|
return { ...initialState, ...(overrides ?? {}) };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const dialogStateInitializer = (
|
||||||
|
overrides?: Partial<DialogState>,
|
||||||
|
): 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<void>;
|
||||||
|
disabled?: boolean;
|
||||||
|
display: string;
|
||||||
|
helper?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label>{label}</Label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
className="gap-2"
|
||||||
|
onClick={onPick}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<MousePointerClick className="h-4 w-4" />
|
||||||
|
{buttonText}
|
||||||
|
</Button>
|
||||||
|
<div className="text-sm text-slate-500 dark:text-slate-400 truncate">
|
||||||
|
{display}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{helper ? (
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400">{helper}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<void>;
|
||||||
|
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<string, unknown>) => Promise<SerialPort>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
).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<TabKey, Pane> = useMemo(
|
||||||
|
() => ({
|
||||||
|
http: {
|
||||||
|
placeholder: t("addConnection.httpConnection.namePlaceholder"),
|
||||||
|
children: () => (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<Label htmlFor="url">
|
||||||
|
{t("addConnection.httpConnection.heading")}
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
id={"url"}
|
||||||
|
inputMode="url"
|
||||||
|
placeholder={t("addConnection.httpConnection.inputPlaceholder")}
|
||||||
|
prefix={`${state.protocol}://`}
|
||||||
|
value={state.url}
|
||||||
|
onChange={(e) => {
|
||||||
|
dispatch({
|
||||||
|
type: "SET_URL_AND_RESET_TEST",
|
||||||
|
payload: e.target.value,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-2 mt-1">
|
||||||
|
<Switch
|
||||||
|
value={state.protocol}
|
||||||
|
disabled={!!isURLHTTPS}
|
||||||
|
checked={state.protocol === "https"}
|
||||||
|
onCheckedChange={(value) => {
|
||||||
|
dispatch({
|
||||||
|
type: "SET_PROTOCOL",
|
||||||
|
payload: value ? "https" : "http",
|
||||||
|
});
|
||||||
|
dispatch({ type: "SET_TEST_STATUS", payload: "idle" });
|
||||||
|
}}
|
||||||
|
></Switch>
|
||||||
|
<Label>{t("addConnection.httpConnection.useHttps")}</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 mt-4">
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
className="gap-2"
|
||||||
|
onClick={handleTestHttp}
|
||||||
|
disabled={
|
||||||
|
urlOrIpv4Schema.safeParse(`${state.protocol}://${state.url}`)
|
||||||
|
.success === false || state.testStatus === "testing"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{state.testStatus === "testing" ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
{t(
|
||||||
|
"addConnection.httpConnection.connectionTest.button.loading",
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<MousePointerClick className="h-4 w-4" />
|
||||||
|
{t(
|
||||||
|
"addConnection.httpConnection.connectionTest.button.label",
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
{state.testStatus === "success" && (
|
||||||
|
<div className="flex items-center gap-1 text-sm text-green-600 dark:text-green-400">
|
||||||
|
<CheckCircle2 className="h-4 w-4" />
|
||||||
|
{t("addConnection.httpConnection.connectionTest.reachable")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{state.testStatus === "failure" && (
|
||||||
|
<div className="flex items-center gap-1 text-sm text-red-600 dark:text-red-400">
|
||||||
|
<XCircle className="h-4 w-4" />
|
||||||
|
{t(
|
||||||
|
"addConnection.httpConnection.connectionTest.notReachable",
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
{t("addConnection.httpConnection.connectionTest.description")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
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: () => (
|
||||||
|
<>
|
||||||
|
<SupportBadge
|
||||||
|
supported={bluetoothSupported}
|
||||||
|
labelSupported={t(
|
||||||
|
"addConnection.bluetoothConnection.supported.title",
|
||||||
|
)}
|
||||||
|
labelUnsupported={t(
|
||||||
|
"addConnection.bluetoothConnection.notSupported.title",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<PickerRow
|
||||||
|
label={t("addConnection.bluetoothConnection.device")}
|
||||||
|
buttonText={t("addConnection.bluetoothConnection.selectDevice")}
|
||||||
|
onPick={handlePickBluetooth}
|
||||||
|
disabled={!bluetoothSupported}
|
||||||
|
display={
|
||||||
|
state.btSelected
|
||||||
|
? state.btSelected.name || state.btSelected.id
|
||||||
|
: t("addConnection.bluetoothConnection.notSelected")
|
||||||
|
}
|
||||||
|
helper={t("addConnection.bluetoothConnection.helperText")}
|
||||||
|
/>
|
||||||
|
<FeatureErrorMessage
|
||||||
|
missingFeatures={unsupported}
|
||||||
|
tabId="bluetooth"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
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: () => (
|
||||||
|
<>
|
||||||
|
<SupportBadge
|
||||||
|
supported={serialSupported}
|
||||||
|
labelSupported={t(
|
||||||
|
"addConnection.serialConnection.supported.title",
|
||||||
|
)}
|
||||||
|
labelUnsupported={t(
|
||||||
|
"addConnection.serialConnection.notSupported.title",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<PickerRow
|
||||||
|
label={t("addConnection.serialConnection.port")}
|
||||||
|
buttonText={t("addConnection.serialConnection.selectPort")}
|
||||||
|
onPick={handlePickSerial}
|
||||||
|
disabled={!serialSupported}
|
||||||
|
display={
|
||||||
|
state.serialSelected
|
||||||
|
? t("addConnection.serialConnection.deviceName", {
|
||||||
|
vendorId:
|
||||||
|
state.serialSelected.vendorId?.toString(16) ?? "?",
|
||||||
|
productId:
|
||||||
|
state.serialSelected.productId?.toString(16) ?? "?",
|
||||||
|
})
|
||||||
|
: t("addConnection.serialConnection.notSelected")
|
||||||
|
}
|
||||||
|
helper={t("addConnection.serialConnection.helperText")}
|
||||||
|
/>
|
||||||
|
<FeatureErrorMessage missingFeatures={unsupported} tabId="serial" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
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<void>) => 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 (
|
||||||
|
<DialogWrapper
|
||||||
|
variant="default"
|
||||||
|
type="custom"
|
||||||
|
title={t("addConnection.title")}
|
||||||
|
description={t("addConnection.description")}
|
||||||
|
cancelText={t("button.cancel")}
|
||||||
|
open={open}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
>
|
||||||
|
<Tabs
|
||||||
|
value={state.tab}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
dispatch({ type: "SET_TAB", payload: v as TabKey })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<TabsList className="grid grid-cols-3">
|
||||||
|
{TAB_META.map(({ key, label, Icon }) => (
|
||||||
|
<TabsTrigger key={key} value={key} className="gap-2">
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
{label}
|
||||||
|
</TabsTrigger>
|
||||||
|
))}
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{TAB_META.map(({ key }) => (
|
||||||
|
<TabsContent key={key} value={key}>
|
||||||
|
{state.tab === key ? (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<Label htmlFor={`name-${state.tab}`}>Name</Label>
|
||||||
|
<Input
|
||||||
|
id={`name-${state.tab}`}
|
||||||
|
value={state.name}
|
||||||
|
onChange={(evt) =>
|
||||||
|
dispatch({ type: "SET_NAME", payload: evt.target.value })
|
||||||
|
}
|
||||||
|
placeholder={currentPane.placeholder}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{PANES[key].children()}
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<div className="inline-flex rounded-md shadow-sm overflow-hidden border">
|
||||||
|
<Button
|
||||||
|
onClick={submit(onSave)}
|
||||||
|
disabled={!canCreate}
|
||||||
|
className="rounded-none"
|
||||||
|
>
|
||||||
|
{t("button.saveConnection")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</TabsContent>
|
||||||
|
))}
|
||||||
|
</Tabs>
|
||||||
|
</DialogWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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}`;
|
||||||
|
});
|
||||||
@@ -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<User>({
|
|
||||||
values: defaultValues,
|
|
||||||
resolver: zodResolver(deviceNameSchema),
|
|
||||||
});
|
|
||||||
|
|
||||||
const onSubmit = handleSubmit((data) => {
|
|
||||||
connection?.setOwner(
|
|
||||||
create(Protobuf.Mesh.UserSchema, {
|
|
||||||
...data,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
onOpenChange(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleReset = () => {
|
|
||||||
reset(defaultValues);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogClose />
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>{t("deviceName.title")}</DialogTitle>
|
|
||||||
<DialogDescription>{t("deviceName.description")}</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<form onSubmit={onSubmit} className="flex flex-col gap-4">
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="longName">{t("deviceName.longName")}</Label>
|
|
||||||
<GenericInput
|
|
||||||
control={control}
|
|
||||||
field={{
|
|
||||||
name: "longName",
|
|
||||||
label: t("deviceName.longName"),
|
|
||||||
type: "text",
|
|
||||||
properties: {
|
|
||||||
className: "text-slate-900 dark:text-slate-200",
|
|
||||||
fieldLength: {
|
|
||||||
currentValueLength: getValues("longName").length,
|
|
||||||
max: 40,
|
|
||||||
min: 1,
|
|
||||||
showCharacterCount: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="shortName">{t("deviceName.shortName")}</Label>
|
|
||||||
<GenericInput
|
|
||||||
control={control}
|
|
||||||
field={{
|
|
||||||
name: "shortName",
|
|
||||||
label: t("deviceName.shortName"),
|
|
||||||
type: "text",
|
|
||||||
properties: {
|
|
||||||
fieldLength: {
|
|
||||||
currentValueLength: getValues("shortName").length,
|
|
||||||
max: 4,
|
|
||||||
min: 1,
|
|
||||||
showCharacterCount: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="destructive"
|
|
||||||
name="reset"
|
|
||||||
onClick={handleReset}
|
|
||||||
>
|
|
||||||
{t("button.reset")}
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" name="save">
|
|
||||||
{t("button.save")}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -3,7 +3,6 @@ import { FactoryResetDeviceDialog } from "@app/components/Dialog/FactoryResetDev
|
|||||||
import { ClearAllStoresDialog } from "@components/Dialog/ClearAllStoresDialog/ClearAllStoresDialog.tsx";
|
import { ClearAllStoresDialog } from "@components/Dialog/ClearAllStoresDialog/ClearAllStoresDialog.tsx";
|
||||||
import { ClientNotificationDialog } from "@components/Dialog/ClientNotificationDialog/ClientNotificationDialog.tsx";
|
import { ClientNotificationDialog } from "@components/Dialog/ClientNotificationDialog/ClientNotificationDialog.tsx";
|
||||||
import { DeleteMessagesDialog } from "@components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.tsx";
|
import { DeleteMessagesDialog } from "@components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.tsx";
|
||||||
import { DeviceNameDialog } from "@components/Dialog/DeviceNameDialog.tsx";
|
|
||||||
import { ImportDialog } from "@components/Dialog/ImportDialog.tsx";
|
import { ImportDialog } from "@components/Dialog/ImportDialog.tsx";
|
||||||
import { NodeDetailsDialog } from "@components/Dialog/NodeDetailsDialog/NodeDetailsDialog.tsx";
|
import { NodeDetailsDialog } from "@components/Dialog/NodeDetailsDialog/NodeDetailsDialog.tsx";
|
||||||
import { PkiBackupDialog } from "@components/Dialog/PKIBackupDialog.tsx";
|
import { PkiBackupDialog } from "@components/Dialog/PKIBackupDialog.tsx";
|
||||||
@@ -47,12 +46,6 @@ export const DialogManager = () => {
|
|||||||
setDialogOpen("reboot", false);
|
setDialogOpen("reboot", false);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<DeviceNameDialog
|
|
||||||
open={dialog.deviceName}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
setDialogOpen("deviceName", open);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<RemoveNodeDialog
|
<RemoveNodeDialog
|
||||||
open={dialog.nodeRemoval}
|
open={dialog.nodeRemoval}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ export const ImportDialog = ({ open, onOpenChange }: ImportDialogProps) => {
|
|||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
setChange(
|
setChange(
|
||||||
{ type: "channel", index: importIndex[index] ?? 0 },
|
{ type: "channels", index: importIndex[index] ?? 0 },
|
||||||
payload,
|
payload,
|
||||||
channels.get(importIndex[index] ?? 0),
|
channels.get(importIndex[index] ?? 0),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export const Channel = ({ onFormInit, channel }: SettingsPanelProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const workingChannel = getChange({
|
const workingChannel = getChange({
|
||||||
type: "channel",
|
type: "channels",
|
||||||
index: channel.index,
|
index: channel.index,
|
||||||
}) as Protobuf.Channel.Channel | undefined;
|
}) as Protobuf.Channel.Channel | undefined;
|
||||||
const effectiveConfig = workingChannel ?? channel;
|
const effectiveConfig = workingChannel ?? channel;
|
||||||
@@ -123,11 +123,11 @@ export const Channel = ({ onFormInit, channel }: SettingsPanelProps) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (deepCompareConfig(channel, payload, true)) {
|
if (deepCompareConfig(channel, payload, true)) {
|
||||||
removeChange({ type: "channel", index: channel.index });
|
removeChange({ type: "channels", index: channel.index });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setChange({ type: "channel", index: channel.index }, payload, channel);
|
setChange({ type: "channels", index: channel.index }, payload, channel);
|
||||||
};
|
};
|
||||||
|
|
||||||
const preSharedKeyRegenerate = async () => {
|
const preSharedKeyRegenerate = async () => {
|
||||||
|
|||||||
@@ -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<BluetoothDevice[]>([]);
|
|
||||||
|
|
||||||
const { addDevice } = useDeviceStore();
|
|
||||||
const { addNodeDB } = useNodeDBStore();
|
|
||||||
const { addMessageStore } = useMessageStore();
|
|
||||||
const { setSelectedDevice } = useAppStore();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const updateBleDeviceList = useCallback(async (): Promise<void> => {
|
|
||||||
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 (
|
|
||||||
<fieldset
|
|
||||||
className="flex w-full flex-col gap-2 p-4"
|
|
||||||
disabled={connectionInProgress}
|
|
||||||
>
|
|
||||||
<div className="flex h-48 flex-col gap-2 overflow-y-auto">
|
|
||||||
{bleDevices.map((device) => (
|
|
||||||
<Button
|
|
||||||
key={device.id}
|
|
||||||
variant="default"
|
|
||||||
onClick={() => {
|
|
||||||
setConnectionInProgress(true);
|
|
||||||
onConnect(device);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{device.name}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
{bleDevices.length === 0 && (
|
|
||||||
<Mono className="m-auto select-none">
|
|
||||||
{t("newDeviceDialog.bluetoothConnection.noDevicesPaired")}
|
|
||||||
</Mono>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="default"
|
|
||||||
onClick={async () => {
|
|
||||||
await navigator.bluetooth
|
|
||||||
.requestDevice({
|
|
||||||
filters: [{ services: [TransportWebBluetooth.ServiceUuid] }],
|
|
||||||
})
|
|
||||||
.then((device) => {
|
|
||||||
const exists = bleDevices.findIndex((d) => d.id === device.id);
|
|
||||||
if (exists === -1) {
|
|
||||||
setBleDevices(bleDevices.concat(device));
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((error: Error) => {
|
|
||||||
console.error("Error requesting device:", error);
|
|
||||||
setConnectionInProgress(false);
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
setConnectionInProgress(false);
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span>{t("newDeviceDialog.bluetoothConnection.newDeviceButton")}</span>
|
|
||||||
</Button>
|
|
||||||
</fieldset>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -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(<HTTP closeDialog={vi.fn()} />);
|
|
||||||
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(<HTTP closeDialog={vi.fn()} />);
|
|
||||||
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(<HTTP closeDialog={vi.fn()} />);
|
|
||||||
|
|
||||||
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(<HTTP closeDialog={vi.fn()} />);
|
|
||||||
|
|
||||||
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(<HTTP closeDialog={closeDialog} />);
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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<FormData>({
|
|
||||||
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 (
|
|
||||||
<form className="flex w-full flex-col gap-2 p-4" onSubmit={onSubmit}>
|
|
||||||
<fieldset
|
|
||||||
className="flex flex-col gap-2"
|
|
||||||
style={{ minHeight: "12rem" }}
|
|
||||||
disabled={connectionInProgress}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<Label>{t("newDeviceDialog.httpConnection.label")}</Label>
|
|
||||||
<Input
|
|
||||||
prefix={
|
|
||||||
tlsValue
|
|
||||||
? `${t("newDeviceDialog.https")}://`
|
|
||||||
: `${t("newDeviceDialog.http")}://`
|
|
||||||
}
|
|
||||||
placeholder={t("newDeviceDialog.httpConnection.placeholder")}
|
|
||||||
className="text-slate-900 dark:text-slate-100"
|
|
||||||
{...register("ip")}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 flex items-center gap-2">
|
|
||||||
<Switch
|
|
||||||
onCheckedChange={setTLS}
|
|
||||||
disabled={isURLHTTPS}
|
|
||||||
checked={isURLHTTPS || tlsValue}
|
|
||||||
{...register("tls")}
|
|
||||||
/>
|
|
||||||
<Label>{t("newDeviceDialog.useHttps")}</Label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{connectionError && (
|
|
||||||
<div className="mt-2 mb-2 p-3 rounded-md bg-amber-100 border border-amber-300 dark:bg-amber-100 dark:border-amber-300">
|
|
||||||
<div className="flex gap-2 items-start">
|
|
||||||
<AlertTriangle
|
|
||||||
className="shrink-0 mt-0.5 text-amber-600 dark:text-amber-600"
|
|
||||||
size={20}
|
|
||||||
/>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-amber-800 dark:text-amber-800">
|
|
||||||
{t("newDeviceDialog.connectionFailedAlert.title")}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs mt-1 text-amber-700 dark:text-amber-700">
|
|
||||||
{t("newDeviceDialog.connectionFailedAlert.descriptionPrefix")}
|
|
||||||
{connectionError.secure &&
|
|
||||||
t("newDeviceDialog.connectionFailedAlert.httpsHint")}
|
|
||||||
{t("newDeviceDialog.connectionFailedAlert.openLinkPrefix")}
|
|
||||||
<Link
|
|
||||||
href={`${
|
|
||||||
connectionError.secure
|
|
||||||
? t("newDeviceDialog.https")
|
|
||||||
: t("newDeviceDialog.http")
|
|
||||||
}://${connectionError.host}`}
|
|
||||||
className="underline font-medium text-amber-800 dark:text-amber-800"
|
|
||||||
>
|
|
||||||
{`${
|
|
||||||
connectionError.secure
|
|
||||||
? t("newDeviceDialog.https")
|
|
||||||
: t("newDeviceDialog.http")
|
|
||||||
}://${connectionError.host}`}
|
|
||||||
</Link>{" "}
|
|
||||||
{t("newDeviceDialog.connectionFailedAlert.openLinkSuffix")}
|
|
||||||
{connectionError.secure
|
|
||||||
? t(
|
|
||||||
"newDeviceDialog.connectionFailedAlert.acceptTlsWarningSuffix",
|
|
||||||
)
|
|
||||||
: ""}
|
|
||||||
.{" "}
|
|
||||||
<Link
|
|
||||||
href="https://meshtastic.org/docs/software/web-client/#http"
|
|
||||||
className="underline font-medium text-amber-800 dark:text-amber-800"
|
|
||||||
>
|
|
||||||
{t("newDeviceDialog.connectionFailedAlert.learnMoreLink")}
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</fieldset>
|
|
||||||
<Button type="submit" variant="default">
|
|
||||||
<span>
|
|
||||||
{connectionInProgress
|
|
||||||
? t("newDeviceDialog.connecting")
|
|
||||||
: t("newDeviceDialog.connect")}
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -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<SerialPort[]>([]);
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<fieldset
|
|
||||||
className="flex w-full flex-col gap-2 p-4"
|
|
||||||
disabled={connectionInProgress}
|
|
||||||
>
|
|
||||||
<div className="flex h-48 flex-col gap-2 overflow-y-auto">
|
|
||||||
{serialPorts.map((port, idx) => {
|
|
||||||
const { usbProductId, usbVendorId } = port.getInfo();
|
|
||||||
const vendor = usbVendorId ?? t("unknown.shortName");
|
|
||||||
const product = usbProductId ?? t("unknown.shortName");
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
key={`${vendor}-${product}`}
|
|
||||||
disabled={port.readable !== null}
|
|
||||||
variant="default"
|
|
||||||
onClick={async () => {
|
|
||||||
setConnectionInProgress(true);
|
|
||||||
await onConnect(port);
|
|
||||||
// No need to setConnectionInProgress(false) here as closeDialog() unmounts.
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("newDeviceDialog.serialConnection.deviceIdentifier", {
|
|
||||||
vendorId: vendor,
|
|
||||||
productId: product,
|
|
||||||
index: idx,
|
|
||||||
})}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{serialPorts.length === 0 && (
|
|
||||||
<Mono className="m-auto select-none">
|
|
||||||
{t("newDeviceDialog.serialConnection.noDevicesPaired")}
|
|
||||||
</Mono>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="default"
|
|
||||||
onClick={async () => {
|
|
||||||
await navigator.serial
|
|
||||||
.requestPort()
|
|
||||||
.then((port) => {
|
|
||||||
setSerialPorts(serialPorts.concat(port));
|
|
||||||
// No need to setConnectionInProgress(false) here if requestPort is quick
|
|
||||||
})
|
|
||||||
.catch((error: Error) => {
|
|
||||||
console.error("Error requesting port:", error);
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
setConnectionInProgress(false);
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span>{t("newDeviceDialog.serialConnection.newDeviceButton")}</span>
|
|
||||||
</Button>
|
|
||||||
</fieldset>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -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 (
|
||||||
|
<Button variant="subtle" className="inline-flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={`h-2.5 w-2.5 rounded-full ${color}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span className="text-xs capitalize text-slate-500 dark:text-slate-400">
|
||||||
|
{status}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
type Page,
|
type Page,
|
||||||
useAppStore,
|
useAppStore,
|
||||||
useDevice,
|
useDevice,
|
||||||
|
useDeviceStore,
|
||||||
useNodeDB,
|
useNodeDB,
|
||||||
useSidebar,
|
useSidebar,
|
||||||
} from "@core/stores";
|
} from "@core/stores";
|
||||||
@@ -70,11 +71,18 @@ export const Sidebar = ({ children }: SidebarProps) => {
|
|||||||
const { hardware, metadata, unreadCounts, setDialogOpen } = useDevice();
|
const { hardware, metadata, unreadCounts, setDialogOpen } = useDevice();
|
||||||
const { getNode, getNodesLength } = useNodeDB();
|
const { getNode, getNodesLength } = useNodeDB();
|
||||||
const { setCommandPaletteOpen } = useAppStore();
|
const { setCommandPaletteOpen } = useAppStore();
|
||||||
|
const savedConnections = useDeviceStore((s) => s.savedConnections);
|
||||||
const myNode = getNode(hardware.myNodeNum);
|
const myNode = getNode(hardware.myNodeNum);
|
||||||
const { isCollapsed } = useSidebar();
|
const { isCollapsed } = useSidebar();
|
||||||
const { t } = useTranslation("ui");
|
const { t } = useTranslation("ui");
|
||||||
const navigate = useNavigate({ from: "/" });
|
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({
|
const pathname = useLocation({
|
||||||
select: (location) => location.pathname.replace(/^\//, ""),
|
select: (location) => location.pathname.replace(/^\//, ""),
|
||||||
});
|
});
|
||||||
@@ -210,6 +218,8 @@ export const Sidebar = ({ children }: SidebarProps) => {
|
|||||||
? Math.abs(myNode.deviceMetrics?.voltage)
|
? Math.abs(myNode.deviceMetrics?.voltage)
|
||||||
: undefined,
|
: undefined,
|
||||||
}}
|
}}
|
||||||
|
connectionStatus={activeConnection?.status}
|
||||||
|
connectionName={activeConnection?.name}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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) => (
|
||||||
|
<AlertDialogPrimitive.Portal {...props}>
|
||||||
|
<div className="fixed inset-0 z-50 flex items-end justify-center sm:items-center">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</AlertDialogPrimitive.Portal>
|
||||||
|
);
|
||||||
|
AlertDialogPortal.displayName = AlertDialogPrimitive.Portal.displayName;
|
||||||
|
|
||||||
|
const AlertDialogOverlay = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Overlay
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 z-50 bg-black/50 backdrop-blur-xs transition-opacity animate-in fade-in",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
|
||||||
|
|
||||||
|
const AlertDialogContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPortal>
|
||||||
|
<AlertDialogOverlay />
|
||||||
|
<AlertDialogPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed z-50 grid w-full max-w-lg scale-100 gap-4 border border-slate-200 bg-white p-6 opacity-100 shadow-lg animate-in fade-in-90 slide-in-from-bottom-10 sm:rounded-lg sm:zoom-in-90 sm:slide-in-from-bottom-0 dark:border-slate-700 dark:bg-slate-800",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</AlertDialogPortal>
|
||||||
|
));
|
||||||
|
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
const AlertDialogHeader = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col space-y-2 text-center sm:text-left",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
AlertDialogHeader.displayName = "AlertDialogHeader";
|
||||||
|
|
||||||
|
const AlertDialogFooter = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
AlertDialogFooter.displayName = "AlertDialogFooter";
|
||||||
|
|
||||||
|
const AlertDialogTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"text-lg font-semibold text-slate-900 dark:text-slate-100",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
|
||||||
|
|
||||||
|
const AlertDialogDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm text-slate-500 dark:text-slate-400", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
AlertDialogDescription.displayName =
|
||||||
|
AlertDialogPrimitive.Description.displayName;
|
||||||
|
|
||||||
|
const AlertDialogAction = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Action>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Action
|
||||||
|
ref={ref}
|
||||||
|
className={cn(buttonVariants(), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
|
||||||
|
|
||||||
|
const AlertDialogCancel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Cancel
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
buttonVariants({ variant: "outline" }),
|
||||||
|
"mt-2 sm:mt-0",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
|
||||||
|
|
||||||
|
export {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
};
|
||||||
@@ -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<HTMLDivElement>,
|
||||||
|
VariantProps<typeof badgeVariants> {}
|
||||||
|
|
||||||
|
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants };
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { cn } from "@core/utils/cn.ts";
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
const Card = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"rounded-lg border border-slate-200 bg-white text-slate-900 shadow-sm dark:border-slate-700 dark:bg-slate-800 dark:text-slate-100",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
Card.displayName = "Card";
|
||||||
|
|
||||||
|
const CardHeader = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
CardHeader.displayName = "CardHeader";
|
||||||
|
|
||||||
|
const CardTitle = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLHeadingElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<h3
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"text-lg font-semibold leading-none tracking-tight",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
CardTitle.displayName = "CardTitle";
|
||||||
|
|
||||||
|
const CardDescription = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<p
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm text-slate-500 dark:text-slate-400", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
CardDescription.displayName = "CardDescription";
|
||||||
|
|
||||||
|
const CardContent = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||||
|
));
|
||||||
|
CardContent.displayName = "CardContent";
|
||||||
|
|
||||||
|
const CardFooter = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("flex items-center p-6 pt-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
CardFooter.displayName = "CardFooter";
|
||||||
|
|
||||||
|
export {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardFooter,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
};
|
||||||
@@ -48,8 +48,8 @@ export function ErrorPage({ error }: { error: Error }) {
|
|||||||
</P>
|
</P>
|
||||||
<P>
|
<P>
|
||||||
<Trans
|
<Trans
|
||||||
i18nKey="errorPage.dashboardLink"
|
i18nKey="errorPage.connectionsLink"
|
||||||
components={[<Link key="dashboard" href="/" />]}
|
components={[<Link key="connections" href="/" />]}
|
||||||
/>
|
/>
|
||||||
</P>
|
</P>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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<J = unknown> = {
|
||||||
|
v: 1; // schema version
|
||||||
|
capacity: number; // last used capacity (for info/migrations)
|
||||||
|
items: J[]; // serialized items (MRU -> LRU)
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UseLRUListOptions<T, J = unknown> = {
|
||||||
|
/** 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<T> = {
|
||||||
|
/** 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<T> – 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<T, J = unknown>(
|
||||||
|
opts: UseLRUListOptions<T, J>,
|
||||||
|
): UseLRUListReturn<T> {
|
||||||
|
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<J>;
|
||||||
|
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<J> = {
|
||||||
|
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<T[]>(
|
||||||
|
() => 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<T>(arr: T[], capacity: number): T[] {
|
||||||
|
if (arr.length <= capacity) {
|
||||||
|
return arr;
|
||||||
|
}
|
||||||
|
return arr.slice(0, capacity);
|
||||||
|
}
|
||||||
@@ -7,7 +7,6 @@ if (isDev) {
|
|||||||
featureFlags.setOverrides({
|
featureFlags.setOverrides({
|
||||||
persistNodeDB: true,
|
persistNodeDB: true,
|
||||||
persistMessages: true,
|
persistMessages: true,
|
||||||
persistDevices: false,
|
|
||||||
persistApp: true,
|
persistApp: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export type ValidModuleConfigType =
|
|||||||
export type ConfigChangeKey =
|
export type ConfigChangeKey =
|
||||||
| { type: "config"; variant: ValidConfigType }
|
| { type: "config"; variant: ValidConfigType }
|
||||||
| { type: "moduleConfig"; variant: ValidModuleConfigType }
|
| { type: "moduleConfig"; variant: ValidModuleConfigType }
|
||||||
| { type: "channel"; index: Types.ChannelNumber }
|
| { type: "channels"; index: Types.ChannelNumber }
|
||||||
| { type: "user" };
|
| { type: "user" };
|
||||||
|
|
||||||
// Serialized key for Map storage
|
// Serialized key for Map storage
|
||||||
@@ -57,7 +57,7 @@ export function serializeKey(key: ConfigChangeKey): ConfigChangeKeyString {
|
|||||||
return `config:${key.variant}`;
|
return `config:${key.variant}`;
|
||||||
case "moduleConfig":
|
case "moduleConfig":
|
||||||
return `moduleConfig:${key.variant}`;
|
return `moduleConfig:${key.variant}`;
|
||||||
case "channel":
|
case "channels":
|
||||||
return `channel:${key.index}`;
|
return `channel:${key.index}`;
|
||||||
case "user":
|
case "user":
|
||||||
return "user";
|
return "user";
|
||||||
@@ -78,9 +78,9 @@ export function deserializeKey(keyStr: ConfigChangeKeyString): ConfigChangeKey {
|
|||||||
type: "moduleConfig",
|
type: "moduleConfig",
|
||||||
variant: variant as ValidModuleConfigType,
|
variant: variant as ValidModuleConfigType,
|
||||||
};
|
};
|
||||||
case "channel":
|
case "channels":
|
||||||
return {
|
return {
|
||||||
type: "channel",
|
type: "channels",
|
||||||
index: Number(variant) as Types.ChannelNumber,
|
index: Number(variant) as Types.ChannelNumber,
|
||||||
};
|
};
|
||||||
case "user":
|
case "user":
|
||||||
@@ -126,7 +126,7 @@ export function hasChannelChange(
|
|||||||
registry: ChangeRegistry,
|
registry: ChangeRegistry,
|
||||||
index: Types.ChannelNumber,
|
index: Types.ChannelNumber,
|
||||||
): boolean {
|
): 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;
|
let count = 0;
|
||||||
for (const keyStr of registry.changes.keys()) {
|
for (const keyStr of registry.changes.keys()) {
|
||||||
const key = deserializeKey(keyStr);
|
const key = deserializeKey(keyStr);
|
||||||
if (key.type === "channel") {
|
if (key.type === "channels") {
|
||||||
count++;
|
count++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -212,7 +212,7 @@ export function getAllModuleConfigChanges(
|
|||||||
export function getAllChannelChanges(registry: ChangeRegistry): ChangeEntry[] {
|
export function getAllChannelChanges(registry: ChangeRegistry): ChangeEntry[] {
|
||||||
const changes: ChangeEntry[] = [];
|
const changes: ChangeEntry[] = [];
|
||||||
for (const entry of registry.changes.values()) {
|
for (const entry of registry.changes.values()) {
|
||||||
if (entry.key.type === "channel") {
|
if (entry.key.type === "channels") {
|
||||||
changes.push(entry);
|
changes.push(entry);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { create, toBinary } from "@bufbuild/protobuf";
|
import { create, toBinary } from "@bufbuild/protobuf";
|
||||||
import { featureFlags } from "@core/services/featureFlags";
|
|
||||||
import { evictOldestEntries } from "@core/stores/utils/evictOldestEntries.ts";
|
import { evictOldestEntries } from "@core/stores/utils/evictOldestEntries.ts";
|
||||||
import { createStorage } from "@core/stores/utils/indexDB.ts";
|
import { createStorage } from "@core/stores/utils/indexDB.ts";
|
||||||
import { type MeshDevice, Protobuf, Types } from "@meshtastic/core";
|
import { type MeshDevice, Protobuf, Types } from "@meshtastic/core";
|
||||||
@@ -26,6 +25,8 @@ import {
|
|||||||
serializeKey,
|
serializeKey,
|
||||||
} from "./changeRegistry.ts";
|
} from "./changeRegistry.ts";
|
||||||
import type {
|
import type {
|
||||||
|
Connection,
|
||||||
|
ConnectionId,
|
||||||
Dialogs,
|
Dialogs,
|
||||||
DialogVariant,
|
DialogVariant,
|
||||||
ValidConfigType,
|
ValidConfigType,
|
||||||
@@ -142,6 +143,16 @@ export interface deviceState {
|
|||||||
removeDevice: (id: number) => void;
|
removeDevice: (id: number) => void;
|
||||||
getDevices: () => Device[];
|
getDevices: () => Device[];
|
||||||
getDevice: (id: number) => Device | undefined;
|
getDevice: (id: number) => Device | undefined;
|
||||||
|
|
||||||
|
// Saved connections management
|
||||||
|
savedConnections: Connection[];
|
||||||
|
addSavedConnection: (connection: Connection) => void;
|
||||||
|
updateSavedConnection: (
|
||||||
|
id: ConnectionId,
|
||||||
|
updates: Partial<Connection>,
|
||||||
|
) => void;
|
||||||
|
removeSavedConnection: (id: ConnectionId) => void;
|
||||||
|
getSavedConnections: () => Connection[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PrivateDeviceState extends deviceState {
|
interface PrivateDeviceState extends deviceState {
|
||||||
@@ -150,6 +161,7 @@ interface PrivateDeviceState extends deviceState {
|
|||||||
|
|
||||||
type DevicePersisted = {
|
type DevicePersisted = {
|
||||||
devices: Map<number, DeviceData>;
|
devices: Map<number, DeviceData>;
|
||||||
|
savedConnections: Connection[];
|
||||||
};
|
};
|
||||||
|
|
||||||
function deviceFactory(
|
function deviceFactory(
|
||||||
@@ -894,6 +906,7 @@ export const deviceStoreInitializer: StateCreator<PrivateDeviceState> = (
|
|||||||
get,
|
get,
|
||||||
) => ({
|
) => ({
|
||||||
devices: new Map(),
|
devices: new Map(),
|
||||||
|
savedConnections: [],
|
||||||
|
|
||||||
addDevice: (id) => {
|
addDevice: (id) => {
|
||||||
const existing = get().devices.get(id);
|
const existing = get().devices.get(id);
|
||||||
@@ -924,6 +937,41 @@ export const deviceStoreInitializer: StateCreator<PrivateDeviceState> = (
|
|||||||
},
|
},
|
||||||
getDevices: () => Array.from(get().devices.values()),
|
getDevices: () => Array.from(get().devices.values()),
|
||||||
getDevice: (id) => get().devices.get(id),
|
getDevice: (id) => get().devices.get(id),
|
||||||
|
|
||||||
|
addSavedConnection: (connection) => {
|
||||||
|
set(
|
||||||
|
produce<PrivateDeviceState>((draft) => {
|
||||||
|
draft.savedConnections.push(connection);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
updateSavedConnection: (id, updates) => {
|
||||||
|
set(
|
||||||
|
produce<PrivateDeviceState>((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<string, unknown>)[key] =
|
||||||
|
updates[key as keyof typeof updates];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
removeSavedConnection: (id) => {
|
||||||
|
set(
|
||||||
|
produce<PrivateDeviceState>((draft) => {
|
||||||
|
draft.savedConnections = draft.savedConnections.filter(
|
||||||
|
(c: Connection) => c.id !== id,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
getSavedConnections: () => get().savedConnections,
|
||||||
});
|
});
|
||||||
|
|
||||||
const persistOptions: PersistOptions<PrivateDeviceState, DevicePersisted> = {
|
const persistOptions: PersistOptions<PrivateDeviceState, DevicePersisted> = {
|
||||||
@@ -943,6 +991,7 @@ const persistOptions: PersistOptions<PrivateDeviceState, DevicePersisted> = {
|
|||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
|
savedConnections: s.savedConnections,
|
||||||
}),
|
}),
|
||||||
onRehydrateStorage: () => (state) => {
|
onRehydrateStorage: () => (state) => {
|
||||||
if (!state) {
|
if (!state) {
|
||||||
@@ -980,14 +1029,6 @@ const persistOptions: PersistOptions<PrivateDeviceState, DevicePersisted> = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add persist middleware on the store if the feature flag is enabled
|
export const useDeviceStore = createStore(
|
||||||
const persistDevices = featureFlags.get("persistDevices");
|
subscribeWithSelector(persist(deviceStoreInitializer, persistOptions)),
|
||||||
console.debug(
|
|
||||||
`DeviceStore: Persisting devices is ${persistDevices ? "enabled" : "disabled"}`,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
export const useDeviceStore = persistDevices
|
|
||||||
? createStore(
|
|
||||||
subscribeWithSelector(persist(deviceStoreInitializer, persistOptions)),
|
|
||||||
)
|
|
||||||
: createStore(subscribeWithSelector(deviceStoreInitializer));
|
|
||||||
|
|||||||
@@ -28,6 +28,46 @@ type DialogVariant = keyof Dialogs;
|
|||||||
|
|
||||||
type Page = "messages" | "map" | "settings" | "channels" | "nodes";
|
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 & {
|
type WaypointWithMetadata = Protobuf.Mesh.Waypoint & {
|
||||||
metadata: {
|
metadata: {
|
||||||
channel: number; // Channel on which the waypoint was received
|
channel: number; // Channel on which the waypoint was received
|
||||||
|
|||||||
@@ -50,11 +50,11 @@ i18next
|
|||||||
debug: import.meta.env.MODE === "development",
|
debug: import.meta.env.MODE === "development",
|
||||||
ns: [
|
ns: [
|
||||||
"channels",
|
"channels",
|
||||||
|
"connections",
|
||||||
"commandPalette",
|
"commandPalette",
|
||||||
"common",
|
"common",
|
||||||
"config",
|
"config",
|
||||||
"moduleConfig",
|
"moduleConfig",
|
||||||
"dashboard",
|
|
||||||
"dialog",
|
"dialog",
|
||||||
"messages",
|
"messages",
|
||||||
"nodes",
|
"nodes",
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div className="space-y-6 p-6">
|
||||||
|
<header className="flex items-start justify-between">
|
||||||
|
<div className="flex items-stretch gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate({ to: "/" })}
|
||||||
|
className="flex items-center justify-center w-9 rounded-md hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
|
||||||
|
aria-label="Go back"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="size-5" />
|
||||||
|
</button>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl md:text-3xl font-bold tracking-tight">
|
||||||
|
{t("page.title")}
|
||||||
|
</h1>
|
||||||
|
<p className="text-slate-500 dark:text-slate-400 mt-1">
|
||||||
|
{t("page.description")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center ml-2 gap-2">
|
||||||
|
<Button onClick={() => setAddOpen(true)} className="gap-2">
|
||||||
|
<PlugZap className="size-4" />
|
||||||
|
{t("button.addConnection")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{sorted.length === 0 ? (
|
||||||
|
<Card className="border-dashed">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg">
|
||||||
|
{t("noConnections.title")}{" "}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="text-slate-500 dark:text-slate-400">
|
||||||
|
{t("noConnections.description")}
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter>
|
||||||
|
<Button onClick={() => setAddOpen(true)} className="gap-2">
|
||||||
|
<PlugZap className="size-4" />
|
||||||
|
{t("button.addConnection")}
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{sorted.map((c) => (
|
||||||
|
<ConnectionCard
|
||||||
|
key={c.id}
|
||||||
|
connection={c}
|
||||||
|
onConnect={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.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: "/" });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<AddConnectionDialog
|
||||||
|
open={addOpen}
|
||||||
|
onOpenChange={setAddOpen}
|
||||||
|
isHTTPS={isURLHTTPS}
|
||||||
|
onSave={async (partial, btDevice) => {
|
||||||
|
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",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
function TypeBadge({ type }: { type: Connection["type"] }) {
|
||||||
|
const Icon = connectionTypeIcon(type);
|
||||||
|
const label =
|
||||||
|
type === "http" ? "HTTP" : type === "bluetooth" ? "Bluetooth" : "Serial";
|
||||||
|
return (
|
||||||
|
<Badge variant="default" className="gap-1.5">
|
||||||
|
<Icon className="h-3.5 w-3.5" />
|
||||||
|
{label}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConnectionCard({
|
||||||
|
connection,
|
||||||
|
onConnect,
|
||||||
|
onDisconnect,
|
||||||
|
onSetDefault,
|
||||||
|
onDelete,
|
||||||
|
onRetry,
|
||||||
|
}: {
|
||||||
|
connection: Connection;
|
||||||
|
onConnect: () => Promise<boolean> | Promise<void>;
|
||||||
|
onDisconnect: () => Promise<void> | Promise<void>;
|
||||||
|
onSetDefault: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
onRetry: () => Promise<boolean> | Promise<void>;
|
||||||
|
}) {
|
||||||
|
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 (
|
||||||
|
<Card className="flex flex-col">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Icon className="size-4 text-slate-500 dark:text-slate-400" />
|
||||||
|
<span className="truncate">{connection.name}</span>
|
||||||
|
{connection.isDefault ? (
|
||||||
|
<Badge variant="secondary" className="gap-1">
|
||||||
|
<Star className="h-3.5 w-3.5 fill-yellow-500 text-yellow-500" />
|
||||||
|
{t("default")}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</CardTitle>
|
||||||
|
<div className="mt-3 flex flex-wrap items-center gap-2 text-sm">
|
||||||
|
<TypeBadge type={connection.type} />
|
||||||
|
<span className="text-slate-500 dark:text-slate-400 truncate">
|
||||||
|
{formatConnectionSubtext(connection)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ConnectionStatusBadge status={connection.status} />
|
||||||
|
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||||
|
<MoreHorizontal className="size-4" />
|
||||||
|
<span className="sr-only">{t("moreActions")}</span>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
{connection.type === "http" && connection.isDefault && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="gap-2"
|
||||||
|
onClick={() => onSetDefault()}
|
||||||
|
>
|
||||||
|
<StarOff className="size-4" />
|
||||||
|
{t("button.unsetDefault")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
{connection.type === "http" && !connection.isDefault && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="gap-2"
|
||||||
|
onClick={() => onSetDefault()}
|
||||||
|
>
|
||||||
|
<Star className="size-4" />
|
||||||
|
{t("button.setDefault")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="gap-2 text-red-600 focus:text-red-600 dark:text-red-400 dark:focus:text-red-400"
|
||||||
|
onSelect={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
{t("button.delete")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>
|
||||||
|
{t("deleteConnection")}
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{t("areYouSure", { name: connection.name })}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>
|
||||||
|
{t("button.cancel")}
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
className="bg-red-600 hover:bg-red-700"
|
||||||
|
onClick={() => onDelete()}
|
||||||
|
>
|
||||||
|
{t("button.delete")}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-0">
|
||||||
|
{connection.error ? (
|
||||||
|
<p className="text-sm text-red-600 dark:text-red-400">
|
||||||
|
{connection.error}
|
||||||
|
</p>
|
||||||
|
) : connection.lastConnectedAt ? (
|
||||||
|
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||||
|
{t("lastConnectedAt", {
|
||||||
|
date: new Date(connection.lastConnectedAt),
|
||||||
|
})}
|
||||||
|
:{new Date(connection.lastConnectedAt).toLocaleString()}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||||
|
{t("neverConnected")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter className="flex items-center gap-2 mt-auto">
|
||||||
|
{isConnected ? (
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
className="gap-2"
|
||||||
|
onClick={() => onDisconnect()}
|
||||||
|
disabled={isBusy}
|
||||||
|
>
|
||||||
|
{t("button.disconnect")}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
className="gap-2"
|
||||||
|
onClick={() => (isError ? onRetry() : onConnect())}
|
||||||
|
disabled={isBusy}
|
||||||
|
>
|
||||||
|
{isError ? (
|
||||||
|
<>
|
||||||
|
<RotateCw className="size-4" />
|
||||||
|
{t("button.retry")}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<LinkIcon className="size-4" />
|
||||||
|
{t("button.connect")}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<ConnectionId, BluetoothDevice>;
|
||||||
|
serial: Map<ConnectionId, SerialPort>;
|
||||||
|
meshDevices: Map<ConnectionId, MeshDevice>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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<LiveRefs>({
|
||||||
|
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<Connection> = {
|
||||||
|
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<void> }).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<ReturnType<typeof TransportHTTP.create>>
|
||||||
|
| Awaited<ReturnType<typeof TransportWebBluetooth.createFromDevice>>
|
||||||
|
| Awaited<ReturnType<typeof TransportWebSerial.createFromPort>>,
|
||||||
|
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<BluetoothDevice[]>;
|
||||||
|
}
|
||||||
|
).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<SerialPort[]> };
|
||||||
|
}
|
||||||
|
).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<string, unknown>,
|
||||||
|
) => Promise<SerialPort>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
).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<void>;
|
||||||
|
};
|
||||||
|
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<void>;
|
||||||
|
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<BluetoothDevice[]>;
|
||||||
|
}
|
||||||
|
).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<SerialPort[]> };
|
||||||
|
}
|
||||||
|
).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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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<boolean> {
|
||||||
|
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}`;
|
||||||
|
}
|
||||||
@@ -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 (
|
|
||||||
<div className="flex flex-col gap-3 p-3 px-8">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Heading as="h3">{t("dashboard.title")}</Heading>
|
|
||||||
<Subtle>{t("dashboard.description")}</Subtle>
|
|
||||||
</div>
|
|
||||||
<LanguageSwitcher />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Separator />
|
|
||||||
|
|
||||||
<div className="flex h-[450px] rounded-md border border-dashed border-slate-200 p-3 dark:border-slate-700">
|
|
||||||
{
|
|
||||||
/*devices.length ? (
|
|
||||||
<ul className="grow divide-y divide-slate-200">
|
|
||||||
{devices.map((device) => {
|
|
||||||
const nodeDB = getNodeDB(device.id);
|
|
||||||
if (!nodeDB) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<li key={device.id}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="w-full px-4 py-4 sm:px-6"
|
|
||||||
onClick={() => {
|
|
||||||
setSelectedDevice(device.id);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<p className="truncate text-sm font-medium text-accent">
|
|
||||||
{nodeDB.getNode(device.hardware.myNodeNum)?.user
|
|
||||||
?.longName ?? t("unknown.shortName")}
|
|
||||||
</p>
|
|
||||||
<div className="mt-2 sm:flex sm:justify-between">
|
|
||||||
<div className="flex gap-2 text-sm text-slate-500">
|
|
||||||
<UsersIcon
|
|
||||||
size={20}
|
|
||||||
className="text-slate-400"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
{nodeDB.getNodesLength() === 0
|
|
||||||
? 0
|
|
||||||
: nodeDB.getNodesLength() - 1}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
) : */ <div className="m-auto flex flex-col gap-3 text-center">
|
|
||||||
<ListPlusIcon size={48} className="mx-auto text-text-secondary" />
|
|
||||||
<Heading as="h3">{t("dashboard.noDevicesTitle")}</Heading>
|
|
||||||
<Subtle>{t("dashboard.noDevicesDescription")}</Subtle>
|
|
||||||
<Button
|
|
||||||
className="gap-2"
|
|
||||||
variant="default"
|
|
||||||
onClick={() => setConnectDialogOpen(true)}
|
|
||||||
>
|
|
||||||
<PlusIcon size={16} />
|
|
||||||
{t("dashboard.button_newConnection")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -40,7 +40,7 @@ export interface DeleteNoteDialogProps {
|
|||||||
|
|
||||||
const NodesPage = (): JSX.Element => {
|
const NodesPage = (): JSX.Element => {
|
||||||
const { t } = useTranslation("nodes");
|
const { t } = useTranslation("nodes");
|
||||||
const { currentLanguage } = useLang();
|
const { current } = useLang();
|
||||||
const { hardware, connection, setDialogOpen } = useDevice();
|
const { hardware, connection, setDialogOpen } = useDevice();
|
||||||
|
|
||||||
const { setNodeNumDetails } = useAppStore();
|
const { setNodeNumDetails } = useAppStore();
|
||||||
@@ -200,7 +200,7 @@ const NodesPage = (): JSX.Element => {
|
|||||||
) : (
|
) : (
|
||||||
<TimeAgo
|
<TimeAgo
|
||||||
timestamp={node.lastHeard * 1000}
|
timestamp={node.lastHeard * 1000}
|
||||||
locale={currentLanguage?.code}
|
locale={current?.code}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Mono>
|
</Mono>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { DialogManager } from "@components/Dialog/DialogManager.tsx";
|
import { DialogManager } from "@components/Dialog/DialogManager.tsx";
|
||||||
import type { useAppStore, useMessageStore } from "@core/stores";
|
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 MapPage from "@pages/Map/index.tsx";
|
||||||
import MessagesPage from "@pages/Messages.tsx";
|
import MessagesPage from "@pages/Messages.tsx";
|
||||||
import NodesPage from "@pages/Nodes/index.tsx";
|
import NodesPage from "@pages/Nodes/index.tsx";
|
||||||
@@ -30,7 +30,7 @@ export const rootRoute = createRootRouteWithContext<AppContext>()({
|
|||||||
const indexRoute = createRoute({
|
const indexRoute = createRoute({
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
path: "/",
|
path: "/",
|
||||||
component: Dashboard,
|
component: Connections,
|
||||||
loader: () => {
|
loader: () => {
|
||||||
// Redirect to the broadcast messages page on initial load
|
// Redirect to the broadcast messages page on initial load
|
||||||
return redirect({ to: "/messages/broadcast/0", replace: true });
|
return redirect({ to: "/messages/broadcast/0", replace: true });
|
||||||
@@ -151,6 +151,12 @@ const dialogWithParamsRoute = createRoute({
|
|||||||
component: DialogManager,
|
component: DialogManager,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const connectionsRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: "/connections",
|
||||||
|
component: Connections,
|
||||||
|
});
|
||||||
|
|
||||||
const routeTree = rootRoute.addChildren([
|
const routeTree = rootRoute.addChildren([
|
||||||
indexRoute,
|
indexRoute,
|
||||||
messagesRoute,
|
messagesRoute,
|
||||||
@@ -160,6 +166,7 @@ const routeTree = rootRoute.addChildren([
|
|||||||
settingsRoute.addChildren([radioRoute, deviceRoute, moduleRoute]),
|
settingsRoute.addChildren([radioRoute, deviceRoute, moduleRoute]),
|
||||||
nodesRoute,
|
nodesRoute,
|
||||||
dialogWithParamsRoute,
|
dialogWithParamsRoute,
|
||||||
|
connectionsRoute,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import commonEN from "@public/i18n/locales/en/common.json" with {
|
|||||||
import configEN from "@public/i18n/locales/en/config.json" with {
|
import configEN from "@public/i18n/locales/en/config.json" with {
|
||||||
type: "json",
|
type: "json",
|
||||||
};
|
};
|
||||||
import dashboardEN from "@public/i18n/locales/en/dashboard.json" with {
|
import connectionsEN from "@public/i18n/locales/en/connections.json" with {
|
||||||
type: "json",
|
type: "json",
|
||||||
};
|
};
|
||||||
import dialogEN from "@public/i18n/locales/en/dialog.json" with {
|
import dialogEN from "@public/i18n/locales/en/dialog.json" with {
|
||||||
@@ -55,7 +55,7 @@ const appNamespaces = [
|
|||||||
"common",
|
"common",
|
||||||
"config",
|
"config",
|
||||||
"moduleConfig",
|
"moduleConfig",
|
||||||
"dashboard",
|
"connections",
|
||||||
"dialog",
|
"dialog",
|
||||||
"messages",
|
"messages",
|
||||||
"nodes",
|
"nodes",
|
||||||
@@ -78,7 +78,7 @@ i18n.use(initReactI18next).init({
|
|||||||
common: commonEN,
|
common: commonEN,
|
||||||
config: configEN,
|
config: configEN,
|
||||||
moduleConfig: moduleConfigEN,
|
moduleConfig: moduleConfigEN,
|
||||||
dashboard: dashboardEN,
|
connections: connectionsEN,
|
||||||
dialog: dialogEN,
|
dialog: dialogEN,
|
||||||
messages: messagesEN,
|
messages: messagesEN,
|
||||||
nodes: nodesEN,
|
nodes: nodesEN,
|
||||||
|
|||||||
@@ -4,12 +4,12 @@ import { z } from "zod/v4";
|
|||||||
export const UserValidationSchema = z.object({
|
export const UserValidationSchema = z.object({
|
||||||
longName: z
|
longName: z
|
||||||
.string()
|
.string()
|
||||||
.min(1, t("deviceName.validation.longNameMin"))
|
.min(1, t("user.longName.validation.min"))
|
||||||
.max(40, t("deviceName.validation.longNameMax")),
|
.max(40, t("user.longName.validation.max")),
|
||||||
shortName: z
|
shortName: z
|
||||||
.string()
|
.string()
|
||||||
.min(2, t("deviceName.validation.shortNameMin"))
|
.min(2, t("user.shortName.validation.min"))
|
||||||
.max(4, t("deviceName.validation.shortNameMax")),
|
.max(4, t("user.shortName.validation.max")),
|
||||||
isUnmessageable: z.boolean().default(false),
|
isUnmessageable: z.boolean().default(false),
|
||||||
isLicensed: z.boolean().default(false),
|
isLicensed: z.boolean().default(false),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { execSync } from "node:child_process";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import process from "node:process";
|
import process from "node:process";
|
||||||
import tailwindcss from "@tailwindcss/vite";
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
|
import basicSsl from "@vitejs/plugin-basic-ssl";
|
||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
import { defineConfig, loadEnv } from "vite";
|
import { defineConfig, loadEnv } from "vite";
|
||||||
import { createHtmlPlugin } from "vite-plugin-html";
|
import { createHtmlPlugin } from "vite-plugin-html";
|
||||||
@@ -32,19 +33,21 @@ export default defineConfig(({ mode }) => {
|
|||||||
|
|
||||||
const isProd = mode === "production";
|
const isProd = mode === "production";
|
||||||
const isTest = env.VITE_IS_TEST;
|
const isTest = env.VITE_IS_TEST;
|
||||||
|
const useHTTPS = env.VITE_USE_HTTPS === "true";
|
||||||
|
|
||||||
return {
|
return {
|
||||||
plugins: [
|
plugins: [
|
||||||
react(),
|
react(),
|
||||||
tailwindcss(),
|
tailwindcss(),
|
||||||
// This is for GDPR/CCPA compliance
|
...(useHTTPS ? [basicSsl()] : []),
|
||||||
createHtmlPlugin({
|
createHtmlPlugin({
|
||||||
inject: {
|
inject: {
|
||||||
data: {
|
data: {
|
||||||
title: isTest ? "Meshtastic Web (TEST)" : "Meshtastic Web",
|
title: isTest ? "Meshtastic Web (TEST)" : "Meshtastic Web",
|
||||||
cookieYesScript:
|
cookieYesScript:
|
||||||
isProd && env.VITE_COOKIEYES_CLIENT_ID
|
isProd && env.VITE_COOKIEYES_CLIENT_ID
|
||||||
? `<script async src="https://cdn-cookieyes.com/client_data/${env.VITE_COOKIEYES_CLIENT_ID}/script.js"></script>`
|
? // This is for GDPR/CCPA compliance
|
||||||
|
`<script async src="https://cdn-cookieyes.com/client_data/${env.VITE_COOKIEYES_CLIENT_ID}/script.js"></script>`
|
||||||
: "",
|
: "",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -76,7 +79,7 @@ export default defineConfig(({ mode }) => {
|
|||||||
server: {
|
server: {
|
||||||
port: 3000,
|
port: 3000,
|
||||||
headers: {
|
headers: {
|
||||||
"content-security-policy": CONTENT_SECURITY_POLICY,
|
"Content-Security-Policy": CONTENT_SECURITY_POLICY,
|
||||||
"Cross-Origin-Opener-Policy": "same-origin",
|
"Cross-Origin-Opener-Policy": "same-origin",
|
||||||
"Cross-Origin-Embedder-Policy": "credentialless",
|
"Cross-Origin-Embedder-Policy": "credentialless",
|
||||||
"X-Content-Type-Options": "nosniff",
|
"X-Content-Type-Options": "nosniff",
|
||||||
|
|||||||
Generated
+119
-100
@@ -202,6 +202,9 @@ importers:
|
|||||||
'@radix-ui/react-accordion':
|
'@radix-ui/react-accordion':
|
||||||
specifier: ^1.2.12
|
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)
|
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':
|
'@radix-ui/react-checkbox':
|
||||||
specifier: ^1.3.3
|
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)
|
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':
|
'@types/w3c-web-serial':
|
||||||
specifier: ^1.0.8
|
specifier: ^1.0.8
|
||||||
version: 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':
|
'@vitejs/plugin-react':
|
||||||
specifier: ^5.0.4
|
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))
|
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':
|
'@microsoft/tsdoc@0.15.1':
|
||||||
resolution: {integrity: sha512-4aErSrCR/On/e5G2hDP0wjooqDdauzEbIq8hIkIe5pXV0rtWJZvdCEKL0ykZxex+IxIwBp0eGeV48hQN07dXtw==}
|
resolution: {integrity: sha512-4aErSrCR/On/e5G2hDP0wjooqDdauzEbIq8hIkIe5pXV0rtWJZvdCEKL0ykZxex+IxIwBp0eGeV48hQN07dXtw==}
|
||||||
|
|
||||||
'@napi-rs/wasm-runtime@1.0.7':
|
'@napi-rs/wasm-runtime@0.2.12':
|
||||||
resolution: {integrity: sha512-SeDnOO0Tk7Okiq6DbXmmBODgOAb9dp9gjlphokTUxmt8U3liIP1ZsozBahH69j/RJv+Rfs6IwUKHTgQYJ/HBAw==}
|
resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==}
|
||||||
|
|
||||||
'@noble/curves@1.9.6':
|
'@noble/curves@1.9.6':
|
||||||
resolution: {integrity: sha512-GIKz/j99FRthB8icyJQA51E8Uk5hXmdyThjgQXRKiv9h0zeRlzSCLIzFw6K1LotZ3XuB7yzlf76qk7uBmTdFqA==}
|
resolution: {integrity: sha512-GIKz/j99FRthB8icyJQA51E8Uk5hXmdyThjgQXRKiv9h0zeRlzSCLIzFw6K1LotZ3XuB7yzlf76qk7uBmTdFqA==}
|
||||||
@@ -1530,8 +1536,12 @@ packages:
|
|||||||
resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
|
resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
|
|
||||||
'@oxc-project/types@0.95.0':
|
'@oxc-project/runtime@0.71.0':
|
||||||
resolution: {integrity: sha512-vACy7vhpMPhjEJhULNxrdR0D943TkA/MigMpJCHmBHvMXxRStRi/dPtTlfQ3uDwWSzRpT8z+7ImjZVf8JWBocQ==}
|
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':
|
'@publint/pack@0.1.2':
|
||||||
resolution: {integrity: sha512-S+9ANAvUmjutrshV4jZjaiG8XQyuJIZ8a4utWmN/vW1sgQ9IfBnPndwkmQYw53QmouOIytT874u65HEmu6H5jw==}
|
resolution: {integrity: sha512-S+9ANAvUmjutrshV4jZjaiG8XQyuJIZ8a4utWmN/vW1sgQ9IfBnPndwkmQYw53QmouOIytT874u65HEmu6H5jw==}
|
||||||
@@ -1559,6 +1569,19 @@ packages:
|
|||||||
'@types/react-dom':
|
'@types/react-dom':
|
||||||
optional: true
|
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':
|
'@radix-ui/react-arrow@1.1.7':
|
||||||
resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==}
|
resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -2052,90 +2075,67 @@ packages:
|
|||||||
'@radix-ui/rect@1.1.1':
|
'@radix-ui/rect@1.1.1':
|
||||||
resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==}
|
resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==}
|
||||||
|
|
||||||
'@rolldown/binding-android-arm64@1.0.0-beta.45':
|
'@rolldown/binding-darwin-arm64@1.0.0-beta.9-commit.d91dfb5':
|
||||||
resolution: {integrity: sha512-bfgKYhFiXJALeA/riil908+2vlyWGdwa7Ju5S+JgWZYdR4jtiPOGdM6WLfso1dojCh+4ZWeiTwPeV9IKQEX+4g==}
|
resolution: {integrity: sha512-Mp0/gqiPdepHjjVm7e0yL1acWvI0rJVVFQEADSezvAjon9sjQ7CEg9JnXICD4B1YrPmN9qV/e7cQZCp87tTV4w==}
|
||||||
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}
|
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [darwin]
|
os: [darwin]
|
||||||
|
|
||||||
'@rolldown/binding-darwin-x64@1.0.0-beta.45':
|
'@rolldown/binding-darwin-x64@1.0.0-beta.9-commit.d91dfb5':
|
||||||
resolution: {integrity: sha512-ddcO9TD3D/CLUa/l8GO8LHzBOaZqWg5ClMy3jICoxwCuoz47h9dtqPsIeTiB6yR501LQTeDsjA4lIFd7u3Ljfw==}
|
resolution: {integrity: sha512-40re4rMNrsi57oavRzIOpRGmg3QRlW6Ea8Q3znaqgOuJuKVrrm2bIQInTfkZJG7a4/5YMX7T951d0+toGLTdCA==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [darwin]
|
os: [darwin]
|
||||||
|
|
||||||
'@rolldown/binding-freebsd-x64@1.0.0-beta.45':
|
'@rolldown/binding-freebsd-x64@1.0.0-beta.9-commit.d91dfb5':
|
||||||
resolution: {integrity: sha512-MBTWdrzW9w+UMYDUvnEuh0pQvLENkl2Sis15fHTfHVW7ClbGuez+RWopZudIDEGkpZXdeI4CkRXk+vdIIebrmg==}
|
resolution: {integrity: sha512-8BDM939bbMariZupiHp3OmP5N+LXPT4mULA0hZjDaq970PCxv4krZOSMG+HkWUUwmuQROtV+/00xw39EO0P+8g==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [freebsd]
|
os: [freebsd]
|
||||||
|
|
||||||
'@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.45':
|
'@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.9-commit.d91dfb5':
|
||||||
resolution: {integrity: sha512-4YgoCFiki1HR6oSg+GxxfzfnVCesQxLF1LEnw9uXS/MpBmuog0EOO2rYfy69rWP4tFZL9IWp6KEfGZLrZ7aUog==}
|
resolution: {integrity: sha512-sntsPaPgrECpBB/+2xrQzVUt0r493TMPI+4kWRMhvMsmrxOqH1Ep5lM0Wua/ZdbfZNwm1aVa5pcESQfNfM4Fhw==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
|
||||||
cpu: [arm]
|
cpu: [arm]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@rolldown/binding-linux-arm64-gnu@1.0.0-beta.45':
|
'@rolldown/binding-linux-arm64-gnu@1.0.0-beta.9-commit.d91dfb5':
|
||||||
resolution: {integrity: sha512-LE1gjAwQRrbCOorJJ7LFr10s5vqYf5a00V5Ea9wXcT2+56n5YosJkcp8eQ12FxRBv2YX8dsdQJb+ZTtYJwb6XQ==}
|
resolution: {integrity: sha512-5clBW/I+er9F2uM1OFjJFWX86y7Lcy0M+NqsN4s3o07W+8467Zk8oQa4B45vdaXoNUF/yqIAgKkA/OEdQDxZqA==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
libc: [glibc]
|
||||||
|
|
||||||
'@rolldown/binding-linux-arm64-musl@1.0.0-beta.45':
|
'@rolldown/binding-linux-arm64-musl@1.0.0-beta.9-commit.d91dfb5':
|
||||||
resolution: {integrity: sha512-tdy8ThO/fPp40B81v0YK3QC+KODOmzJzSUOO37DinQxzlTJ026gqUSOM8tzlVixRbQJltgVDCTYF8HNPRErQTA==}
|
resolution: {integrity: sha512-wv+rnAfQDk9p/CheX8/Kmqk2o1WaFa4xhWI9gOyDMk/ljvOX0u0ubeM8nI1Qfox7Tnh71eV5AjzSePXUhFOyOg==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
libc: [musl]
|
||||||
|
|
||||||
'@rolldown/binding-linux-x64-gnu@1.0.0-beta.45':
|
'@rolldown/binding-linux-x64-gnu@1.0.0-beta.9-commit.d91dfb5':
|
||||||
resolution: {integrity: sha512-lS082ROBWdmOyVY/0YB3JmsiClaWoxvC+dA8/rbhyB9VLkvVEaihLEOr4CYmrMse151C4+S6hCw6oa1iewox7g==}
|
resolution: {integrity: sha512-gxD0/xhU4Py47IH3bKZbWtvB99tMkUPGPJFRfSc5UB9Osoje0l0j1PPbxpUtXIELurYCqwLBKXIMTQGifox1BQ==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
libc: [glibc]
|
||||||
|
|
||||||
'@rolldown/binding-linux-x64-musl@1.0.0-beta.45':
|
'@rolldown/binding-linux-x64-musl@1.0.0-beta.9-commit.d91dfb5':
|
||||||
resolution: {integrity: sha512-Hi73aYY0cBkr1/SvNQqH8Cd+rSV6S9RB5izCv0ySBcRnd/Wfn5plguUoGYwBnhHgFbh6cPw9m2dUVBR6BG1gxA==}
|
resolution: {integrity: sha512-HotuVe3XUjDwqqEMbm3o3IRkP9gdm8raY/btd/6KE3JGLF/cv4+3ff1l6nOhAZI8wulWDPEXPtE7v+HQEaTXnA==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
libc: [musl]
|
||||||
|
|
||||||
'@rolldown/binding-openharmony-arm64@1.0.0-beta.45':
|
'@rolldown/binding-wasm32-wasi@1.0.0-beta.9-commit.d91dfb5':
|
||||||
resolution: {integrity: sha512-fljEqbO7RHHogNDxYtTzr+GNjlfOx21RUyGmF+NrkebZ8emYYiIqzPxsaMZuRx0rgZmVmliOzEp86/CQFDKhJQ==}
|
resolution: {integrity: sha512-8Cx+ucbd8n2dIr21FqBh6rUvTVL0uTgEtKR7l+MUZ5BgY4dFh1e4mPVX8oqmoYwOxBiXrsD2JIOCz4AyKLKxWA==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: '>=14.21.3'}
|
||||||
cpu: [arm64]
|
|
||||||
os: [openharmony]
|
|
||||||
|
|
||||||
'@rolldown/binding-wasm32-wasi@1.0.0-beta.45':
|
|
||||||
resolution: {integrity: sha512-ZJDB7lkuZE9XUnWQSYrBObZxczut+8FZ5pdanm8nNS1DAo8zsrPuvGwn+U3fwU98WaiFsNrA4XHngesCGr8tEQ==}
|
|
||||||
engines: {node: '>=14.0.0'}
|
|
||||||
cpu: [wasm32]
|
cpu: [wasm32]
|
||||||
|
|
||||||
'@rolldown/binding-win32-arm64-msvc@1.0.0-beta.45':
|
'@rolldown/binding-win32-arm64-msvc@1.0.0-beta.9-commit.d91dfb5':
|
||||||
resolution: {integrity: sha512-zyzAjItHPUmxg6Z8SyRhLdXlJn3/D9KL5b9mObUrBHhWS/GwRH4665xCiFqeuktAhhWutqfc+rOV2LjK4VYQGQ==}
|
resolution: {integrity: sha512-Vhq5vikrVDxAa75fxsyqj0c0Y/uti/TwshXI71Xb8IeUQJOBnmLUsn5dgYf5ljpYYkNa0z9BPAvUDIDMmyDi+w==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
'@rolldown/binding-win32-ia32-msvc@1.0.0-beta.45':
|
'@rolldown/binding-win32-ia32-msvc@1.0.0-beta.9-commit.d91dfb5':
|
||||||
resolution: {integrity: sha512-wODcGzlfxqS6D7BR0srkJk3drPwXYLu7jPHN27ce2c4PUnVVmJnp9mJzUQGT4LpmHmmVdMZ+P6hKvyTGBzc1CA==}
|
resolution: {integrity: sha512-lN7RIg9Iugn08zP2aZN9y/MIdG8iOOCE93M1UrFlrxMTqPf8X+fDzmR/OKhTSd1A2pYNipZHjyTcb5H8kyQSow==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
|
||||||
cpu: [ia32]
|
cpu: [ia32]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
'@rolldown/binding-win32-x64-msvc@1.0.0-beta.45':
|
'@rolldown/binding-win32-x64-msvc@1.0.0-beta.9-commit.d91dfb5':
|
||||||
resolution: {integrity: sha512-wiU40G1nQo9rtfvF9jLbl79lUgjfaD/LTyUEw2Wg/gdF5OhjzpKMVugZQngO+RNdwYaNj+Fs+kWBWfp4VXPMHA==}
|
resolution: {integrity: sha512-7/7cLIn48Y+EpQ4CePvf8reFl63F15yPUlg4ZAhl+RXJIfydkdak1WD8Ir3AwAO+bJBXzrfNL+XQbxm0mcQZmw==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
@@ -2145,8 +2145,8 @@ packages:
|
|||||||
'@rolldown/pluginutils@1.0.0-beta.38':
|
'@rolldown/pluginutils@1.0.0-beta.38':
|
||||||
resolution: {integrity: sha512-N/ICGKleNhA5nc9XXQG/kkKHJ7S55u0x0XUJbbkmdCnFuoRkM1Il12q9q0eX19+M7KKUEPw/daUPIRnxhcxAIw==}
|
resolution: {integrity: sha512-N/ICGKleNhA5nc9XXQG/kkKHJ7S55u0x0XUJbbkmdCnFuoRkM1Il12q9q0eX19+M7KKUEPw/daUPIRnxhcxAIw==}
|
||||||
|
|
||||||
'@rolldown/pluginutils@1.0.0-beta.45':
|
'@rolldown/pluginutils@1.0.0-beta.9-commit.d91dfb5':
|
||||||
resolution: {integrity: sha512-Le9ulGCrD8ggInzWw/k2J8QcbPz7eGIOWqfJ2L+1R0Opm7n6J37s2hiDWlh6LJN0Lk9L5sUzMvRHKW7UxBZsQA==}
|
resolution: {integrity: sha512-8sExkWRK+zVybw3+2/kBkYBFeLnEUWz1fT7BLHplpzmtqkOfTbAQ9gkt4pzwGIIZmg4Qn5US5ACjUBenrhezwQ==}
|
||||||
|
|
||||||
'@rollup/plugin-babel@5.3.1':
|
'@rollup/plugin-babel@5.3.1':
|
||||||
resolution: {integrity: sha512-WFfdLWU/xVWKeRQnKmIAQULUI7Il0gZnBIH/ZFO069wYIfPu+8zrfp/KMW0atmELoRDq8FbiP3VCss9MhCut7Q==}
|
resolution: {integrity: sha512-WFfdLWU/xVWKeRQnKmIAQULUI7Il0gZnBIH/ZFO069wYIfPu+8zrfp/KMW0atmELoRDq8FbiP3VCss9MhCut7Q==}
|
||||||
@@ -3209,6 +3209,12 @@ packages:
|
|||||||
maplibre-gl:
|
maplibre-gl:
|
||||||
optional: true
|
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':
|
'@vitejs/plugin-react@4.7.0':
|
||||||
resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==}
|
resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==}
|
||||||
engines: {node: ^14.18.0 || >=16.0.0}
|
engines: {node: ^14.18.0 || >=16.0.0}
|
||||||
@@ -5057,9 +5063,8 @@ packages:
|
|||||||
vue-tsc:
|
vue-tsc:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
rolldown@1.0.0-beta.45:
|
rolldown@1.0.0-beta.9-commit.d91dfb5:
|
||||||
resolution: {integrity: sha512-iMmuD72XXLf26Tqrv1cryNYLX6NNPLhZ3AmNkSf8+xda0H+yijjGJ+wVT9UdBUHOpKzq9RjKtQKRCWoEKQQBZQ==}
|
resolution: {integrity: sha512-FHkj6gGEiEgmAXQchglofvUUdwj2Oiw603Rs+zgFAnn9Cb7T7z3fiaEc0DbN3ja4wYkW6sF2rzMEtC1V4BGx/g==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
rollup@2.79.2:
|
rollup@2.79.2:
|
||||||
@@ -7092,7 +7097,7 @@ snapshots:
|
|||||||
|
|
||||||
'@microsoft/tsdoc@0.15.1': {}
|
'@microsoft/tsdoc@0.15.1': {}
|
||||||
|
|
||||||
'@napi-rs/wasm-runtime@1.0.7':
|
'@napi-rs/wasm-runtime@0.2.12':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@emnapi/core': 1.5.0
|
'@emnapi/core': 1.5.0
|
||||||
'@emnapi/runtime': 1.5.0
|
'@emnapi/runtime': 1.5.0
|
||||||
@@ -7117,7 +7122,9 @@ snapshots:
|
|||||||
'@nodelib/fs.scandir': 2.1.5
|
'@nodelib/fs.scandir': 2.1.5
|
||||||
fastq: 1.19.1
|
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': {}
|
'@publint/pack@0.1.2': {}
|
||||||
|
|
||||||
@@ -7146,6 +7153,20 @@ snapshots:
|
|||||||
'@types/react': 19.2.1
|
'@types/react': 19.2.1
|
||||||
'@types/react-dom': 19.2.0(@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)':
|
'@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:
|
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)
|
'@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': {}
|
'@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
|
optional: true
|
||||||
|
|
||||||
'@rolldown/binding-darwin-arm64@1.0.0-beta.45':
|
'@rolldown/binding-darwin-x64@1.0.0-beta.9-commit.d91dfb5':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rolldown/binding-darwin-x64@1.0.0-beta.45':
|
'@rolldown/binding-freebsd-x64@1.0.0-beta.9-commit.d91dfb5':
|
||||||
optional: true
|
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
|
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
|
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
|
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
|
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
|
optional: true
|
||||||
|
|
||||||
'@rolldown/binding-linux-x64-musl@1.0.0-beta.45':
|
'@rolldown/binding-wasm32-wasi@1.0.0-beta.9-commit.d91dfb5':
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@rolldown/binding-openharmony-arm64@1.0.0-beta.45':
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@rolldown/binding-wasm32-wasi@1.0.0-beta.45':
|
|
||||||
dependencies:
|
dependencies:
|
||||||
'@napi-rs/wasm-runtime': 1.0.7
|
'@napi-rs/wasm-runtime': 0.2.12
|
||||||
optional: true
|
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
|
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
|
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
|
optional: true
|
||||||
|
|
||||||
'@rolldown/pluginutils@1.0.0-beta.27': {}
|
'@rolldown/pluginutils@1.0.0-beta.27': {}
|
||||||
|
|
||||||
'@rolldown/pluginutils@1.0.0-beta.38': {}
|
'@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)':
|
'@rollup/plugin-babel@5.3.1(@babel/core@7.28.4)(@types/babel__core@7.20.5)(rollup@2.79.2)':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -9844,6 +9859,10 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
maplibre-gl: 5.8.0
|
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))':
|
'@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:
|
dependencies:
|
||||||
'@babel/core': 7.28.4
|
'@babel/core': 7.28.4
|
||||||
@@ -11728,7 +11747,7 @@ snapshots:
|
|||||||
|
|
||||||
robust-predicates@3.0.2: {}
|
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:
|
dependencies:
|
||||||
'@babel/generator': 7.28.3
|
'@babel/generator': 7.28.3
|
||||||
'@babel/parser': 7.28.4
|
'@babel/parser': 7.28.4
|
||||||
@@ -11738,32 +11757,32 @@ snapshots:
|
|||||||
debug: 4.4.3
|
debug: 4.4.3
|
||||||
dts-resolver: 2.1.2
|
dts-resolver: 2.1.2
|
||||||
get-tsconfig: 4.10.1
|
get-tsconfig: 4.10.1
|
||||||
rolldown: 1.0.0-beta.45
|
rolldown: 1.0.0-beta.9-commit.d91dfb5
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
typescript: 5.9.2
|
typescript: 5.9.2
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- oxc-resolver
|
- oxc-resolver
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
rolldown@1.0.0-beta.45:
|
rolldown@1.0.0-beta.9-commit.d91dfb5:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@oxc-project/types': 0.95.0
|
'@oxc-project/runtime': 0.71.0
|
||||||
'@rolldown/pluginutils': 1.0.0-beta.45
|
'@oxc-project/types': 0.71.0
|
||||||
|
'@rolldown/pluginutils': 1.0.0-beta.9-commit.d91dfb5
|
||||||
|
ansis: 4.2.0
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@rolldown/binding-android-arm64': 1.0.0-beta.45
|
'@rolldown/binding-darwin-arm64': 1.0.0-beta.9-commit.d91dfb5
|
||||||
'@rolldown/binding-darwin-arm64': 1.0.0-beta.45
|
'@rolldown/binding-darwin-x64': 1.0.0-beta.9-commit.d91dfb5
|
||||||
'@rolldown/binding-darwin-x64': 1.0.0-beta.45
|
'@rolldown/binding-freebsd-x64': 1.0.0-beta.9-commit.d91dfb5
|
||||||
'@rolldown/binding-freebsd-x64': 1.0.0-beta.45
|
'@rolldown/binding-linux-arm-gnueabihf': 1.0.0-beta.9-commit.d91dfb5
|
||||||
'@rolldown/binding-linux-arm-gnueabihf': 1.0.0-beta.45
|
'@rolldown/binding-linux-arm64-gnu': 1.0.0-beta.9-commit.d91dfb5
|
||||||
'@rolldown/binding-linux-arm64-gnu': 1.0.0-beta.45
|
'@rolldown/binding-linux-arm64-musl': 1.0.0-beta.9-commit.d91dfb5
|
||||||
'@rolldown/binding-linux-arm64-musl': 1.0.0-beta.45
|
'@rolldown/binding-linux-x64-gnu': 1.0.0-beta.9-commit.d91dfb5
|
||||||
'@rolldown/binding-linux-x64-gnu': 1.0.0-beta.45
|
'@rolldown/binding-linux-x64-musl': 1.0.0-beta.9-commit.d91dfb5
|
||||||
'@rolldown/binding-linux-x64-musl': 1.0.0-beta.45
|
'@rolldown/binding-wasm32-wasi': 1.0.0-beta.9-commit.d91dfb5
|
||||||
'@rolldown/binding-openharmony-arm64': 1.0.0-beta.45
|
'@rolldown/binding-win32-arm64-msvc': 1.0.0-beta.9-commit.d91dfb5
|
||||||
'@rolldown/binding-wasm32-wasi': 1.0.0-beta.45
|
'@rolldown/binding-win32-ia32-msvc': 1.0.0-beta.9-commit.d91dfb5
|
||||||
'@rolldown/binding-win32-arm64-msvc': 1.0.0-beta.45
|
'@rolldown/binding-win32-x64-msvc': 1.0.0-beta.9-commit.d91dfb5
|
||||||
'@rolldown/binding-win32-ia32-msvc': 1.0.0-beta.45
|
|
||||||
'@rolldown/binding-win32-x64-msvc': 1.0.0-beta.45
|
|
||||||
|
|
||||||
rollup@2.79.2:
|
rollup@2.79.2:
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
@@ -12228,8 +12247,8 @@ snapshots:
|
|||||||
diff: 8.0.2
|
diff: 8.0.2
|
||||||
empathic: 2.0.0
|
empathic: 2.0.0
|
||||||
hookable: 5.5.3
|
hookable: 5.5.3
|
||||||
rolldown: 1.0.0-beta.45
|
rolldown: 1.0.0-beta.9-commit.d91dfb5
|
||||||
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)
|
||||||
semver: 7.7.2
|
semver: 7.7.2
|
||||||
tinyexec: 1.0.1
|
tinyexec: 1.0.1
|
||||||
tinyglobby: 0.2.15
|
tinyglobby: 0.2.15
|
||||||
|
|||||||
Reference in New Issue
Block a user