├── .github ├── ISSUE_TEMPLATE │ ├── bug.yml │ └── feature.yml ├── pull_request_template.md └── workflows │ ├── ci.yml │ ├── crowdin-download.yml │ ├── crowdin-upload-sources.yml │ ├── crowdin-upload-translations.yml │ ├── nightly.yml │ ├── pr.yml │ ├── release.yml │ └── update-stable-from-master.yml ├── .gitignore ├── .npmrc ├── .vscode ├── extensions.json └── settings.json ├── LICENSE ├── README.md ├── crowdin.yml ├── deno.json ├── deno.lock ├── index.html ├── infra ├── .dockerignore ├── Containerfile └── default.conf ├── package.json ├── postcss.config.cjs ├── public ├── Logo.svg ├── Logo_Black.svg ├── Logo_White.svg ├── apple-touch-icon.png ├── chirpy.svg ├── diy.svg ├── favicon.ico ├── heltec-ht62-esp32c3-sx1262.svg ├── heltec-mesh-node-t114-case.svg ├── heltec-mesh-node-t114.svg ├── heltec-v3-case.svg ├── heltec-v3.svg ├── heltec-vision-master-e213.svg ├── heltec-vision-master-e290.svg ├── heltec-vision-master-t190.svg ├── heltec-wireless-paper-V1_0.svg ├── heltec-wireless-paper.svg ├── heltec-wireless-tracker-V1-0.svg ├── heltec-wireless-tracker.svg ├── heltec-wsl-v3.svg ├── icon.svg ├── nano-g2-ultra.svg ├── pico.svg ├── promicro.svg ├── rak-wismeshtap.svg ├── rak11310.svg ├── rak2560.svg ├── rak4631.svg ├── rak4631_case.svg ├── robots.txt ├── rpipicow.svg ├── seeed-sensecap-indicator.svg ├── seeed-xiao-s3.svg ├── site.webmanifest ├── station-g2.svg ├── t-deck.svg ├── t-echo.svg ├── t-watch-s3.svg ├── tbeam-s3-core.svg ├── tbeam.svg ├── tlora-c6.svg ├── tlora-t3s3-epaper.svg ├── tlora-t3s3-v1.svg ├── tlora-v2-1-1_6.svg ├── tlora-v2-1-1_8.svg ├── tracker-t1000-e.svg ├── unknown.svg ├── wio-tracker-wm1110.svg └── wm1110_dev_kit.svg ├── src ├── App.tsx ├── DeviceWrapper.tsx ├── __mocks__ │ ├── README.md │ └── components │ │ └── UI │ │ ├── Button.tsx │ │ ├── Checkbox.tsx │ │ ├── Dialog │ │ └── Dialog.tsx │ │ ├── Label.tsx │ │ └── Link.tsx ├── components │ ├── BatteryStatus.tsx │ ├── CommandPalette │ │ └── index.tsx │ ├── DeviceInfoPanel.tsx │ ├── Dialog │ │ ├── DeleteMessagesDialog │ │ │ ├── DeleteMessagesDialog.test.tsx │ │ │ └── DeleteMessagesDialog.tsx │ │ ├── DeviceNameDialog.tsx │ │ ├── DialogManager.tsx │ │ ├── ImportDialog.tsx │ │ ├── LocationResponseDialog.tsx │ │ ├── NewDeviceDialog.tsx │ │ ├── NodeDetailsDialog │ │ │ ├── NodeDetailsDialog.test.tsx │ │ │ └── NodeDetailsDialog.tsx │ │ ├── PKIBackupDialog.tsx │ │ ├── PkiRegenerateDialog.tsx │ │ ├── QRDialog.tsx │ │ ├── RebootDialog.tsx │ │ ├── RebootOTADialog.test.tsx │ │ ├── RebootOTADialog.tsx │ │ ├── RefreshKeysDialog │ │ │ ├── RefreshKeysDialog.test.tsx │ │ │ ├── RefreshKeysDialog.tsx │ │ │ ├── useRefreshKeysDialog.test.ts │ │ │ └── useRefreshKeysDialog.ts │ │ ├── RemoveNodeDialog.tsx │ │ ├── ShutdownDialog.tsx │ │ ├── TracerouteResponseDialog.tsx │ │ └── UnsafeRolesDialog │ │ │ ├── UnsafeRolesDialog.test.tsx │ │ │ ├── UnsafeRolesDialog.tsx │ │ │ ├── useUnsafeRolesDialog.test.tsx │ │ │ └── useUnsafeRolesDialog.ts │ ├── Form │ │ ├── DynamicForm.test.tsx │ │ ├── DynamicForm.tsx │ │ ├── DynamicFormField.tsx │ │ ├── FormInput.tsx │ │ ├── FormMultiSelect.tsx │ │ ├── FormPasswordGenerator.tsx │ │ ├── FormSelect.tsx │ │ ├── FormToggle.tsx │ │ ├── FormWrapper.tsx │ │ └── createZodResolver.ts │ ├── KeyBackupReminder.tsx │ ├── LanguageSwitcher.tsx │ ├── Map.tsx │ ├── PageComponents │ │ ├── Channel.tsx │ │ ├── Config │ │ │ ├── Bluetooth.tsx │ │ │ ├── Device │ │ │ │ ├── Device.test.tsx │ │ │ │ └── index.tsx │ │ │ ├── Display.tsx │ │ │ ├── LoRa.tsx │ │ │ ├── Network │ │ │ │ ├── Network.test.tsx │ │ │ │ └── index.tsx │ │ │ ├── Position.tsx │ │ │ ├── Power.tsx │ │ │ └── Security │ │ │ │ └── Security.tsx │ │ ├── Connect │ │ │ ├── BLE.tsx │ │ │ ├── HTTP.test.tsx │ │ │ ├── HTTP.tsx │ │ │ └── Serial.tsx │ │ ├── Map │ │ │ └── NodeDetail.tsx │ │ ├── Messages │ │ │ ├── ChannelChat.tsx │ │ │ ├── MessageActionsMenu.tsx │ │ │ ├── MessageInput.test.tsx │ │ │ ├── MessageInput.tsx │ │ │ ├── MessageItem.tsx │ │ │ ├── TraceRoute.test.tsx │ │ │ └── TraceRoute.tsx │ │ └── ModuleConfig │ │ │ ├── AmbientLighting.tsx │ │ │ ├── Audio.tsx │ │ │ ├── CannedMessage.tsx │ │ │ ├── DetectionSensor.tsx │ │ │ ├── ExternalNotification.tsx │ │ │ ├── MQTT.tsx │ │ │ ├── NeighborInfo.tsx │ │ │ ├── Paxcounter.tsx │ │ │ ├── RangeTest.tsx │ │ │ ├── Serial.tsx │ │ │ ├── StoreForward.tsx │ │ │ └── Telemetry.tsx │ ├── PageLayout.tsx │ ├── Sidebar.tsx │ ├── ThemeSwitcher.tsx │ ├── Toaster.tsx │ ├── UI │ │ ├── Accordion.tsx │ │ ├── Avatar.tsx │ │ ├── Button.tsx │ │ ├── Checkbox │ │ │ ├── Checkbox.test.tsx │ │ │ └── index.tsx │ │ ├── Command.tsx │ │ ├── Dialog.tsx │ │ ├── DropdownMenu.tsx │ │ ├── ErrorPage.tsx │ │ ├── Footer.tsx │ │ ├── Generator.tsx │ │ ├── Input.tsx │ │ ├── Label.tsx │ │ ├── Menubar.tsx │ │ ├── MultiSelect.tsx │ │ ├── Popover.tsx │ │ ├── ScrollArea.tsx │ │ ├── Select.tsx │ │ ├── Seperator.tsx │ │ ├── Sidebar │ │ │ ├── SidebarButton.tsx │ │ │ └── SidebarSection.tsx │ │ ├── Slider.tsx │ │ ├── Spinner.tsx │ │ ├── Switch.tsx │ │ ├── Tabs.tsx │ │ ├── Toast.tsx │ │ ├── ToggleGroup.tsx │ │ ├── Tooltip.tsx │ │ └── Typography │ │ │ ├── Blockquote.tsx │ │ │ ├── Code.tsx │ │ │ ├── Heading.tsx │ │ │ ├── Link.tsx │ │ │ ├── P.tsx │ │ │ └── Subtle.tsx │ ├── generic │ │ ├── Blur.tsx │ │ ├── DeviceImage.tsx │ │ ├── Filter │ │ │ ├── FilterComponents.tsx │ │ │ ├── FilterControl.tsx │ │ │ ├── useFilterNode.test.ts │ │ │ └── useFilterNode.ts │ │ ├── Mono.tsx │ │ ├── Table │ │ │ ├── index.test.tsx │ │ │ └── index.tsx │ │ ├── TimeAgo.tsx │ │ └── Uptime.tsx │ └── types.ts ├── core │ ├── dto │ │ ├── NodeNumToNodeInfoDTO.ts │ │ └── PacketToMessageDTO.ts │ ├── hooks │ │ ├── useBrowserFeatureDetection.ts │ │ ├── useCookie.ts │ │ ├── useCopyToClipboard.ts │ │ ├── useFavoriteNode.test.ts │ │ ├── useFavoriteNode.ts │ │ ├── useIgnoreNode.test.ts │ │ ├── useIgnoreNode.ts │ │ ├── useKeyBackupReminder.tsx │ │ ├── useLang.ts │ │ ├── useLocalStorage.test.ts │ │ ├── useLocalStorage.ts │ │ ├── usePasswordVisibilityToggle.test.ts │ │ ├── usePasswordVisibilityToggle.ts │ │ ├── usePinnedItems.test.ts │ │ ├── usePinnedItems.ts │ │ ├── usePositionFlags.ts │ │ ├── useTheme.ts │ │ ├── useToast.test.tsx │ │ └── useToast.ts │ ├── stores │ │ ├── appStore.ts │ │ ├── deviceStore.ts │ │ ├── messageStore │ │ │ ├── index.ts │ │ │ ├── messageStore.test.ts │ │ │ └── types.ts │ │ ├── sidebarStore.tsx │ │ └── storage │ │ │ └── indexDB.ts │ ├── subscriptions.ts │ └── utils │ │ ├── bitwise.ts │ │ ├── cn.ts │ │ ├── debounce.test.ts │ │ ├── debounce.ts │ │ ├── dotPath.test.ts │ │ ├── dotPath.ts │ │ ├── eventBus.test.ts │ │ ├── eventBus.ts │ │ ├── github.ts │ │ ├── ip.test.ts │ │ ├── ip.ts │ │ ├── randId.test.ts │ │ ├── randId.ts │ │ ├── string.ts │ │ ├── test.tsx │ │ └── x25519.ts ├── i18n │ ├── config.ts │ └── locales │ │ └── en │ │ ├── channels.json │ │ ├── commandPalette.json │ │ ├── common.json │ │ ├── dashboard.json │ │ ├── deviceConfig.json │ │ ├── dialog.json │ │ ├── messages.json │ │ ├── moduleConfig.json │ │ ├── nodes.json │ │ └── ui.json ├── index.css ├── index.tsx ├── pages │ ├── Channels.tsx │ ├── Config │ │ ├── DeviceConfig.tsx │ │ ├── ModuleConfig.tsx │ │ └── index.tsx │ ├── Dashboard │ │ └── index.tsx │ ├── Map │ │ └── index.tsx │ ├── Messages.test.tsx │ ├── Messages.tsx │ └── Nodes.tsx ├── routeTree.gen.ts ├── routes.tsx ├── tests │ └── setupTests.ts └── validation │ ├── channel.test.ts │ ├── channel.ts │ ├── config │ ├── bluetooth.ts │ ├── device.ts │ ├── display.ts │ ├── lora.ts │ ├── network.ts │ ├── position.ts │ ├── power.ts │ ├── security.test.ts │ └── security.ts │ ├── moduleConfig │ ├── ambientLighting.ts │ ├── audio.ts │ ├── cannedMessage.ts │ ├── detectionSensor.ts │ ├── externalNotification.ts │ ├── mqtt.ts │ ├── neighborInfo.ts │ ├── paxcounter.ts │ ├── rangeTest.ts │ ├── serial.ts │ ├── storeForward.ts │ └── telemetry.ts │ ├── pskSchema.test.ts │ ├── pskSchema.ts │ └── validate.ts ├── vercel.json ├── vite.config.ts └── vitest.config.ts /.github/ISSUE_TEMPLATE/feature.yml: -------------------------------------------------------------------------------- 1 | name: Feature Request 2 | description: Request a new feature 3 | title: "[Feature Request]: " 4 | labels: ["enhancement"] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | Thanks for your request. While we can't guarantee implementation, all requests will be carefully reviewed. 10 | - type: checkboxes 11 | id: prerequisites 12 | attributes: 13 | label: Prerequisites 14 | description: Please confirm the following before submitting your feature request 15 | options: 16 | - label: I have searched existing issues to ensure this feature hasn't already been requested 17 | required: true 18 | - label: I have checked the documentation to verify this feature doesn't already exist 19 | required: true 20 | - type: textarea 21 | id: problem 22 | attributes: 23 | label: Problem Statement 24 | description: What problem are you trying to solve? Describe the challenge or limitation you're facing. 25 | placeholder: I'm frustrated when... 26 | validations: 27 | required: true 28 | - type: textarea 29 | id: solution 30 | attributes: 31 | label: Proposed Solution 32 | description: Describe your idea for solving the problem. What would you like to see implemented? 33 | placeholder: It would be great if... 34 | validations: 35 | required: true 36 | - type: textarea 37 | id: alternatives 38 | attributes: 39 | label: Current Alternatives 40 | description: Are there any workarounds or alternative solutions you're currently using? 41 | placeholder: Currently, I'm working around this by... 42 | validations: 43 | required: false 44 | - type: dropdown 45 | id: importance 46 | attributes: 47 | label: Importance 48 | description: How important is this feature to you? 49 | options: 50 | - Nice to have 51 | - Important 52 | - Critical 53 | validations: 54 | required: true 55 | - type: textarea 56 | id: context 57 | attributes: 58 | label: Additional Context 59 | description: Add any other context, screenshots, mockups, or examples that might help us understand your request better. 60 | validations: 61 | required: false 62 | - type: markdown 63 | attributes: 64 | value: | 65 | Thank you for taking the time to fill out this feature request! -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | 4 | 5 | ## Description 6 | 7 | 10 | 11 | ## Related Issues 12 | 13 | 17 | 18 | ## Changes Made 19 | 20 | 23 | 24 | - 25 | - 26 | - 27 | 28 | ## Testing Done 29 | 30 | 33 | 34 | ## Screenshots (if applicable) 35 | 36 | 39 | 40 | ## Checklist 41 | 42 | 45 | 46 | - [ ] Code follows project style guidelines 47 | - [ ] Documentation has been updated or added 48 | - [ ] Tests have been added or updated 49 | - [ ] All i18n translation labels have bee added 50 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Push to Main CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | permissions: 9 | contents: write 10 | packages: write 11 | 12 | jobs: 13 | build-and-package: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Checkout code 17 | uses: actions/checkout@v4 18 | 19 | - name: Setup Deno 20 | uses: denoland/setup-deno@v2 21 | with: 22 | deno-version: v2.x 23 | 24 | - name: Install Dependencies 25 | run: deno install 26 | 27 | - name: Cache Deno dependencies 28 | uses: actions/cache@v4 29 | with: 30 | path: | 31 | ~/.cache/deno 32 | ./deno.lock 33 | key: ${{ runner.os }}-deno-${{ hashFiles('**/deno.lock') }} 34 | restore-keys: | 35 | ${{ runner.os }}-deno- 36 | 37 | - name: Cache Dependencies 38 | run: deno cache src/index.tsx 39 | 40 | - name: Run linter 41 | run: deno task lint 42 | 43 | - name: Check formatter 44 | run: deno task format --check 45 | 46 | - name: Run tests 47 | run: deno task test 48 | 49 | - name: Build Package 50 | run: deno task build 51 | -------------------------------------------------------------------------------- /.github/workflows/crowdin-download.yml: -------------------------------------------------------------------------------- 1 | name: Crowdin Download Translations Action 2 | 3 | on: 4 | schedule: # Every Sunday at midnight 5 | - cron: '0 0 * * 0' 6 | workflow_dispatch: # Allow manual triggering 7 | 8 | jobs: 9 | synchronize-with-crowdin: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v4 15 | 16 | - name: Download translations with Crowdin 17 | uses: crowdin/github-action@v2 18 | with: 19 | base_url: 'https://meshtastic.crowdin.com/api/v2' 20 | config: 'crowdin.yml' 21 | upload_sources: false 22 | upload_translations: false 23 | download_translations: true 24 | localization_branch_name: i18n_crowdin_translations 25 | commit_message: 'chore(i18n): New Crowdin Translations by GitHub Action' 26 | create_pull_request: true 27 | pull_request_title: 'chore(i18n): New Crowdin Translations' 28 | pull_request_body: 'New Crowdin translations by [Crowdin GH Action](https://github.com/crowdin/github-action)' 29 | pull_request_base_branch_name: 'main' 30 | pull_request_labels: 'i18n' 31 | crowdin_branch_name: 'main' 32 | env: 33 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 34 | CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }} 35 | CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }} -------------------------------------------------------------------------------- /.github/workflows/crowdin-upload-sources.yml: -------------------------------------------------------------------------------- 1 | name: Crowdin Upload Sources Action 2 | 3 | on: 4 | push: 5 | # Monitor all .json files within the /src/i18n/locales/en/ directory. 6 | # This ensures the workflow triggers if any the English namespace files are modified on the main branch. 7 | paths: 8 | - '/src/i18n/locales/en/**/*.json' 9 | branches: [ main ] 10 | workflow_dispatch: # Allow manual triggering 11 | 12 | jobs: 13 | synchronize-with-crowdin: 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v4 19 | 20 | - name: Upload sources with Crowdin 21 | uses: crowdin/github-action@v2 22 | with: 23 | base_url: 'https://meshtastic.crowdin.com/api/v2' 24 | config: 'crowdin.yml' 25 | upload_sources: true 26 | upload_translations: false 27 | download_translations: false 28 | crowdin_branch_name: 'main' 29 | 30 | env: 31 | CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }} 32 | CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }} -------------------------------------------------------------------------------- /.github/workflows/crowdin-upload-translations.yml: -------------------------------------------------------------------------------- 1 | name: Crowdin Upload Translations Action 2 | 3 | on: 4 | workflow_dispatch: # Allow manual triggering 5 | 6 | jobs: 7 | synchronize-with-crowdin: 8 | runs-on: ubuntu-latest 9 | 10 | steps: 11 | - name: Checkout 12 | uses: actions/checkout@v4 13 | 14 | - name: Upload translations with Crowdin 15 | uses: crowdin/github-action@v2 16 | with: 17 | base_url: "https://meshtastic.crowdin.com/api/v2" 18 | config: "crowdin.yml" 19 | upload_sources: false 20 | upload_translations: true 21 | download_translations: false 22 | crowdin_branch_name: "main" 23 | env: 24 | CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }} 25 | CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }} 26 | -------------------------------------------------------------------------------- /.github/workflows/nightly.yml: -------------------------------------------------------------------------------- 1 | name: 'Nightly Release' 2 | 3 | on: 4 | schedule: 5 | - cron: "0 5 * * *" # Run every day at 5am UTC 6 | 7 | permissions: 8 | contents: write 9 | packages: write 10 | 11 | jobs: 12 | build-and-package: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v4 17 | 18 | - name: Setup Deno 19 | uses: denoland/setup-deno@v2 20 | with: 21 | deno-version: v2.x 22 | 23 | - name: Install Dependencies 24 | run: deno install 25 | 26 | - name: Run tests 27 | run: deno task test 28 | 29 | - name: Build Package 30 | run: deno task build 31 | 32 | - name: Package Output 33 | run: deno task package 34 | 35 | - name: Archive compressed build 36 | uses: actions/upload-artifact@v4 37 | with: 38 | name: build 39 | path: dist/build.tar 40 | 41 | - name: Set up QEMU 42 | uses: docker/setup-qemu-action@v3 43 | 44 | - name: Buildah Build 45 | id: build-container 46 | uses: redhat-actions/buildah-build@v2 47 | with: 48 | containerfiles: | 49 | ./infra/Containerfile 50 | image: ${{github.event.repository.full_name}} 51 | tags: nightly ${{ github.sha }} 52 | oci: true 53 | platforms: linux/amd64, linux/arm64 54 | 55 | - name: Push To Registry 56 | id: push-to-registry 57 | uses: redhat-actions/push-to-registry@v2 58 | with: 59 | image: ${{ steps.build-container.outputs.image }} 60 | tags: ${{ steps.build-container.outputs.tags }} 61 | registry: ghcr.io 62 | username: ${{ github.actor }} 63 | password: ${{ secrets.GITHUB_TOKEN }} 64 | 65 | - name: Print image url 66 | run: echo "Image pushed to ${{ steps.push-to-registry.outputs.registry-paths }}" 67 | -------------------------------------------------------------------------------- /.github/workflows/pr.yml: -------------------------------------------------------------------------------- 1 | name: Pull Request CI 2 | 3 | on: 4 | pull_request: 5 | 6 | jobs: 7 | build: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Checkout code 11 | uses: actions/checkout@v4 12 | 13 | - name: Setup Deno 14 | uses: denoland/setup-deno@v2 15 | with: 16 | deno-version: v2.x 17 | 18 | - name: Cache Deno dependencies 19 | uses: actions/cache@v4 20 | with: 21 | path: | 22 | ~/.cache/deno 23 | ./deno.lock 24 | key: ${{ runner.os }}-deno-${{ hashFiles('**/deno.lock') }} 25 | restore-keys: | 26 | ${{ runner.os }}-deno- 27 | 28 | - name: Install Dependencies 29 | run: deno install 30 | 31 | - name: Cache Dependencies 32 | run: deno cache src/index.tsx 33 | 34 | - name: Run linter 35 | run: deno task lint 36 | 37 | - name: Check formatter 38 | run: deno task format --check 39 | 40 | - name: Run tests 41 | run: deno task test 42 | 43 | - name: Build Package 44 | run: deno task build 45 | 46 | - name: Compress build 47 | run: deno task package 48 | 49 | - name: Archive compressed build 50 | uses: actions/upload-artifact@v4 51 | with: 52 | name: build 53 | path: dist/build.tar 54 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | release: 5 | types: [released, prereleased] 6 | 7 | permissions: 8 | contents: write 9 | packages: write 10 | 11 | jobs: 12 | build-and-package: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v4 17 | 18 | - name: Setup Deno 19 | uses: denoland/setup-deno@v2 20 | with: 21 | deno-version: v2.x 22 | 23 | - name: Install Dependencies 24 | run: deno install 25 | 26 | - name: Run tests 27 | run: deno task test 28 | 29 | - name: Build Package 30 | run: deno task build 31 | 32 | - name: Package Output 33 | run: deno task package 34 | 35 | - name: Archive compressed build 36 | uses: actions/upload-artifact@v4 37 | with: 38 | name: build 39 | path: dist/build.tar 40 | 41 | - name: Attach build.tar to release 42 | run: | 43 | gh release upload ${{ github.event.release.tag_name }} dist/build.tar 44 | env: 45 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 46 | 47 | - name: Set up QEMU 48 | uses: docker/setup-qemu-action@v3 49 | 50 | - name: Buildah Build 51 | id: build-container 52 | uses: redhat-actions/buildah-build@v2 53 | with: 54 | containerfiles: | 55 | ./infra/Containerfile 56 | image: ${{github.event.repository.full_name}} 57 | tags: latest ${{ github.sha }} 58 | oci: true 59 | platforms: linux/amd64, linux/arm64 60 | 61 | - name: Push To Registry 62 | id: push-to-registry 63 | uses: redhat-actions/push-to-registry@v2 64 | with: 65 | image: ${{ steps.build-container.outputs.image }} 66 | tags: ${{ steps.build-container.outputs.tags }} 67 | registry: ghcr.io 68 | username: ${{ github.actor }} 69 | password: ${{ secrets.GITHUB_TOKEN }} 70 | 71 | - name: Print image url 72 | run: echo "Image pushed to ${{ steps.push-to-registry.outputs.registry-paths }}" 73 | -------------------------------------------------------------------------------- /.github/workflows/update-stable-from-master.yml: -------------------------------------------------------------------------------- 1 | name: Update Stable Branch from Main on Latest Release 2 | 3 | on: 4 | release: 5 | types: [released] 6 | 7 | permissions: 8 | contents: write 9 | 10 | jobs: 11 | update-stable-branch: 12 | name: Update Stable Branch from Main 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - name: Checkout repository 17 | uses: actions/checkout@v4 18 | with: 19 | fetch-depth: 0 20 | token: ${{ secrets.GITHUB_TOKEN }} 21 | 22 | - name: Configure Git 23 | run: | 24 | git config user.name "GitHub Actions Bot" 25 | git config user.email "github-actions[bot]@users.noreply.github.com" 26 | 27 | - name: Fetch latest main and stable branches 28 | run: | 29 | git fetch origin main:main 30 | git fetch origin stable:stable || echo "Stable branch not found remotely, will create." 31 | 32 | - name: Get latest main commit SHA 33 | id: get_main_sha 34 | run: echo "MAIN_SHA=$(git rev-parse main)" >> $GITHUB_ENV 35 | 36 | - name: Check out stable branch 37 | run: | 38 | if git show-ref --verify --quiet refs/heads/stable; then 39 | git checkout stable 40 | git pull origin stable # Sync with remote stable if it exists 41 | else 42 | echo "Creating local stable branch based on main HEAD." 43 | git checkout -b stable ${{ env.MAIN_SHA }} 44 | fi 45 | 46 | - name: Reset stable branch to latest main 47 | run: git reset --hard ${{ env.MAIN_SHA }} 48 | 49 | - name: Force push stable branch 50 | run: git push origin stable --force -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | stats.html 4 | .vercel 5 | .vite 6 | dev-dist 7 | __screenshots__* -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | @jsr:registry=https://npm.jsr.io 2 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["bradlc.vscode-tailwindcss", "biomejs.biome"] 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "deno.enable": true, 3 | "deno.suggest.imports.autoDiscover": true, 4 | "editor.formatOnSave": true, 5 | "editor.defaultFormatter": "denoland.vscode-deno" 6 | } 7 | -------------------------------------------------------------------------------- /crowdin.yml: -------------------------------------------------------------------------------- 1 | project_id_env: CROWDIN_PROJECT_ID 2 | api_token_env: CROWDIN_PERSONAL_TOKEN 3 | base_path: "." 4 | base_url: "https://meshtastic.crowdin.com/api/v2" 5 | 6 | preserve_hierarchy: true 7 | 8 | files: 9 | - source: "/src/i18n/locales/en/**/*.json" 10 | translation: "/src/i18n/locales/%locale%/%original_file_name%" 11 | -------------------------------------------------------------------------------- /deno.json: -------------------------------------------------------------------------------- 1 | { 2 | "imports": { 3 | "@app/": "./src/", 4 | "@pages/": "./src/pages/", 5 | "@components/": "./src/components/", 6 | "@core/": "./src/core/", 7 | "@layouts/": "./src/layouts/", 8 | "@std/path": "jsr:@std/path@^1.1.0" 9 | }, 10 | "compilerOptions": { 11 | "lib": [ 12 | "DOM", 13 | "DOM.Iterable", 14 | "ESNext", 15 | "deno.window", 16 | "deno.ns" 17 | ], 18 | "jsx": "react-jsx", 19 | "strict": true, 20 | "noUnusedLocals": true, 21 | "noUnusedParameters": true, 22 | "noFallthroughCasesInSwitch": true, 23 | "strictNullChecks": true, 24 | "types": [ 25 | "vite/client", 26 | "node", 27 | "@types/web-bluetooth", 28 | "@types/w3c-web-serial" 29 | ], 30 | "strictPropertyInitialization": false 31 | }, 32 | "fmt": { 33 | "exclude": [ 34 | "src/*.gen.ts", 35 | "*.test.ts", 36 | "*.test.tsx" 37 | ] 38 | }, 39 | "lint": { 40 | "exclude": [ 41 | "src/*.gen.ts", 42 | "*.test.ts", 43 | "*.test.tsx" 44 | ], 45 | "report": "pretty" 46 | }, 47 | "unstable": [ 48 | "sloppy-imports" 49 | ] 50 | } 51 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 16 | 17 | 21 | 22 | Meshtastic Web 23 | 24 | 25 |
26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /infra/.dockerignore: -------------------------------------------------------------------------------- 1 | ../dist/build.tar 2 | ../dist/output 3 | -------------------------------------------------------------------------------- /infra/Containerfile: -------------------------------------------------------------------------------- 1 | FROM nginx:1.27-alpine 2 | 3 | RUN rm -r /usr/share/nginx/html \ 4 | && mkdir -p /usr/share/nginx/html \ 5 | && mkdir -p /etc/nginx/conf.d 6 | 7 | WORKDIR /usr/share/nginx/html 8 | 9 | ADD dist . 10 | 11 | COPY ./infra/default.conf /etc/nginx/conf.d/default.conf 12 | 13 | EXPOSE 8080 14 | 15 | CMD ["nginx", "-g", "daemon off;"] 16 | -------------------------------------------------------------------------------- /infra/default.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 8080; 3 | server_name localhost; 4 | 5 | root /usr/share/nginx/html; 6 | index index.html index.htm; 7 | 8 | location / { 9 | try_files $uri $uri/ =404; 10 | } 11 | 12 | error_page 500 502 503 504 /50x.html; 13 | location = /50x.html { 14 | internal; 15 | } 16 | 17 | location ~ /\.ht { 18 | deny all; 19 | } 20 | 21 | gzip on; 22 | gzip_disable "msie6"; 23 | 24 | gzip_vary on; 25 | gzip_proxied any; 26 | gzip_comp_level 6; 27 | gzip_buffers 16 8k; 28 | gzip_http_version 1.1; 29 | gzip_types 30 | text/plain 31 | text/css 32 | text/xml 33 | text/javascript 34 | application/javascript 35 | application/x-javascript 36 | application/json 37 | application/xml 38 | application/xml+rss 39 | font/ttf 40 | font/otf 41 | image/svg+xml; 42 | } 43 | -------------------------------------------------------------------------------- /postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | '@tailwindcss/postcss': {}, 4 | }, 5 | }; 6 | -------------------------------------------------------------------------------- /public/Logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Created with Fabric.js 4.6.0 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /public/Logo_Black.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /public/Logo_White.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meshtastic/web/828e5d0903d9bc214c54eceeb0407a9855fb1bb5/public/apple-touch-icon.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meshtastic/web/828e5d0903d9bc214c54eceeb0407a9855fb1bb5/public/favicon.ico -------------------------------------------------------------------------------- /public/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Created with Fabric.js 4.6.0 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Allow: / -------------------------------------------------------------------------------- /public/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Meshtastic", 3 | "short_name": "Meshtastic", 4 | "start_url": ".", 5 | "description": "Meshtastic web app", 6 | "icons": [ 7 | { 8 | "src": "/icon.svg", 9 | "sizes": "any", 10 | "type": "image/svg+xml" 11 | } 12 | ], 13 | "theme_color": "#67ea94", 14 | "background_color": "#67ea94", 15 | "display": "standalone" 16 | } 17 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import { DeviceWrapper } from "@app/DeviceWrapper.tsx"; 2 | import { DialogManager } from "@components/Dialog/DialogManager.tsx"; 3 | import { NewDeviceDialog } from "@components/Dialog/NewDeviceDialog.tsx"; 4 | import { KeyBackupReminder } from "@components/KeyBackupReminder.tsx"; 5 | import { Toaster } from "@components/Toaster.tsx"; 6 | import Footer from "@components/UI/Footer.tsx"; 7 | import { useAppStore } from "@core/stores/appStore.ts"; 8 | import { useDeviceStore } from "@core/stores/deviceStore.ts"; 9 | import { Dashboard } from "@pages/Dashboard/index.tsx"; 10 | import { ErrorBoundary } from "react-error-boundary"; 11 | import { ErrorPage } from "@components/UI/ErrorPage.tsx"; 12 | import { MapProvider } from "react-map-gl/maplibre"; 13 | import { CommandPalette } from "@components/CommandPalette/index.tsx"; 14 | import { SidebarProvider } from "@core/stores/sidebarStore.tsx"; 15 | import { useTheme } from "@core/hooks/useTheme.ts"; 16 | import { Outlet } from "@tanstack/react-router"; 17 | import { TanStackRouterDevtools } from "@tanstack/react-router-devtools"; 18 | 19 | export function App() { 20 | const { getDevice } = useDeviceStore(); 21 | const { selectedDevice, setConnectDialogOpen, connectDialogOpen } = 22 | useAppStore(); 23 | 24 | const device = getDevice(selectedDevice); 25 | 26 | // Sets up light/dark mode based on user preferences or system settings 27 | useTheme(); 28 | 29 | return ( 30 | 31 | { 34 | setConnectDialogOpen(open); 35 | }} 36 | /> 37 | 38 | 39 | 40 |
44 | 45 |
46 | {device 47 | ? ( 48 |
49 | 50 | 51 | 52 | 53 | 54 | 55 |
56 | ) 57 | : ( 58 | <> 59 | 60 |
64 |
65 |
66 |
67 |
68 | ); 69 | } 70 | -------------------------------------------------------------------------------- /src/DeviceWrapper.tsx: -------------------------------------------------------------------------------- 1 | import { DeviceContext } from "@core/stores/deviceStore.ts"; 2 | import type { Device } from "@core/stores/deviceStore.ts"; 3 | import type { ReactNode } from "react"; 4 | 5 | export interface DeviceWrapperProps { 6 | children: ReactNode; 7 | device?: Device; 8 | } 9 | 10 | export const DeviceWrapper = ({ children, device }: DeviceWrapperProps) => { 11 | return ( 12 | {children} 13 | ); 14 | }; 15 | -------------------------------------------------------------------------------- /src/__mocks__/README.md: -------------------------------------------------------------------------------- 1 | # Mocks Directory 2 | 3 | This directory contains mock implementations used by Vitest for testing. 4 | 5 | ## Structure 6 | 7 | The directory structure mirrors the actual project structure to make mocking 8 | more intuitive: 9 | 10 | ``` 11 | __mocks__/ 12 | ├── components/ 13 | │ └── UI/ 14 | │ ├── Dialog.tsx 15 | │ ├── Button.tsx 16 | │ ├── Checkbox.tsx 17 | │ └── ... 18 | ├── core/ 19 | │ └── ... 20 | └── ... 21 | ``` 22 | 23 | ## Auto-mocking 24 | 25 | Vitest will automatically use the mock files in this directory when the 26 | corresponding module is imported in tests. For example, when a test imports 27 | `@components/UI/Dialog.tsx`, Vitest will use 28 | `__mocks__/components/UI/Dialog.tsx` instead. 29 | 30 | ## Creating New Mocks 31 | 32 | To create a new mock: 33 | 34 | 1. Create a file in the same relative path as the original module 35 | 2. Export the mocked functionality with the same names as the original 36 | 3. Add a `vi.mock()` statement to `vitest.setup.ts` if needed 37 | 38 | ## Mock Guidelines 39 | 40 | - Keep mocks as simple as possible 41 | - Use `data-testid` attributes for easy querying in tests 42 | - Implement just enough functionality to test the component 43 | - Use TypeScript types to ensure compatibility with the original module 44 | -------------------------------------------------------------------------------- /src/__mocks__/components/UI/Button.tsx: -------------------------------------------------------------------------------- 1 | import { vi } from "vitest"; 2 | 3 | vi.mock("@components/UI/Button.tsx", () => ({ 4 | Button: ({ children, name, disabled, onClick }: { 5 | children: React.ReactNode; 6 | variant: string; 7 | name: string; 8 | disabled?: boolean; 9 | onClick: () => void; 10 | }) => ( 11 | 20 | ), 21 | })); 22 | -------------------------------------------------------------------------------- /src/__mocks__/components/UI/Checkbox.tsx: -------------------------------------------------------------------------------- 1 | import { vi } from "vitest"; 2 | 3 | vi.mock("@components/UI/Checkbox.tsx", () => ({ 4 | Checkbox: ( 5 | { id, checked, onChange }: { 6 | id: string; 7 | checked: boolean; 8 | onChange: () => void; 9 | }, 10 | ) => ( 11 | 18 | ), 19 | })); 20 | -------------------------------------------------------------------------------- /src/__mocks__/components/UI/Dialog/Dialog.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export const Dialog = ({ children, open }: { 4 | children: React.ReactNode; 5 | open: boolean; 6 | onOpenChange?: (open: boolean) => void; 7 | }) => open ?
{children}
: null; 8 | 9 | export const DialogContent = ({ 10 | children, 11 | className, 12 | }: { 13 | children: React.ReactNode; 14 | className?: string; 15 | }) =>
{children}
; 16 | 17 | export const DialogHeader = ({ 18 | children, 19 | }: { 20 | children: React.ReactNode; 21 | }) =>
{children}
; 22 | 23 | export const DialogTitle = ({ 24 | children, 25 | }: { 26 | children: React.ReactNode; 27 | }) =>
{children}
; 28 | 29 | export const DialogDescription = ({ 30 | children, 31 | className, 32 | }: { 33 | children: React.ReactNode; 34 | className?: string; 35 | }) => ( 36 |
{children}
37 | ); 38 | 39 | export const DialogFooter = ({ 40 | children, 41 | className, 42 | }: { 43 | children: React.ReactNode; 44 | className?: string; 45 | }) =>
{children}
; 46 | -------------------------------------------------------------------------------- /src/__mocks__/components/UI/Label.tsx: -------------------------------------------------------------------------------- 1 | import { vi } from "vitest"; 2 | 3 | vi.mock("@components/UI/Label.tsx", () => ({ 4 | Label: ( 5 | { children, htmlFor, className }: { 6 | children: React.ReactNode; 7 | htmlFor: string; 8 | className?: string; 9 | }, 10 | ) => ( 11 | 14 | ), 15 | })); 16 | -------------------------------------------------------------------------------- /src/__mocks__/components/UI/Link.tsx: -------------------------------------------------------------------------------- 1 | import { vi } from "vitest"; 2 | 3 | vi.mock("@components/UI/Typography/Link.tsx", () => ({ 4 | Link: ( 5 | { children, href, className }: { 6 | children: React.ReactNode; 7 | href: string; 8 | className?: string; 9 | }, 10 | ) => {children}, 11 | })); 12 | -------------------------------------------------------------------------------- /src/components/BatteryStatus.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { 3 | BatteryFullIcon, 4 | BatteryLowIcon, 5 | BatteryMediumIcon, 6 | PlugZapIcon, 7 | } from "lucide-react"; 8 | import { useTranslation } from "react-i18next"; 9 | import { DeviceMetrics } from "./types.ts"; 10 | 11 | interface BatteryStateConfig { 12 | condition: (level: number) => boolean; 13 | Icon: React.ElementType; 14 | className: string; 15 | text: (level: number) => string; 16 | } 17 | 18 | interface BatteryStatusProps { 19 | deviceMetrics?: DeviceMetrics | null; 20 | } 21 | 22 | const getBatteryStates = ( 23 | t: (key: string, options?: object) => string, 24 | ): BatteryStateConfig[] => { 25 | return [ 26 | { 27 | condition: (level) => level > 100, 28 | Icon: PlugZapIcon, 29 | className: "text-gray-500", 30 | text: () => t("batteryStatus.pluggedIn"), 31 | }, 32 | { 33 | condition: (level) => level > 80, 34 | Icon: BatteryFullIcon, 35 | className: "text-green-500", 36 | text: (level) => t("batteryStatus.charging", { level }), 37 | }, 38 | { 39 | condition: (level) => level > 20, 40 | Icon: BatteryMediumIcon, 41 | className: "text-yellow-500", 42 | text: (level) => t("batteryStatus.charging", { level }), 43 | }, 44 | { 45 | condition: () => true, 46 | Icon: BatteryLowIcon, 47 | className: "text-red-500", 48 | text: (level) => t("batteryStatus.charging", { level }), 49 | }, 50 | ]; 51 | }; 52 | 53 | const getBatteryState = ( 54 | level: number, 55 | batteryStates: BatteryStateConfig[], 56 | ) => { 57 | return batteryStates.find((state) => state.condition(level)); 58 | }; 59 | 60 | const BatteryStatus: React.FC = ({ deviceMetrics }) => { 61 | if ( 62 | deviceMetrics?.batteryLevel === undefined || 63 | deviceMetrics?.batteryLevel === null 64 | ) { 65 | return null; 66 | } 67 | 68 | const { t } = useTranslation(); 69 | const batteryStates = getBatteryStates(t); 70 | 71 | const { batteryLevel } = deviceMetrics; 72 | const currentState = getBatteryState(batteryLevel, batteryStates) ?? 73 | batteryStates[batteryStates.length - 1]; 74 | 75 | const BatteryIcon = currentState.Icon; 76 | const iconClassName = currentState.className; 77 | const statusText = currentState.text(batteryLevel); 78 | 79 | return ( 80 |
84 | 85 | {statusText} 86 |
87 | ); 88 | }; 89 | 90 | export default BatteryStatus; 91 | -------------------------------------------------------------------------------- /src/components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@components/UI/Button.tsx"; 2 | import { 3 | Dialog, 4 | DialogClose, 5 | DialogContent, 6 | DialogDescription, 7 | DialogFooter, 8 | DialogHeader, 9 | DialogTitle, 10 | } from "@components/UI/Dialog.tsx"; 11 | import { AlertTriangleIcon } from "lucide-react"; 12 | import { useMessageStore } from "../../../core/stores/messageStore/index.ts"; 13 | import { useTranslation } from "react-i18next"; 14 | 15 | export interface DeleteMessagesDialogProps { 16 | open: boolean; 17 | onOpenChange: (open: boolean) => void; 18 | } 19 | 20 | export const DeleteMessagesDialog = ({ 21 | open, 22 | onOpenChange, 23 | }: DeleteMessagesDialogProps) => { 24 | const { t } = useTranslation("dialog"); 25 | const { deleteAllMessages } = useMessageStore(); 26 | const handleCloseDialog = () => { 27 | onOpenChange(false); 28 | }; 29 | 30 | return ( 31 | 32 | 33 | 34 | 35 | 36 | 37 | {t("deleteMessages.title")} 38 | 39 | 40 | {t("deleteMessages.description")} 41 | 42 | 43 | 44 | 51 | 61 | 62 | 63 | 64 | ); 65 | }; 66 | -------------------------------------------------------------------------------- /src/components/Dialog/LocationResponseDialog.tsx: -------------------------------------------------------------------------------- 1 | import { useDevice } from "@core/stores/deviceStore.ts"; 2 | import { 3 | Dialog, 4 | DialogClose, 5 | DialogContent, 6 | DialogDescription, 7 | DialogHeader, 8 | DialogTitle, 9 | } from "../UI/Dialog.tsx"; 10 | import type { Protobuf, Types } from "@meshtastic/core"; 11 | import { numberToHexUnpadded } from "@noble/curves/abstract/utils"; 12 | import { useTranslation } from "react-i18next"; 13 | 14 | export interface LocationResponseDialogProps { 15 | location: Types.PacketMetadata | undefined; 16 | open: boolean; 17 | onOpenChange: () => void; 18 | } 19 | 20 | export const LocationResponseDialog = ({ 21 | location, 22 | open, 23 | onOpenChange, 24 | }: LocationResponseDialogProps) => { 25 | const { t } = useTranslation("dialog"); 26 | const { getNode } = useDevice(); 27 | 28 | const from = getNode(location?.from ?? 0); 29 | const longName = from?.user?.longName ?? 30 | (from ? `!${numberToHexUnpadded(from?.num)}` : t("unknown.shortName")); 31 | const shortName = from?.user?.shortName ?? 32 | (from 33 | ? `${numberToHexUnpadded(from?.num).substring(0, 4)}` 34 | : t("unknown.shortName")); 35 | 36 | return ( 37 | 38 | 39 | 40 | 41 | 42 | {t("locationResponse.title", { 43 | identifier: `${longName} (${shortName})`, 44 | })} 45 | 46 | 47 | 48 |
49 | 50 |

51 | {t("locationResponse.coordinates")} 52 | 60 | {location?.data.latitudeI / 1e7},{" "} 61 | {location?.data.longitudeI / 1e7} 62 | 63 |

64 |

65 | {t("locationResponse.altitude")} 66 | {location?.data.altitude} 67 | {location?.data.altitde < 1 68 | ? t("unit.meter.one") 69 | : t("unit.meter.plural")} 70 |

71 |
72 |
73 |
74 |
75 |
76 | ); 77 | }; 78 | -------------------------------------------------------------------------------- /src/components/Dialog/PkiRegenerateDialog.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@components/UI/Button.tsx"; 2 | import { 3 | Dialog, 4 | DialogClose, 5 | DialogContent, 6 | DialogDescription, 7 | DialogFooter, 8 | DialogHeader, 9 | DialogTitle, 10 | } from "@components/UI/Dialog.tsx"; 11 | import { useTranslation } from "react-i18next"; 12 | 13 | export interface PkiRegenerateDialogProps { 14 | text: { 15 | title: string; 16 | description: string; 17 | button: string; 18 | }; 19 | open: boolean; 20 | onOpenChange: () => void; 21 | onSubmit: () => void; 22 | } 23 | 24 | export const PkiRegenerateDialog = ({ 25 | text = { 26 | title: "", 27 | description: "", 28 | button: "", 29 | }, 30 | open, 31 | onOpenChange, 32 | onSubmit, 33 | }: PkiRegenerateDialogProps) => { 34 | const { t } = useTranslation("dialog"); 35 | const dialogText = { 36 | title: text.title || t("pkiRegenerate.title"), 37 | description: text.description || 38 | t("pkiRegenerate.description"), 39 | button: text.button || t("button.regenerate"), 40 | }; 41 | return ( 42 | 43 | 44 | 45 | 46 | {dialogText.title} 47 | 48 | {dialogText.description} 49 | 50 | 51 | 52 | 59 | 60 | 61 | 62 | ); 63 | }; 64 | -------------------------------------------------------------------------------- /src/components/Dialog/RebootDialog.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@components/UI/Button.tsx"; 2 | import { 3 | Dialog, 4 | DialogClose, 5 | DialogContent, 6 | DialogDescription, 7 | DialogHeader, 8 | DialogTitle, 9 | } from "@components/UI/Dialog.tsx"; 10 | import { Input } from "@components/UI/Input.tsx"; 11 | import { useDevice } from "@core/stores/deviceStore.ts"; 12 | import { ClockIcon, RefreshCwIcon } from "lucide-react"; 13 | import { useTranslation } from "react-i18next"; 14 | import { useState } from "react"; 15 | 16 | export interface RebootDialogProps { 17 | open: boolean; 18 | onOpenChange: (open: boolean) => void; 19 | } 20 | 21 | export const RebootDialog = ({ 22 | open, 23 | onOpenChange, 24 | }: RebootDialogProps) => { 25 | const { t } = useTranslation("dialog"); 26 | const { connection } = useDevice(); 27 | 28 | const [time, setTime] = useState(5); 29 | 30 | return ( 31 | 32 | 33 | 34 | 35 | 36 | {t("reboot.title")} 37 | 38 | 39 | {t("reboot.description")} 40 | 41 | 42 |
43 | setTime(Number.parseInt(e.target.value))} 48 | action={{ 49 | icon: ClockIcon, 50 | onClick() { 51 | connection?.reboot(time * 60).then(() => onOpenChange(false)); 52 | }, 53 | }} 54 | /> 55 | 65 |
66 |
67 |
68 | ); 69 | }; 70 | -------------------------------------------------------------------------------- /src/components/Dialog/RefreshKeysDialog/RefreshKeysDialog.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from "@testing-library/react"; 2 | import { DeviceContext, useDeviceStore } from "@core/stores/deviceStore.ts"; 3 | import { RefreshKeysDialog } from "./RefreshKeysDialog.tsx"; 4 | import { useMessageStore } from "../../../core/stores/messageStore/index.ts"; 5 | import { useRefreshKeysDialog } from "./useRefreshKeysDialog.ts"; 6 | import { afterEach, beforeEach, expect, test, vi } from "vitest"; 7 | 8 | vi.mock("@core/stores/messageStore"); 9 | vi.mock("./useRefreshKeysDialog"); 10 | 11 | const mockUseMessageStore = vi.mocked(useMessageStore); 12 | const mockUseRefreshKeysDialog = vi.mocked(useRefreshKeysDialog); 13 | 14 | const getInitialState = () => 15 | useDeviceStore.getInitialState?.() ?? 16 | { devices: new Map(), remoteDevices: new Map() }; 17 | 18 | beforeEach(() => { 19 | useDeviceStore.setState(getInitialState(), true); 20 | vi.clearAllMocks(); 21 | }); 22 | 23 | afterEach(() => { 24 | vi.restoreAllMocks(); 25 | }); 26 | 27 | test("does not render dialog if no error exists for active chat", () => { 28 | const deviceId = 1; 29 | const activeChatNum = 54321; 30 | 31 | useDeviceStore.getState().addDevice(deviceId); 32 | 33 | const currentDeviceState = useDeviceStore.getState().getDevice(deviceId); 34 | if (!currentDeviceState) throw new Error("Device not found"); 35 | 36 | mockUseMessageStore.mockReturnValue({ activeChat: activeChatNum }); 37 | mockUseRefreshKeysDialog.mockReturnValue({ 38 | handleCloseDialog: vi.fn(), 39 | handleNodeRemove: vi.fn(), 40 | }); 41 | 42 | const { container } = render( 43 | 44 | 45 | , 46 | ); 47 | 48 | expect(container.firstChild).toBeNull(); 49 | }); 50 | -------------------------------------------------------------------------------- /src/components/Dialog/RefreshKeysDialog/useRefreshKeysDialog.ts: -------------------------------------------------------------------------------- 1 | import { useCallback } from "react"; 2 | import { useDevice } from "@core/stores/deviceStore.ts"; 3 | import { useMessageStore } from "@core/stores/messageStore/index.ts"; 4 | 5 | export function useRefreshKeysDialog() { 6 | const { removeNode, setDialogOpen, clearNodeError, getNodeError } = 7 | useDevice(); 8 | const { activeChat } = useMessageStore(); 9 | 10 | const handleCloseDialog = useCallback(() => { 11 | setDialogOpen("refreshKeys", false); 12 | }, [setDialogOpen]); 13 | 14 | const handleNodeRemove = useCallback(() => { 15 | const nodeWithError = getNodeError(activeChat); 16 | if (!nodeWithError) { 17 | return; 18 | } 19 | clearNodeError(activeChat); 20 | handleCloseDialog(); 21 | return removeNode(nodeWithError?.node); 22 | }, [activeChat, clearNodeError, getNodeError, removeNode, handleCloseDialog]); 23 | 24 | return { 25 | handleCloseDialog, 26 | handleNodeRemove, 27 | }; 28 | } 29 | -------------------------------------------------------------------------------- /src/components/Dialog/RemoveNodeDialog.tsx: -------------------------------------------------------------------------------- 1 | import { useAppStore } from "../../core/stores/appStore.ts"; 2 | import { useDevice } from "@core/stores/deviceStore.ts"; 3 | import { Button } from "@components/UI/Button.tsx"; 4 | import { 5 | Dialog, 6 | DialogClose, 7 | DialogContent, 8 | DialogDescription, 9 | DialogFooter, 10 | DialogHeader, 11 | DialogTitle, 12 | } from "@components/UI/Dialog.tsx"; 13 | import { Label } from "@components/UI/Label.tsx"; 14 | import { useTranslation } from "react-i18next"; 15 | 16 | export interface RemoveNodeDialogProps { 17 | open: boolean; 18 | onOpenChange: (open: boolean) => void; 19 | } 20 | 21 | export const RemoveNodeDialog = ({ 22 | open, 23 | onOpenChange, 24 | }: RemoveNodeDialogProps) => { 25 | const { t } = useTranslation("dialog"); 26 | const { connection, getNode, removeNode } = useDevice(); 27 | const { nodeNumToBeRemoved } = useAppStore(); 28 | 29 | const onSubmit = () => { 30 | connection?.removeNodeByNum(nodeNumToBeRemoved); 31 | removeNode(nodeNumToBeRemoved); 32 | onOpenChange(false); 33 | }; 34 | 35 | return ( 36 | 37 | 38 | 39 | 40 | {t("removeNode.title")} 41 | 42 | {t("removeNode.description")} 43 | 44 | 45 |
46 |
47 | 48 |
49 |
50 | 51 | 58 | 59 |
60 |
61 | ); 62 | }; 63 | -------------------------------------------------------------------------------- /src/components/Dialog/ShutdownDialog.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@components/UI/Button.tsx"; 2 | import { 3 | Dialog, 4 | DialogClose, 5 | DialogContent, 6 | DialogDescription, 7 | DialogHeader, 8 | DialogTitle, 9 | } from "@components/UI/Dialog.tsx"; 10 | import { Input } from "@components/UI/Input.tsx"; 11 | import { useDevice } from "@core/stores/deviceStore.ts"; 12 | import { ClockIcon, PowerIcon } from "lucide-react"; 13 | import { useTranslation } from "react-i18next"; 14 | import { useState } from "react"; 15 | 16 | export interface ShutdownDialogProps { 17 | open: boolean; 18 | onOpenChange: (open: boolean) => void; 19 | } 20 | 21 | export const ShutdownDialog = ({ 22 | open, 23 | onOpenChange, 24 | }: ShutdownDialogProps) => { 25 | const { t } = useTranslation("dialog"); 26 | const { connection } = useDevice(); 27 | 28 | const [time, setTime] = useState(5); 29 | 30 | return ( 31 | 32 | 33 | 34 | 35 | 36 | {t("shutdown.title")} 37 | 38 | 39 | {t("shutdown.description")} 40 | 41 | 42 | 43 |
44 | setTime(Number.parseInt(e.target.value))} 48 | suffix={t("unit.minute.plural")} 49 | /> 50 | 58 | 68 |
69 |
70 |
71 | ); 72 | }; 73 | -------------------------------------------------------------------------------- /src/components/Dialog/TracerouteResponseDialog.tsx: -------------------------------------------------------------------------------- 1 | import { useDevice } from "../../core/stores/deviceStore.ts"; 2 | import { 3 | Dialog, 4 | DialogClose, 5 | DialogContent, 6 | DialogDescription, 7 | DialogHeader, 8 | DialogTitle, 9 | } from "../UI/Dialog.tsx"; 10 | import type { Protobuf, Types } from "@meshtastic/core"; 11 | import { numberToHexUnpadded } from "@noble/curves/abstract/utils"; 12 | 13 | import { TraceRoute } from "../PageComponents/Messages/TraceRoute.tsx"; 14 | import { useTranslation } from "react-i18next"; 15 | 16 | export interface TracerouteResponseDialogProps { 17 | traceroute: Types.PacketMetadata | undefined; 18 | open: boolean; 19 | onOpenChange: () => void; 20 | } 21 | 22 | export const TracerouteResponseDialog = ({ 23 | traceroute, 24 | open, 25 | onOpenChange, 26 | }: TracerouteResponseDialogProps) => { 27 | const { t } = useTranslation("dialog"); 28 | const { getNode } = useDevice(); 29 | const route: number[] = traceroute?.data.route ?? []; 30 | const routeBack: number[] = traceroute?.data.routeBack ?? []; 31 | const snrTowards = (traceroute?.data.snrTowards ?? []).map((snr) => snr / 4); 32 | const snrBack = (traceroute?.data.snrBack ?? []).map((snr) => snr / 4); 33 | const from = getNode(traceroute?.from ?? 0); 34 | const longName = from?.user?.longName ?? 35 | (from ? `!${numberToHexUnpadded(from?.num)}` : t("unknown.shortName")); 36 | const shortName = from?.user?.shortName ?? 37 | (from 38 | ? `${numberToHexUnpadded(from?.num).substring(0, 4)}` 39 | : t("unknown.shortName")); 40 | const to = getNode(traceroute?.to ?? 0); 41 | return ( 42 | 43 | 44 | 45 | 46 | 47 | {t("tracerouteResponse.title", { 48 | identifier: `${longName} (${shortName})`, 49 | })} 50 | 51 | 52 | 53 | 61 | 62 | 63 | 64 | ); 65 | }; 66 | -------------------------------------------------------------------------------- /src/components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.ts: -------------------------------------------------------------------------------- 1 | import { useCallback } from "react"; 2 | import { eventBus } from "@core/utils/eventBus.ts"; 3 | import { useDevice } from "@core/stores/deviceStore.ts"; 4 | 5 | export const UNSAFE_ROLES = ["ROUTER", "ROUTER_LATE", "REPEATER"]; 6 | export type UnsafeRole = typeof UNSAFE_ROLES[number]; 7 | 8 | export const useUnsafeRolesDialog = () => { 9 | const { setDialogOpen } = useDevice(); 10 | 11 | const handleCloseDialog = useCallback(() => { 12 | setDialogOpen("unsafeRoles", false); 13 | }, [setDialogOpen]); 14 | 15 | const validateRoleSelection = useCallback( 16 | (newRoleKey: string): Promise => { 17 | if (!UNSAFE_ROLES.includes(newRoleKey as UnsafeRole)) { 18 | return Promise.resolve(true); 19 | } 20 | 21 | setDialogOpen("unsafeRoles", true); 22 | 23 | return new Promise((resolve) => { 24 | const handleResponse = ( 25 | { action }: { action: "confirm" | "dismiss" }, 26 | ) => { 27 | eventBus.off("dialog:unsafeRoles", handleResponse); 28 | resolve(action === "confirm"); 29 | }; 30 | 31 | eventBus.on("dialog:unsafeRoles", handleResponse); 32 | }); 33 | }, 34 | [setDialogOpen], 35 | ); 36 | 37 | return { 38 | handleCloseDialog, 39 | validateRoleSelection, 40 | }; 41 | }; 42 | -------------------------------------------------------------------------------- /src/components/Form/DynamicFormField.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | type MultiSelectFieldProps, 3 | MultiSelectInput, 4 | } from "./FormMultiSelect.tsx"; 5 | import { 6 | GenericInput, 7 | type InputFieldProps, 8 | } from "@components/Form/FormInput.tsx"; 9 | import { 10 | PasswordGenerator, 11 | type PasswordGeneratorProps, 12 | } from "@components/Form/FormPasswordGenerator.tsx"; 13 | import { 14 | type SelectFieldProps, 15 | SelectInput, 16 | } from "@components/Form/FormSelect.tsx"; 17 | import { 18 | type ToggleFieldProps, 19 | ToggleInput, 20 | } from "@components/Form/FormToggle.tsx"; 21 | import type { Control, FieldValues } from "react-hook-form"; 22 | 23 | export type FieldProps = 24 | | InputFieldProps 25 | | SelectFieldProps 26 | | MultiSelectFieldProps 27 | | ToggleFieldProps 28 | | PasswordGeneratorProps; 29 | 30 | export interface DynamicFormFieldProps { 31 | field: FieldProps; 32 | control: Control; 33 | disabled?: boolean; 34 | } 35 | 36 | export function DynamicFormField({ 37 | field, 38 | control, 39 | disabled, 40 | }: DynamicFormFieldProps) { 41 | switch (field.type) { 42 | case "text": 43 | case "password": 44 | case "number": 45 | return ( 46 | 47 | ); 48 | 49 | case "toggle": 50 | return ( 51 | 56 | ); 57 | case "select": 58 | return ( 59 | 64 | ); 65 | case "passwordGenerator": 66 | return ( 67 | 72 | ); 73 | case "multiSelect": 74 | return ( 75 | 76 | ); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/components/Form/FormMultiSelect.tsx: -------------------------------------------------------------------------------- 1 | import type { 2 | BaseFormBuilderProps, 3 | GenericFormElementProps, 4 | } from "@components/Form/DynamicForm.tsx"; 5 | import type { FieldValues } from "react-hook-form"; 6 | import { useTranslation } from "react-i18next"; 7 | import type { FLAGS_CONFIG } from "@core/hooks/usePositionFlags.ts"; 8 | import { MultiSelect, MultiSelectItem } from "../UI/MultiSelect.tsx"; 9 | 10 | export interface MultiSelectFieldProps extends BaseFormBuilderProps { 11 | type: "multiSelect"; 12 | placeholder?: string; 13 | onValueChange: (name: string) => void; 14 | isChecked: (name: string) => boolean; 15 | value: string[]; 16 | properties: BaseFormBuilderProps["properties"] & { 17 | enumValue: 18 | | { [s: string]: string | number } 19 | | typeof FLAGS_CONFIG; 20 | formatEnumName?: boolean; 21 | }; 22 | } 23 | 24 | export function MultiSelectInput({ 25 | field, 26 | }: GenericFormElementProps>) { 27 | const { t } = useTranslation("deviceConfig"); 28 | const { enumValue, ...remainingProperties } = field.properties; 29 | 30 | const isNewConfigStructure = 31 | typeof Object.values(enumValue)[0] === "object" && 32 | Object.values(enumValue)[0] !== null && 33 | "i18nKey" in Object.values(enumValue)[0]; 34 | 35 | const optionsToRender = Object.entries(enumValue).map( 36 | ([key, configOrValue]) => { 37 | if (isNewConfigStructure) { 38 | const config = 39 | configOrValue as typeof FLAGS_CONFIG[keyof typeof FLAGS_CONFIG]; 40 | return { 41 | key, 42 | display: t(config.i18nKey), 43 | value: config.value, 44 | }; 45 | } 46 | return { key, display: key, value: configOrValue as number }; 47 | }, 48 | ); 49 | 50 | return ( 51 | 52 | {optionsToRender.map((option) => { 53 | return ( 54 | field.onValueChange(option.key)} 60 | > 61 | {option.display} 62 | 63 | ); 64 | })} 65 | 66 | ); 67 | } 68 | -------------------------------------------------------------------------------- /src/components/Form/FormPasswordGenerator.tsx: -------------------------------------------------------------------------------- 1 | import type { 2 | BaseFormBuilderProps, 3 | GenericFormElementProps, 4 | } from "@components/Form/DynamicForm.tsx"; 5 | import type { ButtonVariant } from "../UI/Button.tsx"; 6 | import { Generator } from "@components/UI/Generator.tsx"; 7 | import { Controller, type FieldValues, useFormContext } from "react-hook-form"; 8 | import { usePasswordVisibilityToggle } from "@core/hooks/usePasswordVisibilityToggle.ts"; 9 | import { useEffect } from "react"; 10 | 11 | export interface PasswordGeneratorProps extends BaseFormBuilderProps { 12 | type: "passwordGenerator"; 13 | id: string; 14 | hide?: boolean; 15 | bits?: { text: string; value: string; key: string }[]; 16 | devicePSKBitCount: number; 17 | inputChange?: React.ChangeEventHandler; 18 | selectChange?: (event: string) => void; 19 | actionButtons: { 20 | text: string; 21 | onClick: React.MouseEventHandler; 22 | variant: ButtonVariant; 23 | className?: string; 24 | }[]; 25 | showPasswordToggle?: boolean; 26 | showCopyButton?: boolean; 27 | } 28 | 29 | export function PasswordGenerator({ 30 | control, 31 | field, 32 | disabled, 33 | }: GenericFormElementProps>) { 34 | const { isVisible } = usePasswordVisibilityToggle(); 35 | const { trigger } = useFormContext(); 36 | 37 | useEffect(() => { 38 | trigger(field.name); 39 | }, [field.devicePSKBitCount, field.name, trigger]); 40 | 41 | return ( 42 | ( 46 | { 52 | if (field.inputChange) field.inputChange(e); 53 | onChange(e); 54 | }} 55 | selectChange={field.selectChange ?? (() => {})} 56 | value={value} 57 | variant={field.validationText ? "invalid" : "default"} 58 | actionButtons={field.actionButtons} 59 | showPasswordToggle={field.showPasswordToggle} 60 | showCopyButton={field.showCopyButton} 61 | {...field.properties} 62 | {...rest} 63 | disabled={disabled} 64 | /> 65 | )} 66 | /> 67 | ); 68 | } 69 | -------------------------------------------------------------------------------- /src/components/Form/FormToggle.tsx: -------------------------------------------------------------------------------- 1 | import type { 2 | BaseFormBuilderProps, 3 | GenericFormElementProps, 4 | } from "@components/Form/DynamicForm.tsx"; 5 | import { Switch } from "@components/UI/Switch.tsx"; 6 | import { Controller, type FieldValues } from "react-hook-form"; 7 | 8 | export interface ToggleFieldProps extends BaseFormBuilderProps { 9 | type: "toggle"; 10 | inputChange?: (value: boolean) => void; 11 | } 12 | 13 | export function ToggleInput({ 14 | control, 15 | disabled, 16 | field, 17 | }: GenericFormElementProps>) { 18 | return ( 19 | ( 23 | { 26 | onChange(v); 27 | field.inputChange?.(v); 28 | }} 29 | id={field.name} 30 | disabled={disabled} 31 | {...field.properties} 32 | {...rest} 33 | /> 34 | )} 35 | /> 36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /src/components/Form/FormWrapper.tsx: -------------------------------------------------------------------------------- 1 | import { Label } from "@components/UI/Label.tsx"; 2 | 3 | export interface FieldWrapperProps { 4 | label: string; 5 | fieldName: string; 6 | description?: string; 7 | disabled?: boolean; 8 | children?: React.ReactNode; 9 | valid?: boolean; 10 | validationText?: string; 11 | } 12 | 13 | export const FieldWrapper = ({ 14 | label, 15 | fieldName, 16 | description, 17 | children, 18 | valid, 19 | validationText, 20 | }: FieldWrapperProps) => ( 21 |
22 |
23 | {/* first column = labels/heading, second column = fields, third column = gutter */} 24 |
25 | 26 |
27 |

28 | {description} 29 |

30 | 33 |
34 |
{children}
35 |
36 |
37 |
38 |
39 |
40 | ); 41 | -------------------------------------------------------------------------------- /src/components/Form/createZodResolver.ts: -------------------------------------------------------------------------------- 1 | import type { ZodType } from "zod/v4"; 2 | import type { 3 | FieldError, 4 | FieldValues, 5 | Resolver, 6 | ResolverOptions, 7 | ResolverResult, 8 | } from "react-hook-form"; 9 | 10 | export function createZodResolver( 11 | schema: ZodType, 12 | ): Resolver { 13 | return ( 14 | values: T, 15 | _context: unknown, 16 | _options?: ResolverOptions, 17 | ): ResolverResult => { 18 | const result = schema.safeParse(values); 19 | if (result.success) { 20 | return { 21 | values: result.data, 22 | errors: {}, 23 | }; 24 | } 25 | 26 | const errors: Record< 27 | string, 28 | FieldError & { params?: Record } 29 | > = {}; 30 | 31 | for (const issue of result.error.issues) { 32 | const { path, code, message, ...params } = issue; 33 | const key = path.join("."); 34 | 35 | const suffix = "format" in params 36 | ? params.format 37 | : "origin" in params 38 | ? params.origin 39 | : "expected" in params 40 | ? params.expected 41 | : ""; 42 | 43 | const newCode = code.replace( 44 | /_([a-z])/g, 45 | (_, char) => char.toUpperCase(), 46 | ) + (suffix ? `.${suffix}` : ""); 47 | 48 | const fieldError: FieldError & { params?: Record } = { 49 | type: newCode, 50 | message: message, 51 | ...(Object.keys(params).length ? { params } : {}), 52 | }; 53 | 54 | if (!errors[key]) { 55 | errors[key] = fieldError; 56 | } 57 | } 58 | 59 | return { 60 | values: {} as T, 61 | errors, 62 | }; 63 | }; 64 | } 65 | -------------------------------------------------------------------------------- /src/components/KeyBackupReminder.tsx: -------------------------------------------------------------------------------- 1 | import { useBackupReminder } from "@core/hooks/useKeyBackupReminder.tsx"; 2 | import { useDevice } from "@core/stores/deviceStore.ts"; 3 | import { useTranslation } from "react-i18next"; 4 | 5 | export const KeyBackupReminder = () => { 6 | const { setDialogOpen } = useDevice(); 7 | const { t } = useTranslation("dialog"); 8 | 9 | useBackupReminder({ 10 | message: t("pkiBackup.description"), 11 | onAccept: () => setDialogOpen("pkiBackup", true), 12 | enabled: true, 13 | }); 14 | // deno-lint-ignore jsx-no-useless-fragment 15 | return <>; 16 | }; 17 | -------------------------------------------------------------------------------- /src/components/Map.tsx: -------------------------------------------------------------------------------- 1 | import MapGl, { 2 | AttributionControl, 3 | GeolocateControl, 4 | type MapRef, 5 | NavigationControl, 6 | ScaleControl, 7 | } from "react-map-gl/maplibre"; 8 | import { useTheme } from "@core/hooks/useTheme.ts"; 9 | import { useEffect, useRef } from "react"; 10 | 11 | interface MapProps { 12 | children?: React.ReactNode; 13 | onLoad?: (map: MapRef) => void; 14 | } 15 | 16 | export const Map = ({ children, onLoad }: MapProps) => { 17 | const { theme } = useTheme(); 18 | const darkMode = theme === "dark"; 19 | const mapRef = useRef(null); 20 | 21 | useEffect(() => { 22 | const map = mapRef.current; 23 | if (map && onLoad) onLoad(map); 24 | }, [onLoad]); 25 | 26 | return ( 27 | 42 | 48 | 53 | 54 | 55 | {children} 56 | 57 | ); 58 | }; 59 | -------------------------------------------------------------------------------- /src/components/PageComponents/Config/Bluetooth.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | type BluetoothValidation, 3 | BluetoothValidationSchema, 4 | } from "@app/validation/config/bluetooth.ts"; 5 | import { create } from "@bufbuild/protobuf"; 6 | import { DynamicForm } from "@components/Form/DynamicForm.tsx"; 7 | import { useDevice } from "@core/stores/deviceStore.ts"; 8 | import { Protobuf } from "@meshtastic/core"; 9 | import { useTranslation } from "react-i18next"; 10 | 11 | export const Bluetooth = () => { 12 | const { config, setWorkingConfig } = useDevice(); 13 | const { t } = useTranslation("deviceConfig"); 14 | 15 | const onSubmit = (data: BluetoothValidation) => { 16 | setWorkingConfig( 17 | create(Protobuf.Config.ConfigSchema, { 18 | payloadVariant: { 19 | case: "bluetooth", 20 | value: data, 21 | }, 22 | }), 23 | ); 24 | }; 25 | 26 | return ( 27 | 28 | onSubmit={onSubmit} 29 | validationSchema={BluetoothValidationSchema} 30 | formId="Config_BluetoothConfig" 31 | defaultValues={config.bluetooth} 32 | fieldGroups={[ 33 | { 34 | label: t("bluetooth.title"), 35 | description: t("bluetooth.description"), 36 | notes: t("bluetooth.note"), 37 | fields: [ 38 | { 39 | type: "toggle", 40 | name: "enabled", 41 | label: t("bluetooth.enabled.label"), 42 | description: t("bluetooth.enabled.description"), 43 | }, 44 | { 45 | type: "select", 46 | name: "mode", 47 | label: t("bluetooth.pairingMode.label"), 48 | description: t("bluetooth.pairingMode.description"), 49 | disabledBy: [ 50 | { 51 | fieldName: "enabled", 52 | }, 53 | ], 54 | properties: { 55 | enumValue: Protobuf.Config.Config_BluetoothConfig_PairingMode, 56 | formatEnumName: true, 57 | }, 58 | }, 59 | { 60 | type: "number", 61 | name: "fixedPin", 62 | label: t("bluetooth.pin.label"), 63 | description: t("bluetooth.pin.description"), 64 | }, 65 | ], 66 | }, 67 | ]} 68 | /> 69 | ); 70 | }; 71 | -------------------------------------------------------------------------------- /src/components/PageComponents/Messages/ChannelChat.tsx: -------------------------------------------------------------------------------- 1 | import { MessageItem } from "@components/PageComponents/Messages/MessageItem.tsx"; 2 | import { InboxIcon } from "lucide-react"; 3 | import { Message } from "@core/stores/messageStore/types.ts"; 4 | import { useTranslation } from "react-i18next"; 5 | 6 | export interface ChannelChatProps { 7 | messages?: Message[]; 8 | } 9 | 10 | const EmptyState = () => { 11 | const { t } = useTranslation("messages"); 12 | return ( 13 |
14 | 15 | {t("emptyState.text")} 16 |
17 | ); 18 | }; 19 | 20 | export const ChannelChat = ({ messages = [] }: ChannelChatProps) => { 21 | if (!messages?.length) { 22 | return ( 23 |
24 | 25 |
26 | ); 27 | } 28 | 29 | return ( 30 |
    31 | {messages?.map((message) => ( 32 | 36 | ))} 37 |
38 | ); 39 | }; 40 | -------------------------------------------------------------------------------- /src/components/PageComponents/Messages/MessageInput.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@components/UI/Button.tsx"; 2 | import { Input } from "@components/UI/Input.tsx"; 3 | import type { Types } from "@meshtastic/core"; 4 | import { SendIcon } from "lucide-react"; 5 | import { startTransition, useState } from "react"; 6 | import { useMessageStore } from "@core/stores/messageStore/index.ts"; 7 | 8 | export interface MessageInputProps { 9 | onSend: (message: string) => void; 10 | to: Types.Destination; 11 | maxBytes: number; 12 | } 13 | 14 | export const MessageInput = ({ 15 | onSend, 16 | to, 17 | maxBytes, 18 | }: MessageInputProps) => { 19 | const { setDraft, getDraft, clearDraft } = useMessageStore(); 20 | 21 | const calculateBytes = (text: string) => new Blob([text]).size; 22 | 23 | const initialDraft = getDraft(to); 24 | const [localDraft, setLocalDraft] = useState(initialDraft); 25 | const [messageBytes, setMessageBytes] = useState(() => 26 | calculateBytes(initialDraft) 27 | ); 28 | 29 | const handleInputChange = (e: React.ChangeEvent) => { 30 | const newValue = e.target.value; 31 | const byteLength = calculateBytes(newValue); 32 | 33 | if (byteLength <= maxBytes) { 34 | setLocalDraft(newValue); 35 | setMessageBytes(byteLength); 36 | setDraft(to, newValue); 37 | } 38 | }; 39 | 40 | const handleSubmit = (e: React.FormEvent) => { 41 | e.preventDefault(); 42 | if (!localDraft.trim()) return; 43 | // Reset bytes *before* sending (consider if onSend failure needs different handling) 44 | setMessageBytes(0); 45 | 46 | startTransition(() => { 47 | onSend(localDraft.trim()); 48 | setLocalDraft(""); 49 | clearDraft(to); 50 | }); 51 | }; 52 | 53 | return ( 54 |
55 |
56 |
57 | 68 | 69 | 75 | 76 | 82 |
83 |
84 |
85 | ); 86 | }; 87 | -------------------------------------------------------------------------------- /src/components/PageComponents/Messages/TraceRoute.tsx: -------------------------------------------------------------------------------- 1 | import { useDevice } from "@core/stores/deviceStore.ts"; 2 | import type { Protobuf } from "@meshtastic/core"; 3 | import { numberToHexUnpadded } from "@noble/curves/abstract/utils"; 4 | import { useTranslation } from "react-i18next"; 5 | 6 | export interface TraceRouteProps { 7 | from?: Protobuf.Mesh.NodeInfo; 8 | to?: Protobuf.Mesh.NodeInfo; 9 | route: Array; 10 | routeBack?: Array; 11 | snrTowards?: Array; 12 | snrBack?: Array; 13 | } 14 | 15 | interface RoutePathProps { 16 | title: string; 17 | startNode?: Protobuf.Mesh.NodeInfo; 18 | endNode?: Protobuf.Mesh.NodeInfo; 19 | path: number[]; 20 | snr?: number[]; 21 | } 22 | 23 | const RoutePath = ( 24 | { title, startNode, endNode, path, snr }: RoutePathProps, 25 | ) => { 26 | const { getNode } = useDevice(); 27 | const { t } = useTranslation(); 28 | 29 | return ( 30 | 34 |

{title}

35 |

{startNode?.user?.longName}

36 |

37 | ↓ {snr?.[0] ?? t("unknown.num")} 38 | {t("unit.dbm")} 39 |

40 | {path.map((hop, i) => ( 41 | 42 |

43 | {getNode(hop)?.user?.longName ?? 44 | `${t("traceRoute.nodeUnknownPrefix")}${numberToHexUnpadded(hop)}`} 45 |

46 |

47 | ↓ {snr?.[i + 1] ?? t("unknown.num")} 48 | {t("unit.dbm")} 49 |

50 |
51 | ))} 52 |

{endNode?.user?.longName}

53 |
54 | ); 55 | }; 56 | 57 | export const TraceRoute = ({ 58 | from, 59 | to, 60 | route, 61 | routeBack, 62 | snrTowards, 63 | snrBack, 64 | }: TraceRouteProps) => { 65 | const { t } = useTranslation("dialog"); 66 | return ( 67 |
68 | 75 | {routeBack && routeBack.length > 0 && ( 76 | 83 | )} 84 |
85 | ); 86 | }; 87 | -------------------------------------------------------------------------------- /src/components/PageComponents/ModuleConfig/AmbientLighting.tsx: -------------------------------------------------------------------------------- 1 | import { useDevice } from "@core/stores/deviceStore.ts"; 2 | import { 3 | type AmbientLightingValidation, 4 | AmbientLightingValidationSchema, 5 | } from "@app/validation/moduleConfig/ambientLighting.ts"; 6 | import { create } from "@bufbuild/protobuf"; 7 | import { DynamicForm } from "@components/Form/DynamicForm.tsx"; 8 | import { Protobuf } from "@meshtastic/core"; 9 | import { useTranslation } from "react-i18next"; 10 | 11 | export const AmbientLighting = () => { 12 | const { moduleConfig, setWorkingModuleConfig } = useDevice(); 13 | const { t } = useTranslation("moduleConfig"); 14 | 15 | const onSubmit = (data: AmbientLightingValidation) => { 16 | setWorkingModuleConfig( 17 | create(Protobuf.ModuleConfig.ModuleConfigSchema, { 18 | payloadVariant: { 19 | case: "ambientLighting", 20 | value: data, 21 | }, 22 | }), 23 | ); 24 | }; 25 | 26 | return ( 27 | 28 | onSubmit={onSubmit} 29 | validationSchema={AmbientLightingValidationSchema} 30 | formId="ModuleConfig_AmbientLightingConfig" 31 | defaultValues={moduleConfig.ambientLighting} 32 | fieldGroups={[ 33 | { 34 | label: t("ambientLighting.title"), 35 | description: t("ambientLighting.description"), 36 | fields: [ 37 | { 38 | type: "toggle", 39 | name: "ledState", 40 | label: t("ambientLighting.ledState.label"), 41 | description: t("ambientLighting.ledState.description"), 42 | }, 43 | { 44 | type: "number", 45 | name: "current", 46 | label: t("ambientLighting.current.label"), 47 | description: t("ambientLighting.current.description"), 48 | }, 49 | { 50 | type: "number", 51 | name: "red", 52 | label: t("ambientLighting.red.label"), 53 | description: t("ambientLighting.red.description"), 54 | }, 55 | { 56 | type: "number", 57 | name: "green", 58 | label: t("ambientLighting.green.label"), 59 | description: t("ambientLighting.green.description"), 60 | }, 61 | { 62 | type: "number", 63 | name: "blue", 64 | label: t("ambientLighting.blue.label"), 65 | description: t("ambientLighting.blue.description"), 66 | }, 67 | ], 68 | }, 69 | ]} 70 | /> 71 | ); 72 | }; 73 | -------------------------------------------------------------------------------- /src/components/PageComponents/ModuleConfig/NeighborInfo.tsx: -------------------------------------------------------------------------------- 1 | import { useDevice } from "@core/stores/deviceStore.ts"; 2 | import { 3 | type NeighborInfoValidation, 4 | NeighborInfoValidationSchema, 5 | } from "@app/validation/moduleConfig/neighborInfo.ts"; 6 | import { create } from "@bufbuild/protobuf"; 7 | import { DynamicForm } from "@components/Form/DynamicForm.tsx"; 8 | import { Protobuf } from "@meshtastic/core"; 9 | import { useTranslation } from "react-i18next"; 10 | 11 | export const NeighborInfo = () => { 12 | const { moduleConfig, setWorkingModuleConfig } = useDevice(); 13 | const { t } = useTranslation("moduleConfig"); 14 | 15 | const onSubmit = (data: NeighborInfoValidation) => { 16 | setWorkingModuleConfig( 17 | create(Protobuf.ModuleConfig.ModuleConfigSchema, { 18 | payloadVariant: { 19 | case: "neighborInfo", 20 | value: data, 21 | }, 22 | }), 23 | ); 24 | }; 25 | 26 | return ( 27 | 28 | onSubmit={onSubmit} 29 | validationSchema={NeighborInfoValidationSchema} 30 | formId="ModuleConfig_NeighborInfoConfig" 31 | defaultValues={moduleConfig.neighborInfo} 32 | fieldGroups={[ 33 | { 34 | label: t("neighborInfo.title"), 35 | description: t("neighborInfo.description"), 36 | fields: [ 37 | { 38 | type: "toggle", 39 | name: "enabled", 40 | label: t("neighborInfo.enabled.label"), 41 | description: t("neighborInfo.enabled.description"), 42 | }, 43 | { 44 | type: "number", 45 | name: "updateInterval", 46 | label: t("neighborInfo.updateInterval.label"), 47 | description: t("neighborInfo.updateInterval.description"), 48 | properties: { 49 | suffix: t("unit.second.plural"), 50 | }, 51 | disabledBy: [ 52 | { 53 | fieldName: "enabled", 54 | }, 55 | ], 56 | }, 57 | ], 58 | }, 59 | ]} 60 | /> 61 | ); 62 | }; 63 | -------------------------------------------------------------------------------- /src/components/PageComponents/ModuleConfig/RangeTest.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | type RangeTestValidation, 3 | RangeTestValidationSchema, 4 | } from "@app/validation/moduleConfig/rangeTest.ts"; 5 | import { create } from "@bufbuild/protobuf"; 6 | import { DynamicForm } from "@components/Form/DynamicForm.tsx"; 7 | import { useDevice } from "@core/stores/deviceStore.ts"; 8 | import { Protobuf } from "@meshtastic/core"; 9 | import { useTranslation } from "react-i18next"; 10 | 11 | export const RangeTest = () => { 12 | const { moduleConfig, setWorkingModuleConfig } = useDevice(); 13 | const { t } = useTranslation("moduleConfig"); 14 | 15 | const onSubmit = (data: RangeTestValidation) => { 16 | setWorkingModuleConfig( 17 | create(Protobuf.ModuleConfig.ModuleConfigSchema, { 18 | payloadVariant: { 19 | case: "rangeTest", 20 | value: data, 21 | }, 22 | }), 23 | ); 24 | }; 25 | 26 | return ( 27 | 28 | onSubmit={onSubmit} 29 | validationSchema={RangeTestValidationSchema} 30 | formId="ModuleConfig_RangeTestConfig" 31 | defaultValues={moduleConfig.rangeTest} 32 | fieldGroups={[ 33 | { 34 | label: t("rangeTest.title"), 35 | description: t("rangeTest.description"), 36 | fields: [ 37 | { 38 | type: "toggle", 39 | name: "enabled", 40 | label: t("rangeTest.enabled.label"), 41 | description: t("rangeTest.enabled.description"), 42 | }, 43 | { 44 | type: "number", 45 | name: "sender", 46 | label: t("rangeTest.sender.label"), 47 | description: t("rangeTest.sender.description"), 48 | properties: { 49 | suffix: t("unit.second.plural"), 50 | }, 51 | disabledBy: [ 52 | { 53 | fieldName: "enabled", 54 | }, 55 | ], 56 | }, 57 | { 58 | type: "toggle", 59 | name: "save", 60 | label: t("rangeTest.save.label"), 61 | description: t("rangeTest.save.description"), 62 | disabledBy: [ 63 | { 64 | fieldName: "enabled", 65 | }, 66 | ], 67 | }, 68 | ], 69 | }, 70 | ]} 71 | /> 72 | ); 73 | }; 74 | -------------------------------------------------------------------------------- /src/components/Toaster.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Toast, 3 | ToastClose, 4 | ToastDescription, 5 | ToastProvider, 6 | ToastTitle, 7 | ToastViewport, 8 | } from "@components/UI/Toast.tsx"; 9 | import { useToast } from "@core/hooks/useToast.ts"; 10 | 11 | export function Toaster() { 12 | const { toasts } = useToast(); 13 | 14 | return ( 15 | 16 | {toasts.map(({ id, title, description, action, duration, ...props }) => ( 17 | 23 |
24 | {title && {title}} 25 | {description && {description}} 26 |
27 | {action} 28 | 29 |
30 | ))} 31 | 32 |
33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /src/components/UI/Accordion.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@core/utils/cn.ts"; 2 | import * as AccordionPrimitive from "@radix-ui/react-accordion"; 3 | import { ChevronDownIcon } from "lucide-react"; 4 | import { type ComponentRef, forwardRef } from "react"; 5 | 6 | export const Accordion = AccordionPrimitive.Root; 7 | 8 | export const AccordionHeader = AccordionPrimitive.Header; 9 | 10 | export const AccordionItem = AccordionPrimitive.Item; 11 | 12 | export const AccordionTrigger = forwardRef< 13 | ComponentRef, 14 | React.ComponentPropsWithoutRef 15 | >(({ className, ...props }, ref) => ( 16 | 24 | {props.children} 25 | 29 | 30 | )); 31 | 32 | export const AccordionContent = forwardRef< 33 | ComponentRef, 34 | React.ComponentPropsWithoutRef 35 | >(({ className, ...props }, ref) => ( 36 | 44 | )); 45 | -------------------------------------------------------------------------------- /src/components/UI/Footer.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@core/utils/cn.ts"; 2 | import { Trans, useTranslation } from "react-i18next"; 3 | 4 | type FooterProps = { 5 | className?: string; 6 | }; 7 | 8 | const Footer = ({ className, ...props }: FooterProps) => { 9 | const { t } = useTranslation(); 10 | 11 | return ( 12 |
19 |
20 | 21 | {t("footer.commitSha", { 22 | sha: String(import.meta.env.VITE_COMMIT_HASH)?.toUpperCase(), 23 | })} 24 | 25 |
26 |

27 | , 36 | , 42 | ]} 43 | /> 44 |

45 |
46 | ); 47 | }; 48 | 49 | export default Footer; 50 | -------------------------------------------------------------------------------- /src/components/UI/Label.tsx: -------------------------------------------------------------------------------- 1 | import * as LabelPrimitive from "@radix-ui/react-label"; 2 | import * as React from "react"; 3 | 4 | import { cn } from "@core/utils/cn.ts"; 5 | 6 | const Label = React.forwardRef< 7 | React.ElementRef, 8 | React.ComponentPropsWithoutRef 9 | >(({ className, ...props }, ref) => ( 10 | 18 | )); 19 | Label.displayName = LabelPrimitive.Root.displayName; 20 | 21 | export { Label }; 22 | -------------------------------------------------------------------------------- /src/components/UI/MultiSelect.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "../../core/utils/cn.ts"; 2 | import * as CheckboxPrimitive from "@radix-ui/react-checkbox"; 3 | import { Check } from "lucide-react"; 4 | 5 | interface MultiSelectProps { 6 | children: React.ReactNode; 7 | className?: string; 8 | } 9 | 10 | const MultiSelect = ({ children, className = "" }: MultiSelectProps) => { 11 | return
{children} 12 |
; 13 | }; 14 | 15 | interface MultiSelectItemProps { 16 | name: string; 17 | value: string; 18 | checked: boolean; 19 | onCheckedChange: (name: string, value: boolean) => void; 20 | children: React.ReactNode; 21 | className?: string; 22 | } 23 | 24 | const MultiSelectItem = ({ 25 | name, 26 | value, 27 | checked, 28 | onCheckedChange, 29 | children, 30 | className = "", 31 | }: MultiSelectItemProps) => { 32 | return ( 33 | onCheckedChange(name, !!val)} 38 | className={cn( 39 | ` 40 | inline-flex items-center rounded-md px-3 py-2 text-sm transition-colors 41 | border border-slate-300 42 | hover:bg-slate-100 dark:hover:bg-slate-800 43 | focus:outline-hidden focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 44 | data-[state=checked]:bg-slate-100 dark:data-[state=checked]:bg-slate-700`, 45 | className, 46 | )} 47 | > 48 | 49 | 50 | 51 | {children} 52 | 53 | ); 54 | }; 55 | 56 | export { MultiSelect, MultiSelectItem }; 57 | -------------------------------------------------------------------------------- /src/components/UI/Popover.tsx: -------------------------------------------------------------------------------- 1 | import * as PopoverPrimitive from "@radix-ui/react-popover"; 2 | import * as React from "react"; 3 | 4 | import { cn } from "@core/utils/cn.ts"; 5 | 6 | const Popover = PopoverPrimitive.Root; 7 | 8 | const PopoverTrigger = PopoverPrimitive.Trigger; 9 | 10 | const PopoverContent = React.forwardRef< 11 | React.ElementRef, 12 | React.ComponentPropsWithoutRef 13 | >(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( 14 | 15 | 25 | 26 | )); 27 | PopoverContent.displayName = PopoverPrimitive.Content.displayName; 28 | 29 | export { Popover, PopoverContent, PopoverTrigger }; 30 | -------------------------------------------------------------------------------- /src/components/UI/ScrollArea.tsx: -------------------------------------------------------------------------------- 1 | import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"; 2 | import * as React from "react"; 3 | 4 | import { cn } from "@core/utils/cn.ts"; 5 | 6 | const ScrollArea = React.forwardRef< 7 | React.ElementRef, 8 | React.ComponentPropsWithoutRef 9 | >(({ className, children, ...props }, ref) => ( 10 | 15 | 16 | {children} 17 | 18 | 19 | 20 | 21 | )); 22 | ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName; 23 | 24 | const ScrollBar = React.forwardRef< 25 | React.ElementRef, 26 | React.ComponentPropsWithoutRef 27 | >(({ className, orientation = "vertical", ...props }, ref) => ( 28 | 41 | 42 | 43 | )); 44 | ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName; 45 | 46 | export { ScrollArea, ScrollBar }; 47 | -------------------------------------------------------------------------------- /src/components/UI/Seperator.tsx: -------------------------------------------------------------------------------- 1 | import * as SeparatorPrimitive from "@radix-ui/react-separator"; 2 | import * as React from "react"; 3 | 4 | import { cn } from "@core/utils/cn.ts"; 5 | 6 | const Separator = React.forwardRef< 7 | React.ElementRef, 8 | React.ComponentPropsWithoutRef 9 | >( 10 | ( 11 | { className, orientation = "horizontal", decorative = true, ...props }, 12 | ref, 13 | ) => ( 14 | 25 | ), 26 | ); 27 | Separator.displayName = SeparatorPrimitive.Root.displayName; 28 | 29 | export { Separator }; 30 | -------------------------------------------------------------------------------- /src/components/UI/Sidebar/SidebarButton.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Button } from "@components/UI/Button.tsx"; 3 | import type { LucideIcon } from "lucide-react"; 4 | import { cn } from "@core/utils/cn.ts"; 5 | import { useSidebar } from "@core/stores/sidebarStore.tsx"; 6 | 7 | export interface SidebarButtonProps { 8 | label: string; 9 | count?: number; 10 | active?: boolean; 11 | Icon?: LucideIcon; 12 | children?: React.ReactNode; 13 | onClick?: () => void; 14 | disabled?: boolean; 15 | preventCollapse?: boolean; 16 | } 17 | 18 | export const SidebarButton = ({ 19 | label, 20 | active, 21 | Icon, 22 | count, 23 | children, 24 | onClick, 25 | disabled = false, 26 | preventCollapse = false, 27 | }: SidebarButtonProps) => { 28 | const { isCollapsed: isSidebarCollapsed } = useSidebar(); 29 | const isButtonCollapsed = isSidebarCollapsed && !preventCollapse; 30 | 31 | return ( 32 | 80 | ); 81 | }; 82 | -------------------------------------------------------------------------------- /src/components/UI/Sidebar/SidebarSection.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { cn } from "@core/utils/cn.ts"; 3 | import { Heading } from "@components/UI/Typography/Heading.tsx"; 4 | import { useSidebar } from "@core/stores/sidebarStore.tsx"; 5 | 6 | interface SidebarSectionProps { 7 | label: string; 8 | children: React.ReactNode; 9 | className?: string; 10 | } 11 | 12 | export const SidebarSection = ({ 13 | label, 14 | children, 15 | className, 16 | }: SidebarSectionProps) => { 17 | const { isCollapsed } = useSidebar(); 18 | return ( 19 |
26 | 38 | {label} 39 | 40 | 41 |
42 | {children} 43 |
44 |
45 | ); 46 | }; 47 | -------------------------------------------------------------------------------- /src/components/UI/Slider.tsx: -------------------------------------------------------------------------------- 1 | import { useId, useState } from "react"; 2 | import * as SliderPrimitive from "@radix-ui/react-slider"; 3 | import { cn } from "@core/utils/cn.ts"; 4 | 5 | export interface SliderProps { 6 | value: number[]; 7 | step?: number; 8 | min?: number; 9 | max: number; 10 | onValueChange?: (value: number[]) => void; 11 | onValueCommit?: (value: number[]) => void; 12 | disabled?: boolean; 13 | className?: string; 14 | trackClassName?: string; 15 | rangeClassName?: string; 16 | thumbClassName?: string; 17 | } 18 | 19 | export function Slider({ 20 | value, 21 | step = 1, 22 | min = 0, 23 | max, 24 | onValueChange, 25 | onValueCommit, 26 | disabled = false, 27 | className, 28 | trackClassName, 29 | rangeClassName, 30 | thumbClassName, 31 | ...props 32 | }: SliderProps) { 33 | const [internalValue, setInternalValue] = useState(value); 34 | const isControlled = value !== undefined; 35 | const currentValue = isControlled ? value! : internalValue; 36 | const id = useId(); 37 | 38 | const handleValueChange = (newValue: number[]) => { 39 | if (!isControlled) setInternalValue(newValue); 40 | onValueChange?.(newValue); 41 | }; 42 | 43 | const handleValueCommit = (newValue: number[]) => { 44 | onValueCommit?.(newValue); 45 | }; 46 | 47 | return ( 48 | 62 | 68 | 74 | 75 | {currentValue.map((_, i) => ( 76 | 84 | ))} 85 | 86 | ); 87 | } 88 | -------------------------------------------------------------------------------- /src/components/UI/Spinner.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "../../core/utils/cn.ts"; 2 | 3 | interface SpinnerProps extends React.HTMLAttributes { 4 | size?: "sm" | "md" | "lg"; 5 | } 6 | 7 | const sizeClasses = { 8 | sm: "h-4 w-4", 9 | md: "h-8 w-8", 10 | lg: "h-12 w-12", 11 | }; 12 | 13 | export function Spinner({ className, size = "md", ...props }: SpinnerProps) { 14 | return ( 15 |
23 | 29 | 38 | 47 | 56 | 65 | 74 | 83 | 92 | 101 | 102 |
103 | ); 104 | } 105 | -------------------------------------------------------------------------------- /src/components/UI/Switch.tsx: -------------------------------------------------------------------------------- 1 | import * as SwitchPrimitives from "@radix-ui/react-switch"; 2 | import * as React from "react"; 3 | 4 | import { cn } from "@core/utils/cn.ts"; 5 | 6 | const Switch = React.forwardRef< 7 | React.ElementRef, 8 | React.ComponentPropsWithoutRef 9 | >(({ className, ...props }, ref) => ( 10 | 18 | 23 | 24 | )); 25 | Switch.displayName = SwitchPrimitives.Root.displayName; 26 | 27 | export { Switch }; 28 | -------------------------------------------------------------------------------- /src/components/UI/Tabs.tsx: -------------------------------------------------------------------------------- 1 | import * as TabsPrimitive from "@radix-ui/react-tabs"; 2 | import * as React from "react"; 3 | 4 | import { cn } from "@core/utils/cn.ts"; 5 | 6 | const Tabs = TabsPrimitive.Root; 7 | 8 | const TabsList = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, ...props }, ref) => ( 12 | 20 | )); 21 | TabsList.displayName = TabsPrimitive.List.displayName; 22 | 23 | const TabsTrigger = React.forwardRef< 24 | React.ElementRef, 25 | React.ComponentPropsWithoutRef 26 | >(({ className, ...props }, ref) => ( 27 | 35 | )); 36 | TabsTrigger.displayName = TabsPrimitive.Trigger.displayName; 37 | 38 | const TabsContent = React.forwardRef< 39 | React.ElementRef, 40 | React.ComponentPropsWithoutRef 41 | >(({ className, ...props }, ref) => ( 42 | 50 | )); 51 | TabsContent.displayName = TabsPrimitive.Content.displayName; 52 | 53 | export { Tabs, TabsContent, TabsList, TabsTrigger }; 54 | -------------------------------------------------------------------------------- /src/components/UI/ToggleGroup.tsx: -------------------------------------------------------------------------------- 1 | import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group"; 2 | import * as React from "react"; 3 | import { cn } from "@core/utils/cn.ts"; 4 | 5 | const toggleGroupItemClasses = [ 6 | "flex flex-1 h-10 items-center justify-center first:rounded-l last:rounded-r ", 7 | "bg-slate-100", 8 | "dark:bg-slate-800", 9 | "data-[state=on]:bg-slate-600 data-[state=on]:text-white", 10 | "data-[state=on]:dark:bg-slate-950 data-[state=on]:text-white data-[state=on]:dark:text-slate-200", 11 | "data-[state=on]:hover:bg-slate-700 hover:bg-slate-700 hover:text-white hover:z-10 hover:shadow-[0_0_1px_2px] hover:outline-1 hover:outline-slate-700 hover:shadow-white/10", 12 | "data-[state=on]:dark:hover:bg-slate-700 dark:hover:bg-slate-700 dark:hover:text-slate-200 dark:hover:outline-slate-700 dark:hover:shadow-black/20", 13 | "disabled:text-slate-300 disabled:hover:bg-slate-100 disabled:hover:outline-none hover:shadow-none disabled:dark:text-slate-600 disabled:dark:hover:bg-slate-800 disabled:dark:hover:outline-none disabled:shadow-none", 14 | ]; 15 | 16 | const ToggleGroup = React.forwardRef< 17 | React.ComponentRef, 18 | React.ComponentPropsWithoutRef 19 | >(({ className, ...props }, ref) => ( 20 | 28 | )); 29 | ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName; 30 | 31 | const ToggleGroupItem = React.forwardRef< 32 | React.ComponentRef, 33 | React.ComponentPropsWithoutRef 34 | >(({ className, children, ...props }, ref) => ( 35 | 43 | {children} 44 | 45 | )); 46 | ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName; 47 | 48 | export { ToggleGroup, ToggleGroupItem }; 49 | -------------------------------------------------------------------------------- /src/components/UI/Tooltip.tsx: -------------------------------------------------------------------------------- 1 | import * as TooltipPrimitive from "@radix-ui/react-tooltip"; 2 | import * as React from "react"; 3 | 4 | import { cn } from "@core/utils/cn.ts"; 5 | 6 | const TooltipProvider = TooltipPrimitive.Provider; 7 | 8 | const Tooltip = ({ ...props }) => ; 9 | Tooltip.displayName = TooltipPrimitive.Tooltip.displayName; 10 | 11 | const TooltipTrigger = TooltipPrimitive.Trigger; 12 | const TooltipArrow = TooltipPrimitive.Arrow; 13 | 14 | const TooltipContent = React.forwardRef< 15 | React.ElementRef, 16 | React.ComponentPropsWithoutRef 17 | >(({ className, sideOffset = 4, ...props }, ref) => ( 18 | 27 | )); 28 | TooltipContent.displayName = TooltipPrimitive.Content.displayName; 29 | 30 | export { 31 | Tooltip, 32 | TooltipArrow, 33 | TooltipContent, 34 | TooltipProvider, 35 | TooltipTrigger, 36 | }; 37 | -------------------------------------------------------------------------------- /src/components/UI/Typography/Blockquote.tsx: -------------------------------------------------------------------------------- 1 | export interface BlockquoteProps { 2 | children: React.ReactNode; 3 | } 4 | 5 | export const BlockQuote = ({ children }: BlockquoteProps) => ( 6 |
7 | {children} 8 |
9 | ); 10 | -------------------------------------------------------------------------------- /src/components/UI/Typography/Code.tsx: -------------------------------------------------------------------------------- 1 | export interface CodeProps { 2 | children: React.ReactNode; 3 | } 4 | 5 | export const Code = ({ children }: CodeProps) => ( 6 | 7 | {children} 8 | 9 | ); 10 | -------------------------------------------------------------------------------- /src/components/UI/Typography/Heading.tsx: -------------------------------------------------------------------------------- 1 | import type React from "react"; 2 | 3 | const headingStyles = { 4 | h1: "scroll-m-20 text-4xl font-extrabold tracking-tight lg:text-5xl", 5 | h2: 6 | "scroll-m-20 border-b border-b-slate-200 pb-2 text-3xl font-semibold tracking-tight transition-colors first:mt-0 dark:border-b-slate-700", 7 | h3: "scroll-m-20 text-lg font-semibold tracking-tight", 8 | h4: "scroll-m-20 text-xl font-semibold tracking-tight", 9 | h5: "scroll-m-20 text-lg font-medium tracking-tight", 10 | }; 11 | 12 | interface HeadingProps { 13 | as?: "h1" | "h2" | "h3" | "h4" | "h5"; 14 | children: React.ReactNode; 15 | className?: string; 16 | } 17 | 18 | export const Heading = ({ 19 | as: Component = "h1", 20 | children, 21 | className = "", 22 | ...props 23 | }: HeadingProps) => { 24 | const baseStyles = headingStyles[Component] || headingStyles.h1; 25 | 26 | return ( 27 | 28 | {children} 29 | 30 | ); 31 | }; 32 | -------------------------------------------------------------------------------- /src/components/UI/Typography/Link.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@core/utils/cn.ts"; 2 | import { 3 | Link as RouterLink, 4 | LinkProps as RouterLinkProps, 5 | } from "@tanstack/react-router"; 6 | 7 | export interface LinkProps extends RouterLinkProps { 8 | href: string; 9 | children?: React.ReactNode; 10 | className?: string; 11 | } 12 | 13 | export const Link = ({ href, children, className }: LinkProps) => ( 14 | 23 | {children} 24 | 25 | ); 26 | -------------------------------------------------------------------------------- /src/components/UI/Typography/P.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@core/utils/cn.ts"; 2 | 3 | export interface PProps { 4 | children: React.ReactNode; 5 | className?: string; 6 | } 7 | 8 | export const P = ({ children, className }: PProps) => ( 9 |

{children}

10 | ); 11 | -------------------------------------------------------------------------------- /src/components/UI/Typography/Subtle.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@core/utils/cn.ts"; 2 | 3 | export interface SubtleProps { 4 | className?: string; 5 | children: React.ReactNode; 6 | } 7 | 8 | export const Subtle = ({ className, children }: SubtleProps) => ( 9 |

10 | {children} 11 |

12 | ); 13 | -------------------------------------------------------------------------------- /src/components/generic/Blur.tsx: -------------------------------------------------------------------------------- 1 | export const Blur = () => { 2 | return ( 3 |