diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index e0d41bd6..c44eaa18 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -46,4 +46,4 @@ Check all that apply. If an item doesn't apply to your PR, you can leave it unch - [ ] Code follows project style guidelines - [ ] Documentation has been updated or added - [ ] Tests have been added or updated -- [ ] All i18n translation labels have bee added +- [ ] All i18n translation labels have been added/updated diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 47fe8c23..29451318 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -24,18 +24,33 @@ jobs: key: ${{ runner.os }}-deno-${{ hashFiles('**/deno.lock') }} restore-keys: | ${{ runner.os }}-deno- - + - name: Install Dependencies run: deno install - name: Cache Dependencies run: deno cache src/index.tsx - - name: Run linter - run: deno task lint + - name: Get changed files + id: changed-files + uses: tj-actions/changed-files@v44 + with: + files: | + **/*.ts + **/*.tsx - - name: Check formatter - run: deno task format --check + # Uncomment the following lines when you have figured out how to ignore files + # - name: Type check changed files + # if: steps.changed-files.outputs.all_changed_files != '' + # run: deno check ${{ steps.changed-files.outputs.all_changed_files }} + + - name: Run linter on changed files + if: steps.changed-files.outputs.all_changed_files != '' + run: deno task lint ${{ steps.changed-files.outputs.all_changed_files }} + + - name: Check format on changed files + if: steps.changed-files.outputs.all_changed_files != '' + run: deno task format --check ${{ steps.changed-files.outputs.all_changed_files }} - name: Run tests run: deno task test diff --git a/deno.json b/deno.json index 36466202..6320ae08 100644 --- a/deno.json +++ b/deno.json @@ -7,6 +7,7 @@ "@layouts/": "./src/layouts/", "@std/path": "jsr:@std/path@^1.1.0" }, + "include": ["src", "./vite-env.d.ts"], "compilerOptions": { "lib": [ "DOM", @@ -24,26 +25,35 @@ "types": [ "vite/client", "node", - "@types/web-bluetooth", - "@types/w3c-web-serial" + "npm:@types/w3c-web-serial", + "npm:@types/web-bluetooth" ], "strictPropertyInitialization": false }, "fmt": { "exclude": [ - "src/*.gen.ts", + "src/routeTree.gen.ts", "*.test.ts", "*.test.tsx" ] }, "lint": { "exclude": [ - "src/*.gen.ts", + "src/routeTree.gen.ts", "*.test.ts", "*.test.tsx" ], "report": "pretty" }, + "exclude": [ + "routeTree.gen.ts", + "node_modules/", + "dist", + "build", + "coverage", + "out", + ".vscode-test" + ], "unstable": [ "sloppy-imports" ] diff --git a/deno.lock b/deno.lock index 0300c35a..d63863ec 100644 --- a/deno.lock +++ b/deno.lock @@ -5,6 +5,7 @@ "jsr:@std/path@^1.1.0": "1.1.0", "npm:@bufbuild/protobuf@^2.2.5": "2.2.5", "npm:@jsr/meshtastic__core@2.6.2": "2.6.2", + "npm:@jsr/meshtastic__core@2.6.4": "2.6.4", "npm:@jsr/meshtastic__js@2.6.0-0": "2.6.0-0", "npm:@jsr/meshtastic__transport-http@*": "0.2.1", "npm:@jsr/meshtastic__transport-web-bluetooth@*": "0.1.1", @@ -41,12 +42,13 @@ "npm:@types/react-dom@^19.1.3": "19.1.3_@types+react@19.1.2", "npm:@types/react@^19.1.2": "19.1.2", "npm:@types/serviceworker@^0.0.133": "0.0.133", + "npm:@types/w3c-web-serial@*": "1.0.8", "npm:@types/w3c-web-serial@^1.0.8": "1.0.8", + "npm:@types/web-bluetooth@*": "0.0.21", "npm:@types/web-bluetooth@^0.0.21": "0.0.21", "npm:@vitejs/plugin-react@^4.4.1": "4.4.1_vite@6.3.4__@types+node@22.15.3__picomatch@4.0.2_@babel+core@7.27.1_@types+node@22.15.3", "npm:autoprefixer@^10.4.21": "10.4.21_postcss@8.5.3", "npm:base64-js@^1.5.1": "1.5.1", - "npm:class-validator@~0.14.2": "0.14.2", "npm:class-variance-authority@~0.7.1": "0.7.1", "npm:clsx@^2.1.1": "2.1.1", "npm:cmdk@^1.1.1": "1.1.1_react@19.1.0_react-dom@19.1.0__react@19.1.0_@types+react@19.1.2_@types+react-dom@19.1.3__@types+react@19.1.2", @@ -77,14 +79,13 @@ "npm:tar@^7.4.3": "7.4.3", "npm:testing-library@^0.0.2": "0.0.2_@angular+common@6.1.10__@angular+core@6.1.10___rxjs@6.6.7___zone.js@0.8.29__rxjs@6.6.7_@angular+core@6.1.10__rxjs@6.6.7__zone.js@0.8.29", "npm:typescript@^5.8.3": "5.8.3", - "npm:vite-plugin-i18n-ally@^6.0.1": "6.0.1_vite@6.3.4__@types+node@22.15.3__picomatch@4.0.2_@types+node@22.15.3", - "npm:vite-plugin-node-polyfills@0.23": "0.23.0_vite@6.3.4__@types+node@22.15.3__picomatch@4.0.2_@types+node@22.15.3", "npm:vite-plugin-pwa@1": "1.0.0_vite@6.3.4__@types+node@22.15.3__picomatch@4.0.2_workbox-build@7.3.0__ajv@8.17.1__@babel+core@7.27.1__rollup@2.79.2_workbox-window@7.3.0_@types+node@22.15.3", "npm:vite-plugin-static-copy@3": "3.0.0_vite@6.3.4__@types+node@22.15.3__picomatch@4.0.2_@types+node@22.15.3", "npm:vite@^6.3.4": "6.3.4_@types+node@22.15.3_picomatch@4.0.2", "npm:vitest@^3.1.2": "3.1.2_@types+node@22.15.3_happy-dom@17.4.6_vite@6.3.4__@types+node@22.15.3__picomatch@4.0.2", - "npm:zod@^3.24.3": "3.24.3", - "npm:zustand@5.0.4": "5.0.4_@types+react@19.1.2_immer@10.1.1_react@19.1.0" + "npm:zod@^3.25.0": "3.25.49", + "npm:zod@^3.25.62": "3.25.62", + "npm:zustand@5.0.5": "5.0.5_@types+react@19.1.2_immer@10.1.1_react@19.1.0" }, "jsr": { "@std/path@1.0.6": { @@ -1131,6 +1132,17 @@ ], "tarball": "https://npm.jsr.io/~/11/@jsr/meshtastic__core/2.6.2.tgz" }, + "@jsr/meshtastic__core@2.6.4": { + "integrity": "sha512-1Kz5DK6peFxluHOJR38vFwfgeJzMXTz+3p6TvibjILVhSQC2U1nu8aJbn6w5zhRqS+j79OmtrRvdzL6VNsTkkQ==", + "dependencies": [ + "@bufbuild/protobuf", + "@jsr/meshtastic__protobufs", + "crc", + "ste-simple-events", + "tslog" + ], + "tarball": "https://npm.jsr.io/~/11/@jsr/meshtastic__core/2.6.4.tgz" + }, "@jsr/meshtastic__js@2.6.0-0": { "integrity": "sha512-+xpZpxK6oUIVOuEs7C+LyxRr2druvc7UNNNTK9Rl8ioXj63Jz1uQXlYe2Gj0xjnRAiSQLR7QVaPef21BR/YTxA==", "dependencies": [ @@ -1152,21 +1164,21 @@ "@jsr/meshtastic__transport-http@0.2.1": { "integrity": "sha512-lmQKr3aIINKvtGROU4HchmSVqbZSbkIHqajowRRC8IAjsnR0zNTyxz210QyY4pFUF9hpcW3GRjwq5h/VO2JuGg==", "dependencies": [ - "@jsr/meshtastic__core" + "@jsr/meshtastic__core@2.6.2" ], "tarball": "https://npm.jsr.io/~/11/@jsr/meshtastic__transport-http/0.2.1.tgz" }, "@jsr/meshtastic__transport-web-bluetooth@0.1.1": { "integrity": "sha512-eAj23n/Pxe8hMjO/uYbI/C+l1s0tLm41EzvcLWQtLQyEKJpPP+/Eqc5lUmDeF7FVPS2IYhllFJvV8Ili7okHtQ==", "dependencies": [ - "@jsr/meshtastic__core" + "@jsr/meshtastic__core@2.6.2" ], "tarball": "https://npm.jsr.io/~/11/@jsr/meshtastic__transport-web-bluetooth/0.1.1.tgz" }, "@jsr/meshtastic__transport-web-serial@0.2.1": { "integrity": "sha512-yumjEGLkAuJYOC3aWKvZzbQqi/LnqaKfNpVCY7Ki7oLtAshNiZrBLiwiFhN7+ZR9FfMdJThyBMqREBDRRWTO1Q==", "dependencies": [ - "@jsr/meshtastic__core" + "@jsr/meshtastic__core@2.6.2" ], "tarball": "https://npm.jsr.io/~/11/@jsr/meshtastic__transport-web-serial/0.2.1.tgz" }, @@ -1241,23 +1253,6 @@ "@noble/hashes@1.8.0": { "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==" }, - "@nodelib/fs.scandir@2.1.5": { - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dependencies": [ - "@nodelib/fs.stat", - "run-parallel" - ] - }, - "@nodelib/fs.stat@2.0.5": { - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==" - }, - "@nodelib/fs.walk@1.2.8": { - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dependencies": [ - "@nodelib/fs.scandir", - "fastq" - ] - }, "@radix-ui/number@1.1.1": { "integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==" }, @@ -2084,14 +2079,6 @@ "rollup@2.79.2" ] }, - "@rollup/plugin-inject@5.0.5": { - "integrity": "sha512-2+DEJbNBoPROPkgTDNe8/1YXWcqxbN5DTjASVIOx8HS+pITXushyNiBV56RB08zuptzz8gT3YfkqriTBVycepg==", - "dependencies": [ - "@rollup/pluginutils@5.1.4_rollup@2.79.2", - "estree-walker@2.0.2", - "magic-string@0.30.17" - ] - }, "@rollup/plugin-node-resolve@15.3.1_rollup@2.79.2": { "integrity": "sha512-tgg6b91pAybXHJQMAAwW9VuWBO6Thi+q7BCNARLwSqlmsHz0XYURtGvh/AuwSADXSI4h/2uHbs7s4FzlZDGSGA==", "dependencies": [ @@ -2440,7 +2427,7 @@ "@tanstack/virtual-file-routes", "prettier", "tsx", - "zod" + "zod@3.25.49" ], "optionalPeers": [ "@tanstack/react-router" @@ -2467,7 +2454,7 @@ "chokidar", "unplugin", "vite", - "zod" + "zod@3.25.49" ], "optionalPeers": [ "@tanstack/react-router", @@ -4000,9 +3987,6 @@ "@types/trusted-types@2.0.7": { "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==" }, - "@types/validator@13.15.0": { - "integrity": "sha512-nh7nrWhLr6CBq9ldtw0wx+z9wKnnv/uTVLA9g/3/TcOYxbpOSZE+MhKPmWqU+K0NvThjhv12uD8MuqijB0WzEA==" - }, "@types/w3c-web-serial@1.0.8": { "integrity": "sha512-QQOT+bxQJhRGXoZDZGLs3ksLud1dMNnMiSQtBA0w8KXvLpXX4oM4TZb6J0GgJ8UbCaHo5s9/4VQT8uXy9JER2A==" }, @@ -4130,9 +4114,6 @@ "picomatch@2.3.1" ] }, - "argparse@2.0.1": { - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" - }, "aria-hidden@1.2.4": { "integrity": "sha512-y+CcFFwelSXpLZk/7fMB2mUbGtX9lKycf1MWJ7CaTIERyitVlyQx6C+sxcROU2BAJ24OiZyK+8wj2i8AlBoS3A==", "dependencies": [ @@ -4170,24 +4151,6 @@ "is-array-buffer" ] }, - "asn1.js@4.10.1": { - "integrity": "sha512-p32cOF5q0Zqs9uBiONKYLm6BClCoBCM5O9JfeUSlnQLBTxYdTK+pW+nXflm8UkKd2UYlEbYz5qEi0JuZR9ckSw==", - "dependencies": [ - "bn.js@4.12.2", - "inherits", - "minimalistic-assert" - ] - }, - "assert@2.1.0": { - "integrity": "sha512-eLHpSK/Y4nhMJ07gDaAzoX/XAKS8PSaojml3M0DM4JpV1LAi5JOJ/p6H/XWrl8L+DzVEvVCW1z3vWAaB9oTsQw==", - "dependencies": [ - "call-bind", - "is-nan", - "object-is", - "object.assign", - "util" - ] - }, "assertion-error@2.0.1": { "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==" }, @@ -4267,12 +4230,6 @@ "binary-extensions@2.3.0": { "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==" }, - "bn.js@4.12.2": { - "integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==" - }, - "bn.js@5.2.2": { - "integrity": "sha512-v2YAxEmKaBLahNwE1mjp4WON6huMNeuDvagFZW+ASCuA/ku0bXR9hSMw0XpiqMoA3+rmnyck/tPRSFQkoC9Cuw==" - }, "brace-expansion@1.1.11": { "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", "dependencies": [ @@ -4292,72 +4249,6 @@ "fill-range" ] }, - "brorand@1.1.0": { - "integrity": "sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w==" - }, - "browser-resolve@2.0.0": { - "integrity": "sha512-7sWsQlYL2rGLy2IWm8WL8DCTJvYLc/qlOnsakDac87SOoCd16WLsaAMdCiAqsTNHIe+SXfaqyxyo6THoWqs8WQ==", - "dependencies": [ - "resolve" - ] - }, - "browserify-aes@1.2.0": { - "integrity": "sha512-+7CHXqGuspUn/Sl5aO7Ea0xWGAtETPXNSAjHo48JfLdPWcMng33Xe4znFvQweqc/uzk5zSOI3H52CYnjCfb5hA==", - "dependencies": [ - "buffer-xor", - "cipher-base", - "create-hash", - "evp_bytestokey", - "inherits", - "safe-buffer@5.2.1" - ] - }, - "browserify-cipher@1.0.1": { - "integrity": "sha512-sPhkz0ARKbf4rRQt2hTpAHqn47X3llLkUGn+xEJzLjwY8LRs2p0v7ljvI5EyoRO/mexrNunNECisZs+gw2zz1w==", - "dependencies": [ - "browserify-aes", - "browserify-des", - "evp_bytestokey" - ] - }, - "browserify-des@1.0.2": { - "integrity": "sha512-BioO1xf3hFwz4kc6iBhI3ieDFompMhrMlnDFC4/0/vd5MokpuAc3R+LYbwTA9A5Yc9pq9UYPqffKpW2ObuwX5A==", - "dependencies": [ - "cipher-base", - "des.js", - "inherits", - "safe-buffer@5.2.1" - ] - }, - "browserify-rsa@4.1.1": { - "integrity": "sha512-YBjSAiTqM04ZVei6sXighu679a3SqWORA3qZTEqZImnlkDIFtKc6pNutpjyZ8RJTjQtuYfeetkxM11GwoYXMIQ==", - "dependencies": [ - "bn.js@5.2.2", - "randombytes", - "safe-buffer@5.2.1" - ] - }, - "browserify-sign@4.2.3": { - "integrity": "sha512-JWCZW6SKhfhjJxO8Tyiiy+XYB7cqd2S5/+WeYHsKdNKFlCBhKbblba1A/HN/90YwtxKc8tCErjffZl++UNmGiw==", - "dependencies": [ - "bn.js@5.2.2", - "browserify-rsa", - "create-hash", - "create-hmac", - "elliptic", - "hash-base", - "inherits", - "parse-asn1", - "readable-stream@2.3.8", - "safe-buffer@5.2.1" - ] - }, - "browserify-zlib@0.2.0": { - "integrity": "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==", - "dependencies": [ - "pako" - ] - }, "browserslist@4.24.5": { "integrity": "sha512-FDToo4Wo82hIdgc1CQ+NQD0hEhmpPjrZ3hiUgwgOG6IuTdlpr8jdjyG24P6cNP1yJpTLzS5OcGgSw0xmDU1/Tw==", "dependencies": [ @@ -4371,26 +4262,6 @@ "buffer-from@1.1.2": { "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==" }, - "buffer-xor@1.0.3": { - "integrity": "sha512-571s0T7nZWK6vB67HI5dyUF7wXiNcfaPPPTl6zYCNApANjIvYJTg7hlud/+cJpdAhS7dVzqMLmfhfHR3rAcOjQ==" - }, - "buffer@5.7.1": { - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "dependencies": [ - "base64-js", - "ieee754" - ] - }, - "builtin-status-codes@3.0.0": { - "integrity": "sha512-HpGFw18DgFWlncDfjTa2rcQ4W88O1mC8e8yZ2AvQY5KDaktSTwo+KRf6nHK6FRI5FyRyb/5T6+TSxfP7QyGsmQ==" - }, - "bundle-require@5.1.0_esbuild@0.25.3": { - "integrity": "sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==", - "dependencies": [ - "esbuild", - "load-tsconfig" - ] - }, "bytewise-core@1.2.3": { "integrity": "sha512-nZD//kc78OOxeYtRlVk8/zXqTB4gf/nlguL1ggWA8FuchMyOxcyHR4QPQZMUmA7czC+YnaBrPUCubqAWe50DaA==", "dependencies": [ @@ -4478,21 +4349,6 @@ "chownr@3.0.0": { "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==" }, - "cipher-base@1.0.6": { - "integrity": "sha512-3Ek9H3X6pj5TgenXYtNWdaBon1tgYCaebd+XPg0keyjEbEfkD4KkmAxkQ/i1vYvxdcT5nscLBfq9VJRmCBcFSw==", - "dependencies": [ - "inherits", - "safe-buffer@5.2.1" - ] - }, - "class-validator@0.14.2": { - "integrity": "sha512-3kMVRF2io8N8pY1IFIXlho9r8IPUUIfHe2hYVtiebvAzU2XeQFXTv+XI4WX+TnXmtwXMDcjngcpkiPM0O9PvLw==", - "dependencies": [ - "@types/validator", - "libphonenumber-js", - "validator" - ] - }, "class-variance-authority@0.7.1": { "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", "dependencies": [ @@ -4543,18 +4399,9 @@ "tinyqueue@2.0.3" ] }, - "console-browserify@1.2.0": { - "integrity": "sha512-ZMkYO/LkF17QvCPqM0gxw8yUzigAOZOSWSHg91FH6orS7vcEj5dVZTidN2fQ14yBSdg97RqhSNwLUXInd52OTA==" - }, - "constants-browserify@1.0.0": { - "integrity": "sha512-xFxOwqIzR/e1k1gLiWEophSCMqXcwVHIH7akf7b/vxcUeGunlj3hvZaaqxwHsTgn+IndtkQJgSztIDWeumWJDQ==" - }, "convert-source-map@2.0.0": { "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==" }, - "cookie@1.0.2": { - "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==" - }, "core-js-compat@3.42.0": { "integrity": "sha512-bQasjMfyDGyaeWKBIu33lHh9qlSR0MFE/Nmc6nMjf/iU9b3rSMdAYz1Baxrv4lPdGUsTqZudHA4jIGSJy0SWZQ==", "dependencies": [ @@ -4567,60 +4414,12 @@ "crc@4.3.2": { "integrity": "sha512-uGDHf4KLLh2zsHa8D8hIQ1H/HtFQhyHrc0uhHBcoKGol/Xnb+MPYfUMw7cvON6ze/GUESTudKayDcJC5HnJv1A==" }, - "create-ecdh@4.0.4": { - "integrity": "sha512-mf+TCx8wWc9VpuxfP2ht0iSISLZnt0JgWlrOKZiNqyUZWnjIaCIVNQArMHnCZKfEYRg6IM7A+NeJoN8gf/Ws0A==", - "dependencies": [ - "bn.js@4.12.2", - "elliptic" - ] - }, - "create-hash@1.2.0": { - "integrity": "sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg==", - "dependencies": [ - "cipher-base", - "inherits", - "md5.js", - "ripemd160", - "sha.js" - ] - }, - "create-hmac@1.1.7": { - "integrity": "sha512-MJG9liiZ+ogc4TzUwuvbER1JRdgvUFSB5+VR/g5h82fGaIRWMWddtKBHi7/sVhfjQZ6SehlyhvQYrcYkaUIpLg==", - "dependencies": [ - "cipher-base", - "create-hash", - "inherits", - "ripemd160", - "safe-buffer@5.2.1", - "sha.js" - ] - }, - "create-require@1.1.1": { - "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==" - }, "cross-fetch@4.0.0": { "integrity": "sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==", "dependencies": [ "node-fetch" ] }, - "crypto-browserify@3.12.1": { - "integrity": "sha512-r4ESw/IlusD17lgQi1O20Fa3qNnsckR126TdUuBgAu7GBYSIPvdNyONd3Zrxh0xCwA4+6w/TDArBPsMvhur+KQ==", - "dependencies": [ - "browserify-cipher", - "browserify-sign", - "create-ecdh", - "create-hash", - "create-hmac", - "diffie-hellman", - "hash-base", - "inherits", - "pbkdf2", - "public-encrypt", - "randombytes", - "randomfill" - ] - }, "crypto-random-string@2.0.0": { "integrity": "sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==" }, @@ -4681,9 +4480,6 @@ "deep-eql@5.0.2": { "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==" }, - "deep-object-diff@1.1.9": { - "integrity": "sha512-Rn+RuwkmkDwCi2/oXOFS9Gsr5lJZu/yTGpK7wAaAIE75CC+LCGEZHpY6VQJa/RoJcrmaA/docWJZvYohlNkWPA==" - }, "deepmerge@4.3.1": { "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==" }, @@ -4706,13 +4502,6 @@ "dequal@2.0.3": { "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==" }, - "des.js@1.1.0": { - "integrity": "sha512-r17GxjhUCjSRy8aiJpr8/UadFIzMzJGexI3Nmz4ADi9LYSFx4gTBp80+NaX/YsXWWLhpZ7v/v/ubEc/bCNfKwg==", - "dependencies": [ - "inherits", - "minimalistic-assert" - ] - }, "detect-libc@2.0.4": { "integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==" }, @@ -4722,23 +4511,12 @@ "diff@7.0.0": { "integrity": "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==" }, - "diffie-hellman@5.0.3": { - "integrity": "sha512-kqag/Nl+f3GwyK25fhUMYj81BUOrZ9IuJsjIcDE5icNM9FJHAVm3VcUDxdLPoQtTuUylWm6ZIknYJwwaPxsUzg==", - "dependencies": [ - "bn.js@4.12.2", - "miller-rabin", - "randombytes" - ] - }, "dom-accessibility-api@0.5.16": { "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==" }, "dom-accessibility-api@0.6.3": { "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==" }, - "domain-browser@4.22.0": { - "integrity": "sha512-IGBwjF7tNk3cwypFNH/7bfzBcgSCbaMOD3GsaY1AU/JRrnHnYgEM0+9kQt52iZxjNsjBtJYtao146V+f8jFZNw==" - }, "dunder-proto@1.0.1": { "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", "dependencies": [ @@ -4775,18 +4553,6 @@ "electron-to-chromium@1.5.149": { "integrity": "sha512-UyiO82eb9dVOx8YO3ajDf9jz2kKyt98DEITRdeLPstOEuTlLzDA4Gyq5K9he71TQziU5jUVu2OAu5N48HmQiyQ==" }, - "elliptic@6.6.1": { - "integrity": "sha512-RaddvvMatK2LJHqFJ+YA4WysVN5Ita9E35botqIYspQ4TkRAlCicdzKOjlyv/1Za5RyTNn7di//eEV0uTAfe3g==", - "dependencies": [ - "bn.js@4.12.2", - "brorand", - "hash.js", - "hmac-drbg", - "inherits", - "minimalistic-assert", - "minimalistic-crypto-utils" - ] - }, "end-of-stream@1.4.4": { "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", "dependencies": [ @@ -4938,16 +4704,6 @@ "esutils@2.0.3": { "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==" }, - "events@3.3.0": { - "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==" - }, - "evp_bytestokey@1.0.3": { - "integrity": "sha512-/f2Go4TognH/KvCISP7OUsHn85hT9nUkxxA9BEWxFn+Oj9o8ZNLm/40hdlgSLyuOimsrTKLUMEorQexp/aPQeA==", - "dependencies": [ - "md5.js", - "safe-buffer@5.2.1" - ] - }, "expect-type@1.2.1": { "integrity": "sha512-/kP8CAwxzLVEeFrMm4kMmy4CCDlpipyA7MYLVrdJIkV0fYF0UaigQHRsxHiuY/GEea+bh4KSv3TIlgr+2UL6bw==" }, @@ -4967,28 +4723,12 @@ "fast-deep-equal@3.1.3": { "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" }, - "fast-glob@3.3.3": { - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "dependencies": [ - "@nodelib/fs.stat", - "@nodelib/fs.walk", - "glob-parent", - "merge2", - "micromatch" - ] - }, "fast-json-stable-stringify@2.1.0": { "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==" }, "fast-uri@3.0.6": { "integrity": "sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==" }, - "fastq@1.19.1": { - "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", - "dependencies": [ - "reusify" - ] - }, "fdir@6.4.4_picomatch@4.0.2": { "integrity": "sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==", "dependencies": [ @@ -5010,21 +4750,6 @@ "to-regex-range" ] }, - "find-up@5.0.0": { - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "dependencies": [ - "locate-path@6.0.0", - "path-exists@4.0.0" - ] - }, - "find-up@7.0.0": { - "integrity": "sha512-YyZM99iHrqLKjmt4LJDj58KI+fYyufRLBSYcqycxf//KpBk9FoewoGX0450m9nB44qrZnovzC2oeP5hUibxc/g==", - "dependencies": [ - "locate-path@7.2.0", - "path-exists@5.0.0", - "unicorn-magic" - ] - }, "for-each@0.3.5": { "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", "dependencies": [ @@ -5236,43 +4961,18 @@ "has-symbols" ] }, - "hash-base@3.0.5": { - "integrity": "sha512-vXm0l45VbcHEVlTCzs8M+s0VeYsB2lnlAaThoLKGXr3bE/VWDOelNUnycUPEhKEaXARL2TEFjBOyUiM6+55KBg==", - "dependencies": [ - "inherits", - "safe-buffer@5.2.1" - ] - }, - "hash.js@1.1.7": { - "integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==", - "dependencies": [ - "inherits", - "minimalistic-assert" - ] - }, "hasown@2.0.2": { "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", "dependencies": [ "function-bind" ] }, - "hmac-drbg@1.0.1": { - "integrity": "sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg==", - "dependencies": [ - "hash.js", - "minimalistic-assert", - "minimalistic-crypto-utils" - ] - }, "html-parse-stringify@3.0.1": { "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", "dependencies": [ "void-elements" ] }, - "https-browserify@1.0.0": { - "integrity": "sha512-J+FkSdyD+0mA0N+81tMotaRMfSL9SGi+xpD3T6YApKsc3bGSXJlfXri3VyFOeYkfLRQisDk1W+jIFFKBeUBbBg==" - }, "i18next-browser-languagedetector@8.1.0": { "integrity": "sha512-mHZxNx1Lq09xt5kCauZ/4bsXOEA2pfpwSoU11/QTJB+pD94iONFwp+ohqi///PwiFvjFOxe1akYCdHyFo1ng5Q==", "dependencies": [ @@ -5307,17 +5007,6 @@ "immer@10.1.1": { "integrity": "sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==" }, - "importx@0.5.2_esbuild@0.25.3": { - "integrity": "sha512-YEwlK86Ml5WiTxN/ECUYC5U7jd1CisAVw7ya4i9ZppBoHfFkT2+hChhr3PE2fYxUKLkNyivxEQpa5Ruil1LJBQ==", - "dependencies": [ - "bundle-require", - "debug", - "esbuild", - "jiti", - "pathe", - "tsx" - ] - }, "indent-string@4.0.0": { "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==" }, @@ -5343,13 +5032,6 @@ "side-channel" ] }, - "is-arguments@1.2.0": { - "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==", - "dependencies": [ - "call-bound", - "has-tostringtag" - ] - }, "is-array-buffer@3.0.5": { "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", "dependencies": [ @@ -5450,13 +5132,6 @@ "is-module@1.0.0": { "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==" }, - "is-nan@1.3.2": { - "integrity": "sha512-E+zBKpQ2t6MEo1VsonYmluk9NxGrbzpeeLC2xIViuO2EjU2xsXsBPwTr3Ykv9l08UYEVEdWeRZNouaZqF6RN0w==", - "dependencies": [ - "call-bind", - "define-properties" - ] - }, "is-number-object@1.1.1": { "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", "dependencies": [ @@ -5552,9 +5227,6 @@ "isobject@3.0.1": { "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==" }, - "isomorphic-timers-promises@1.0.1": { - "integrity": "sha512-u4sej9B1LPSxTGKB/HiuzvEQnXH0ECYkSVQU39koSwmFAxhlEAFl9RdTvLv4TOTQUgBS5O3O5fwUxk6byBZ+IQ==" - }, "jake@10.9.2": { "integrity": "sha512-2P4SQ0HrLQ+fw6llpLnOaGAvN2Zu6778SJMrCUwns4fOoG9ayrTiZk3VV8sCPkVZF8ab0zksVpS8FDY5pRCNBA==", "dependencies": [ @@ -5575,13 +5247,6 @@ "js-tokens@4.0.0": { "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" }, - "js-yaml@4.1.0": { - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dependencies": [ - "argparse" - ], - "bin": true - }, "jsesc@3.0.2": { "integrity": "sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==", "bin": true @@ -5627,21 +5292,9 @@ "kind-of@6.0.3": { "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==" }, - "language-subtag-registry@0.3.23": { - "integrity": "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==" - }, - "language-tags@2.1.0": { - "integrity": "sha512-D4CgpyCt+61f6z2jHjJS1OmZPviAWM57iJ9OKdFFWSNgS7Udj9QVWqyGs/cveVNF57XpZmhSvMdVIV5mjLA7Vg==", - "dependencies": [ - "language-subtag-registry" - ] - }, "leven@3.1.0": { "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==" }, - "libphonenumber-js@1.12.7": { - "integrity": "sha512-0nYZSNj/QEikyhcM5RZFXGlCB/mr4PVamnT1C2sKBnDDTYndrvbybYjvg+PMqAndQHlLbwQ3socolnL3WWTUFA==" - }, "lightningcss-darwin-arm64@1.29.2": { "integrity": "sha512-cK/eMabSViKn/PG8U/a7aCorpeKLMlK0bQeNHmdb7qUnBkNPnL+oV5DjJUo0kqWsJUapZsM4jCfYItbqBDvlcA==", "os": ["darwin"], @@ -5710,21 +5363,6 @@ "lightningcss-win32-x64-msvc" ] }, - "load-tsconfig@0.2.5": { - "integrity": "sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==" - }, - "locate-path@6.0.0": { - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "dependencies": [ - "p-locate@5.0.0" - ] - }, - "locate-path@7.2.0": { - "integrity": "sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA==", - "dependencies": [ - "p-locate@6.0.0" - ] - }, "lodash.debounce@4.0.8": { "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==" }, @@ -5806,41 +5444,9 @@ "math-intrinsics@1.1.0": { "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==" }, - "md5.js@1.3.5": { - "integrity": "sha512-xitP+WxNPcTTOgnTJcrhM0xvdPepipPSf3I8EIpGKeFLjt3PlJLIDG3u8EX53ZIubkb+5U2+3rELYpEhHhzdkg==", - "dependencies": [ - "hash-base", - "inherits", - "safe-buffer@5.2.1" - ] - }, - "merge2@1.4.1": { - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==" - }, - "micromatch@4.0.8": { - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dependencies": [ - "braces", - "picomatch@2.3.1" - ] - }, - "miller-rabin@4.0.1": { - "integrity": "sha512-115fLhvZVqWwHPbClyntxEVfVDfl9DLLTuJvq3g2O/Oxi8AiNouAHvDSzHS0viUJc+V5vm3eq91Xwqn9dp4jRA==", - "dependencies": [ - "bn.js@4.12.2", - "brorand" - ], - "bin": true - }, "min-indent@1.0.1": { "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==" }, - "minimalistic-assert@1.0.1": { - "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==" - }, - "minimalistic-crypto-utils@1.0.1": { - "integrity": "sha512-JIYlbt6g8i5jKfJ3xz7rF0LXmv2TkDxBLUkiBeZ7bAx4GnnNMr8xFpGnOxn6GhTEHx3SjRrZEoU+j04prX1ktg==" - }, "minimatch@3.1.2": { "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "dependencies": [ @@ -5879,9 +5485,6 @@ "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", "bin": true }, - "negotiator@1.0.0": { - "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==" - }, "node-fetch@2.7.0": { "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", "dependencies": [ @@ -5891,38 +5494,6 @@ "node-releases@2.0.19": { "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==" }, - "node-stdlib-browser@1.3.1": { - "integrity": "sha512-X75ZN8DCLftGM5iKwoYLA3rjnrAEs97MkzvSd4q2746Tgpg8b8XWiBGiBG4ZpgcAqBgtgPHTiAc8ZMCvZuikDw==", - "dependencies": [ - "assert", - "browser-resolve", - "browserify-zlib", - "buffer", - "console-browserify", - "constants-browserify", - "create-require", - "crypto-browserify", - "domain-browser", - "events", - "https-browserify", - "isomorphic-timers-promises", - "os-browserify", - "path-browserify", - "pkg-dir", - "process", - "punycode@1.4.1", - "querystring-es3", - "readable-stream@3.6.2", - "stream-browserify", - "stream-http", - "string_decoder@1.3.0", - "timers-browserify", - "tty-browserify", - "url", - "util", - "vm-browserify" - ] - }, "normalize-path@3.0.0": { "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==" }, @@ -5932,13 +5503,6 @@ "object-inspect@1.13.4": { "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==" }, - "object-is@1.1.6": { - "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==", - "dependencies": [ - "call-bind", - "define-properties" - ] - }, "object-keys@1.1.1": { "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==" }, @@ -5959,9 +5523,6 @@ "wrappy" ] }, - "os-browserify@0.3.0": { - "integrity": "sha512-gjcpUc3clBf9+210TRaDWbf+rZZZEshZ+DlXMRCeAjp0xhTrnQsKHypIy1J3d5hKdUzj69t708EHtU8P6bUn0A==" - }, "own-keys@1.0.1": { "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", "dependencies": [ @@ -5970,56 +5531,9 @@ "safe-push-apply" ] }, - "p-limit@3.1.0": { - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dependencies": [ - "yocto-queue@0.1.0" - ] - }, - "p-limit@4.0.0": { - "integrity": "sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==", - "dependencies": [ - "yocto-queue@1.2.1" - ] - }, - "p-locate@5.0.0": { - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "dependencies": [ - "p-limit@3.1.0" - ] - }, - "p-locate@6.0.0": { - "integrity": "sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==", - "dependencies": [ - "p-limit@4.0.0" - ] - }, "p-map@7.0.3": { "integrity": "sha512-VkndIv2fIB99swvQoA65bm+fsmt6UNdGeIB0oxBs+WhAhdh08QA04JXpI7rbB9r08/nkbysKoya9rtDERYOYMA==" }, - "pako@1.0.11": { - "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==" - }, - "parse-asn1@5.1.7": { - "integrity": "sha512-CTM5kuWR3sx9IFamcl5ErfPl6ea/N8IYwiJ+vpeB2g+1iknv7zBl5uPwbMbRVznRVbrNY6lGuDoE5b30grmbqg==", - "dependencies": [ - "asn1.js", - "browserify-aes", - "evp_bytestokey", - "hash-base", - "pbkdf2", - "safe-buffer@5.2.1" - ] - }, - "path-browserify@1.0.1": { - "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==" - }, - "path-exists@4.0.0": { - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==" - }, - "path-exists@5.0.0": { - "integrity": "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==" - }, "path-is-absolute@1.0.1": { "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==" }, @@ -6040,16 +5554,6 @@ ], "bin": true }, - "pbkdf2@3.1.2": { - "integrity": "sha512-iuh7L6jA7JEGu2WxDwtQP1ddOpaJNC4KlDEFfdQajSGgGPNi4OyDc2R7QnbY2bR9QjBVGwgvTdNJZoE7RaxUMA==", - "dependencies": [ - "create-hash", - "create-hmac", - "ripemd160", - "safe-buffer@5.2.1", - "sha.js" - ] - }, "peek-stream@1.1.3": { "integrity": "sha512-FhJ+YbOSBb9/rIl2ZeE/QHEsWn7PqNYt8ARAY3kIgNGOk13g9FGyIY6JIl/xB/3TFRVoTv5as0l11weORrTekA==", "dependencies": [ @@ -6067,12 +5571,6 @@ "picomatch@4.0.2": { "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==" }, - "pkg-dir@5.0.0": { - "integrity": "sha512-NPE8TDbzl/3YQYY7CSS228s3g2ollTFnc+Qi3tqmqJp9Vg2ovUpixcJEo2HJScN2Ez+kEaal6y70c0ehqJBJeA==", - "dependencies": [ - "find-up@5.0.0" - ] - }, "point-in-polygon-hao@1.2.4": { "integrity": "sha512-x2pcvXeqhRHlNRdhLs/tgFapAbSSe86wa/eqmj1G6pWftbEs5aVRJhRGM6FYSUERKu0PjekJzMq0gsI2XyiclQ==", "dependencies": [ @@ -6133,44 +5631,15 @@ "duplex-maker" ] }, - "process@0.11.10": { - "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==" - }, "protocol-buffers-schema@3.6.0": { "integrity": "sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw==" }, - "public-encrypt@4.0.3": { - "integrity": "sha512-zVpa8oKZSz5bTMTFClc1fQOnyyEzpl5ozpi1B5YcvBrdohMjH2rfsBtyXcuNuwjsDIXmBYlF2N5FlJYhR29t8Q==", - "dependencies": [ - "bn.js@4.12.2", - "browserify-rsa", - "create-hash", - "parse-asn1", - "randombytes", - "safe-buffer@5.2.1" - ] - }, - "punycode@1.4.1": { - "integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==" - }, "punycode@2.3.1": { "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==" }, "qrcode-generator@1.4.4": { "integrity": "sha512-HM7yY8O2ilqhmULxGMpcHSF1EhJJ9yBj8gvDEuZ6M+KGJ0YY2hKpnXvRD+hZPLrDVck3ExIGhmPtSdcjC+guuw==" }, - "qs@6.14.0": { - "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", - "dependencies": [ - "side-channel" - ] - }, - "querystring-es3@0.2.1": { - "integrity": "sha512-773xhDQnZBMFobEiztv8LIl70ch5MSF/jUQVlhwFyBILqq96anmoctVIYz+ZRp0qbCKATTn6ev02M3r7Ga5vqA==" - }, - "queue-microtask@1.2.3": { - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==" - }, "quickselect@1.1.1": { "integrity": "sha512-qN0Gqdw4c4KGPsBOQafj6yj/PA6c/L63f6CaZ/DCF/xF4Esu3jVmKLUDYxghFx8Kb/O7y9tI7x2RjTSXwdK1iQ==" }, @@ -6186,13 +5655,6 @@ "safe-buffer@5.2.1" ] }, - "randomfill@1.0.4": { - "integrity": "sha512-87lcbR8+MhcWcUiQ+9e+Rwx8MyR2P7qnt15ynUlbm3TU/fjbgz4GsvfSUDTemtCCtVCqb4ZcEFlyPNTh9bBTLw==", - "dependencies": [ - "randombytes", - "safe-buffer@5.2.1" - ] - }, "rbush@2.0.2": { "integrity": "sha512-XBOuALcTm+O/H8G90b6pzu6nX6v2zCKiFG4BJho8a+bY6AER6t8uQUZdi5bomQc0AprCWhEGa7ncAbbRap0bRA==", "dependencies": [ @@ -6416,19 +5878,9 @@ ], "bin": true }, - "reusify@1.1.0": { - "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==" - }, "rfc4648@1.5.4": { "integrity": "sha512-rRg/6Lb+IGfJqO05HZkN50UtY7K/JhxJag1kP23+zyMfrvoB0B7RWv06MbOzoc79RgCdNTiUaNsTT1AJZ7Z+cg==" }, - "ripemd160@2.0.2": { - "integrity": "sha512-ii4iagi25WusVoiC4B4lq7pbXfAp3D9v5CwfkY33vffw2+pkDjY1D8GaN7spsxvCSx8dkPqOZCEZyfxcmJG2IA==", - "dependencies": [ - "hash-base", - "inherits" - ] - }, "robust-predicates@2.0.4": { "integrity": "sha512-l4NwboJM74Ilm4VKfbAtFeGq7aEjWL+5kVFcmgFA2MrdnQWx9iE/tUGvxY5HyMI7o/WpSIUFLbC5fbeaHgSCYg==" }, @@ -6472,12 +5924,6 @@ ], "bin": true }, - "run-parallel@1.2.0": { - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dependencies": [ - "queue-microtask" - ] - }, "rw@1.3.3": { "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==" }, @@ -6577,17 +6023,6 @@ "split-string" ] }, - "setimmediate@1.0.5": { - "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==" - }, - "sha.js@2.4.11": { - "integrity": "sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ==", - "dependencies": [ - "inherits", - "safe-buffer@5.2.1" - ], - "bin": true - }, "side-channel-list@1.0.0": { "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", "dependencies": [ @@ -6719,22 +6154,6 @@ "ste-core" ] }, - "stream-browserify@3.0.0": { - "integrity": "sha512-H73RAHsVBapbim0tU2JwwOiXUj+fikfiaoYAKHF3VJfA0pe2BCzkhAHBlLG6REzE+2WNZcxOXjK7lkso+9euLA==", - "dependencies": [ - "inherits", - "readable-stream@3.6.2" - ] - }, - "stream-http@3.2.0": { - "integrity": "sha512-Oq1bLqisTyK3TSCXpPbT4sdeYNdmyZJv1LxpEm2vu1ZhK89kSE5YXwZc3cWk0MagGaKriBh9mCFbVGtO+vY29A==", - "dependencies": [ - "builtin-status-codes", - "inherits", - "readable-stream@3.6.2", - "xtend" - ] - }, "stream-shift@1.0.3": { "integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==" }, @@ -6904,12 +6323,6 @@ "readable-stream@3.6.2" ] }, - "timers-browserify@2.0.12": { - "integrity": "sha512-9phl76Cqm6FhSX9Xe1ZUAMLtm1BLkKj2Qd5ApyWkXzsMRaA7dgr81kf4wJmQf/hAvg8EEyJxDo3du/0KlhPiKQ==", - "dependencies": [ - "setimmediate" - ] - }, "tiny-invariant@1.3.3": { "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==" }, @@ -6970,7 +6383,7 @@ "tr46@1.0.1": { "integrity": "sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==", "dependencies": [ - "punycode@2.3.1" + "punycode" ] }, "tslib@1.14.1": { @@ -6993,9 +6406,6 @@ ], "bin": true }, - "tty-browserify@0.0.1": { - "integrity": "sha512-C3TaO7K81YvjCgQH9Q1S3R3P3BtN3RIM8n+OvX4il1K1zgE8ZhI0op7kClgkxtutIE8hQrcrHBXvIheqKUUCxw==" - }, "type-fest@0.16.0": { "integrity": "sha512-eaBzG6MxNzEn9kiwvtre90cXaNLkmadMWa1zQMs3XORCXNbsH/OewwbxC5ia9dCxIxnTAsSxXJaa/p5y8DlvJg==" }, @@ -7084,9 +6494,6 @@ "unicode-property-aliases-ecmascript@2.1.0": { "integrity": "sha512-6t3foTQI9qne+OZoVQB/8x8rk2k1eVy1gRXhV3oFQ5T6R1dqQ1xtin3XqSlx3+ATBkliTaR/hHyJBm+LVPNM8w==" }, - "unicorn-magic@0.1.0": { - "integrity": "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==" - }, "union-value@1.0.1": { "integrity": "sha512-tJfXmxMeWYnczCVs7XAEvIV7ieppALdyepWMkHkwciRpZraG/xwT+s2JN8+pr1+8jCRf80FFzvr+MpQeeoF4Xg==", "dependencies": [ @@ -7125,13 +6532,6 @@ ], "bin": true }, - "url@0.11.4": { - "integrity": "sha512-oCwdVC7mTuWiPyjLUz/COz5TLk6wgp0RCsN+wHZ2Ekneac9w8uuV0njcbbie2ME+Vs+d6duwmYuR3HgQXs1fOg==", - "dependencies": [ - "punycode@1.4.1", - "qs" - ] - }, "use-callback-ref@1.3.3_@types+react@19.1.2_react@19.1.0": { "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", "dependencies": [ @@ -7164,19 +6564,6 @@ "util-deprecate@1.0.2": { "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" }, - "util@0.12.5": { - "integrity": "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==", - "dependencies": [ - "inherits", - "is-arguments", - "is-generator-function", - "is-typed-array", - "which-typed-array" - ] - }, - "validator@13.15.0": { - "integrity": "sha512-36B2ryl4+oL5QxZ3AzD0t5SsMNGvTtQHpjgFO5tbNxfXbMFkY822ktCDe1MnlqV3301QQI9SLHDNJokDI+Z9pA==" - }, "vite-node@3.1.2_@types+node@22.15.3": { "integrity": "sha512-/8iMryv46J3aK13iUXsei5G/A3CUlW4665THCPS+K8xAaqrVWiGB4RfXMQXCLjpK9P2eK//BczrVkn5JLAk6DA==", "dependencies": [ @@ -7188,31 +6575,6 @@ ], "bin": true }, - "vite-plugin-i18n-ally@6.0.1_vite@6.3.4__@types+node@22.15.3__picomatch@4.0.2_@types+node@22.15.3": { - "integrity": "sha512-BmXlAkrmSRrbaho7iJpBf1d2EPyDK2oqY1AKVxkJEUikGDPducFpLfpmlqTyUNhsWZT01ZLWdjR2uIRnnVJXzw==", - "dependencies": [ - "cookie", - "debug", - "deep-object-diff", - "fast-glob", - "find-up@7.0.0", - "importx", - "js-yaml", - "json5", - "language-tags", - "negotiator", - "picocolors", - "vite" - ] - }, - "vite-plugin-node-polyfills@0.23.0_vite@6.3.4__@types+node@22.15.3__picomatch@4.0.2_@types+node@22.15.3": { - "integrity": "sha512-4n+Ys+2bKHQohPBKigFlndwWQ5fFKwaGY6muNDMTb0fSQLyBzS+jjUNRZG9sKF0S/Go4ApG6LFnUGopjkILg3w==", - "dependencies": [ - "@rollup/plugin-inject", - "node-stdlib-browser", - "vite" - ] - }, "vite-plugin-pwa@1.0.0_vite@6.3.4__@types+node@22.15.3__picomatch@4.0.2_workbox-build@7.3.0__ajv@8.17.1__@babel+core@7.27.1__rollup@2.79.2_workbox-window@7.3.0_@types+node@22.15.3": { "integrity": "sha512-X77jo0AOd5OcxmWj3WnVti8n7Kw2tBgV1c8MCXFclrSlDV23ePzv2eTDIALXI2Qo6nJ5pZJeZAuX0AawvRfoeA==", "dependencies": [ @@ -7287,9 +6649,6 @@ ], "bin": true }, - "vm-browserify@1.1.2": { - "integrity": "sha512-2ham8XPWTONajOR0ohOKOHXkm3+gaBmGut3SRuu75xLd/RRaY6vqgh8NBYYk7+RW3u5AtzPQZG8F10LHkl0lAQ==" - }, "void-elements@3.1.0": { "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==" }, @@ -7547,20 +6906,17 @@ "yallist@5.0.0": { "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==" }, - "yocto-queue@0.1.0": { - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==" + "zod@3.25.49": { + "integrity": "sha512-JMMPMy9ZBk3XFEdbM3iL1brx4NUSejd6xr3ELrrGEfGb355gjhiAWtG3K5o+AViV/3ZfkIrCzXsZn6SbLwTR8Q==" }, - "yocto-queue@1.2.1": { - "integrity": "sha512-AyeEbWOu/TAXdxlV9wmGcR0+yh2j3vYPGOECcIj2S7MkrLyC7ne+oye2BKTItt0ii2PHk4cDy+95+LshzbXnGg==" - }, - "zod@3.24.3": { - "integrity": "sha512-HhY1oqzWCQWuUqvBFnsyrtZRhyPeR7SUGv+C4+MsisMuVfSPx8HpwWqH8tRahSlt6M3PiFAcoeFhZAqIXTxoSg==" + "zod@3.25.62": { + "integrity": "sha512-YCxsr4DmhPcrKPC9R1oBHQNlQzlJEyPAId//qTau/vBee9uO8K6prmRq4eMkOyxvBfH4wDPIPdLx9HVMWIY3xA==" }, "zone.js@0.8.29": { "integrity": "sha512-mla2acNCMkWXBD+c+yeUrBUrzOxYMNFdQ6FGfigGGtEVBPJx07BQeJekjt9DmH1FtZek4E9rE1eRR9qQpxACOQ==" }, - "zustand@5.0.4_@types+react@19.1.2_immer@10.1.1_react@19.1.0": { - "integrity": "sha512-39VFTN5InDtMd28ZhjLyuTnlytDr9HfwO512Ai4I8ZABCoyAj4F1+sr7sD1jP/+p7k77Iko0Pb5NhgBFDCX0kQ==", + "zustand@5.0.5_@types+react@19.1.2_immer@10.1.1_react@19.1.0": { + "integrity": "sha512-mILtRfKW9xM47hqxGIxCv12gXusoY/xTSHBYApXozR0HmQv299whhBeeAcRy+KrPPybzosvJBCOmVjq6x12fCg==", "dependencies": [ "@types/react", "immer", @@ -7575,12 +6931,14 @@ }, "workspace": { "dependencies": [ - "jsr:@std/path@^1.1.0" + "jsr:@std/path@^1.1.0", + "npm:@types/w3c-web-serial@*", + "npm:@types/web-bluetooth@*" ], "packageJson": { "dependencies": [ "npm:@bufbuild/protobuf@^2.2.5", - "npm:@jsr/meshtastic__core@2.6.2", + "npm:@jsr/meshtastic__core@2.6.4", "npm:@jsr/meshtastic__js@2.6.0-0", "npm:@jsr/meshtastic__transport-http@*", "npm:@jsr/meshtastic__transport-web-bluetooth@*", @@ -7622,7 +6980,6 @@ "npm:@vitejs/plugin-react@^4.4.1", "npm:autoprefixer@^10.4.21", "npm:base64-js@^1.5.1", - "npm:class-validator@~0.14.2", "npm:class-variance-authority@~0.7.1", "npm:clsx@^2.1.1", "npm:cmdk@^1.1.1", @@ -7653,14 +7010,12 @@ "npm:tar@^7.4.3", "npm:testing-library@^0.0.2", "npm:typescript@^5.8.3", - "npm:vite-plugin-i18n-ally@^6.0.1", - "npm:vite-plugin-node-polyfills@0.23", "npm:vite-plugin-pwa@1", "npm:vite-plugin-static-copy@3", "npm:vite@^6.3.4", "npm:vitest@^3.1.2", - "npm:zod@^3.24.3", - "npm:zustand@5.0.4" + "npm:zod@^3.25.62", + "npm:zustand@5.0.5" ] } } diff --git a/package.json b/package.json index 77709964..f3577bc3 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "dev": "deno task dev:ui", "dev:ui": "VITE_APP_VERSION=development deno run -A npm:vite dev", "test": "deno run -A npm:vitest", + "check": "deno check", "preview": "deno run -A npm:vite preview", "package": "gzipper c -i html,js,css,png,ico,svg,webmanifest,txt dist dist/output && tar -cvf dist/build.tar -C ./dist/output/ ." }, @@ -35,10 +36,11 @@ "homepage": "https://meshtastic.org", "dependencies": { "@bufbuild/protobuf": "^2.2.5", - "@meshtastic/core": "npm:@jsr/meshtastic__core@2.6.2", - "@meshtastic/transport-http": "npm:@jsr/meshtastic__transport-http@0.2.1", - "@meshtastic/transport-web-bluetooth": "npm:@jsr/meshtastic__transport-web-bluetooth@0.1.2", - "@meshtastic/transport-web-serial": "npm:@jsr/meshtastic__transport-web-serial@0.2.1", + "@meshtastic/core": "npm:@jsr/meshtastic__core@2.6.4", + "@meshtastic/js": "npm:@jsr/meshtastic__js@2.6.0-0", + "@meshtastic/transport-http": "npm:@jsr/meshtastic__transport-http", + "@meshtastic/transport-web-bluetooth": "npm:@jsr/meshtastic__transport-web-bluetooth", + "@meshtastic/transport-web-serial": "npm:@jsr/meshtastic__transport-web-serial", "@noble/curves": "^1.9.0", "@radix-ui/react-accordion": "^1.2.8", "@radix-ui/react-checkbox": "^1.2.3", @@ -60,6 +62,7 @@ "@tanstack/react-router-devtools": "^1.120.16", "@tanstack/router-devtools": "^1.120.15", "@turf/turf": "^7.2.0", + "@types/web-bluetooth": "^0.0.21", "base64-js": "^1.5.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -81,10 +84,8 @@ "react-map-gl": "8.0.4", "react-qrcode-logo": "^3.0.0", "rfc4648": "^1.5.4", - "vite-plugin-i18n-ally": "^6.0.1", - "vite-plugin-node-polyfills": "^0.23.0", - "zod": "^3.25.0", - "zustand": "5.0.4" + "zod": "^3.25.62", + "zustand": "5.0.5" }, "devDependencies": { "@tailwindcss/postcss": "^4.1.5", @@ -93,13 +94,12 @@ "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", "@types/chrome": "^0.0.318", - "@types/js-cookie": "^3.0.6", "@types/node": "^22.15.3", "@types/react": "^19.1.2", "@types/react-dom": "^19.1.3", "@types/serviceworker": "^0.0.133", + "@types/js-cookie": "^3.0.6", "@types/w3c-web-serial": "^1.0.8", - "@types/web-bluetooth": "^0.0.21", "@vitejs/plugin-react": "^4.4.1", "autoprefixer": "^10.4.21", "gzipper": "^8.2.1", diff --git a/src/components/BatteryStatus.tsx b/src/components/BatteryStatus.tsx index c80bb87e..feb7ff67 100644 --- a/src/components/BatteryStatus.tsx +++ b/src/components/BatteryStatus.tsx @@ -8,56 +8,45 @@ import { import { useTranslation } from "react-i18next"; import { DeviceMetrics } from "./types.ts"; -interface BatteryStateConfig { - condition: (level: number) => boolean; - Icon: React.ElementType; - className: string; - text: (level: number) => string; +type BatteryStatusKey = keyof typeof BATTERY_STATUS; + +interface BatteryStatusProps { + deviceMetrics?: DeviceMetrics | null; } interface BatteryStatusProps { deviceMetrics?: DeviceMetrics | null; } -const getBatteryStates = ( - t: (key: string, options?: object) => string, -): BatteryStateConfig[] => { - return [ - { - condition: (level) => level > 100, - Icon: PlugZapIcon, - className: "text-gray-500", - text: () => t("batteryStatus.pluggedIn"), - }, - { - condition: (level) => level > 80, - Icon: BatteryFullIcon, - className: "text-green-500", - text: (level) => t("batteryStatus.charging", { level }), - }, - { - condition: (level) => level > 20, - Icon: BatteryMediumIcon, - className: "text-yellow-500", - text: (level) => t("batteryStatus.charging", { level }), - }, - { - condition: () => true, - Icon: BatteryLowIcon, - className: "text-red-500", - text: (level) => t("batteryStatus.charging", { level }), - }, - ]; -}; +interface StatusConfig { + Icon: React.ElementType; + className: string; + text: string; +} -const getBatteryState = ( - level: number, - batteryStates: BatteryStateConfig[], -) => { - return batteryStates.find((state) => state.condition(level)); +const BATTERY_STATUS = { + PLUGGED_IN: "PLUGGED_IN", + FULL: "FULL", + MEDIUM: "MEDIUM", + LOW: "LOW", +} as const; + +export const getBatteryStatus = (level: number): BatteryStatusKey => { + if (level > 100) { + return BATTERY_STATUS.PLUGGED_IN; + } + if (level > 80) { + return BATTERY_STATUS.FULL; + } + if (level > 20) { + return BATTERY_STATUS.MEDIUM; + } + return BATTERY_STATUS.LOW; }; const BatteryStatus: React.FC = ({ deviceMetrics }) => { + const { t } = useTranslation(); + if ( deviceMetrics?.batteryLevel === undefined || deviceMetrics?.batteryLevel === null @@ -65,16 +54,39 @@ const BatteryStatus: React.FC = ({ deviceMetrics }) => { return null; } - const { t } = useTranslation(); - const batteryStates = getBatteryStates(t); - const { batteryLevel } = deviceMetrics; - const currentState = getBatteryState(batteryLevel, batteryStates) ?? - batteryStates[batteryStates.length - 1]; - const BatteryIcon = currentState.Icon; - const iconClassName = currentState.className; - const statusText = currentState.text(batteryLevel); + const statusKey = getBatteryStatus(batteryLevel); + + const statusConfigMap: Record = { + [BATTERY_STATUS.PLUGGED_IN]: { + Icon: PlugZapIcon, + className: "text-gray-500", + text: t("batteryStatus.pluggedIn"), + }, + [BATTERY_STATUS.FULL]: { + Icon: BatteryFullIcon, + className: "text-green-500", + text: t("batteryStatus.charging", { level: batteryLevel }), + }, + [BATTERY_STATUS.MEDIUM]: { + Icon: BatteryMediumIcon, + className: "text-yellow-500", + text: t("batteryStatus.charging", { level: batteryLevel }), + }, + [BATTERY_STATUS.LOW]: { + Icon: BatteryLowIcon, + className: "text-red-500", + text: t("batteryStatus.charging", { level: batteryLevel }), + }, + }; + + // 3. Use the key to get the current state configuration + const { + Icon: BatteryIcon, + className: iconClassName, + text: statusText, + } = statusConfigMap[statusKey]; return (
{ connection?.setOwner( create(Protobuf.Mesh.UserSchema, { - ...(myNode?.user ?? {}), ...data, }), ); diff --git a/src/components/Dialog/ImportDialog.tsx b/src/components/Dialog/ImportDialog.tsx index ac76d022..6b448ace 100644 --- a/src/components/Dialog/ImportDialog.tsx +++ b/src/components/Dialog/ImportDialog.tsx @@ -72,17 +72,19 @@ export const ImportDialog = ({ }, [importDialogInput]); const apply = () => { - channelSet?.settings.map((ch: unknown, index: number) => { - connection?.setChannel( - create(Protobuf.Channel.ChannelSchema, { - index, - role: index === 0 - ? Protobuf.Channel.Channel_Role.PRIMARY - : Protobuf.Channel.Channel_Role.SECONDARY, - settings: ch, - }), - ); - }); + channelSet?.settings.map( + (ch: Protobuf.Channel.ChannelSettings, index: number) => { + connection?.setChannel( + create(Protobuf.Channel.ChannelSchema, { + index, + role: index === 0 + ? Protobuf.Channel.Channel_Role.PRIMARY + : Protobuf.Channel.Channel_Role.SECONDARY, + settings: ch, + }), + ); + }, + ); if (channelSet?.loraConfig) { connection?.setConfig( diff --git a/src/components/Dialog/LocationResponseDialog.tsx b/src/components/Dialog/LocationResponseDialog.tsx index bd376de3..eb6d5e25 100644 --- a/src/components/Dialog/LocationResponseDialog.tsx +++ b/src/components/Dialog/LocationResponseDialog.tsx @@ -12,7 +12,7 @@ import { numberToHexUnpadded } from "@noble/curves/abstract/utils"; import { useTranslation } from "react-i18next"; export interface LocationResponseDialogProps { - location: Types.PacketMetadata | undefined; + location: Types.PacketMetadata | undefined; open: boolean; onOpenChange: () => void; } @@ -33,6 +33,13 @@ export const LocationResponseDialog = ({ ? `${numberToHexUnpadded(from?.num).substring(0, 4)}` : t("unknown.shortName")); + const position = location?.data; + + const hasCoordinates = position && + typeof position.latitudeI === "number" && + typeof position.longitudeI === "number" && + typeof position.altitude === "number"; + return ( @@ -45,31 +52,40 @@ export const LocationResponseDialog = ({ -
- -

- {t("locationResponse.coordinates")} - - {location?.data.latitudeI / 1e7},{" "} - {location?.data.longitudeI / 1e7} - + {hasCoordinates + ? ( +

+ +

+ {t("locationResponse.coordinates")} + + {" "} + {position.latitudeI ?? 0 / 1e7},{" "} + {position.longitudeI ?? 0 / 1e7} + +

+

+ {t("locationResponse.altitude")} {position.altitude} + {(position.altitude ?? 0) < 1 + ? t("unit.meter.one") + : t("unit.meter.plural")} +

+
+
+ ) + : ( + // Optional: Show a message if coordinates are not available +

+ {t("locationResponse.noCoordinates")}

-

- {t("locationResponse.altitude")} - {location?.data.altitude} - {location?.data.altitde < 1 - ? t("unit.meter.one") - : t("unit.meter.plural")} -

-
-
+ )}
diff --git a/src/components/Dialog/NodeDetailsDialog/NodeDetailsDialog.test.tsx b/src/components/Dialog/NodeDetailsDialog/NodeDetailsDialog.test.tsx deleted file mode 100644 index 0ba79fd1..00000000 --- a/src/components/Dialog/NodeDetailsDialog/NodeDetailsDialog.test.tsx +++ /dev/null @@ -1,149 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { render, screen } from "@testing-library/react"; -import { NodeDetailsDialog } from "@components/Dialog/NodeDetailsDialog/NodeDetailsDialog.tsx"; -import { useDevice } from "@core/stores/deviceStore.ts"; -import { useAppStore } from "@core/stores/appStore.ts"; -import type { Protobuf } from "@meshtastic/core"; - -vi.mock("@core/stores/deviceStore"); -vi.mock("@core/stores/appStore"); - -const mockUseDevice = vi.mocked(useDevice); -const mockUseAppStore = vi.mocked(useAppStore); - -vi.mock("@tanstack/react-router", () => ({ - useNavigate: vi.fn(), -})); - -describe("NodeDetailsDialog", () => { - const mockNode = { - num: 1234, - user: { - longName: "Test Node", - shortName: "TN", - hwModel: 1, - role: 1, - }, - lastHeard: 1697500000, - position: { - latitudeI: 450000000, - longitudeI: -750000000, - altitude: 200, - }, - deviceMetrics: { - airUtilTx: 50.123, - channelUtilization: 75.456, - batteryLevel: 88.789, - voltage: 4.2, - uptimeSeconds: 3600, - }, - } as unknown as Protobuf.Mesh.NodeInfo; - - beforeEach(() => { - vi.resetAllMocks(); - - mockUseDevice.mockReturnValue({ - getNode: (nodeNum: number) => { - if (nodeNum === 1234) { - return mockNode; - } - return undefined; - }, - }); - - mockUseAppStore.mockReturnValue({ - nodeNumDetails: 1234, - }); - }); - - it("renders node details correctly", () => { - render( {}} />); - - expect(screen.getByText(/Node Details for Test Node \(TN\)/i)) - .toBeInTheDocument(); - - expect(screen.getByText("Node Number: 1234")).toBeInTheDocument(); - expect(screen.getByText(/Node Hex: !/i)).toBeInTheDocument(); - expect(screen.getByText(/Last Heard:/i)).toBeInTheDocument(); - - expect(screen.getByText(/Coordinates:/i)).toBeInTheDocument(); - const link = screen.getByRole("link", { name: /^45, -75$/ }); - - expect(link).toBeInTheDocument(); - expect(link).toHaveAttribute( - "href", - expect.stringContaining("openstreetmap.org"), - ); - expect(screen.getByText(/Altitude: 200m/i)).toBeInTheDocument(); - - expect(screen.getByText(/Air TX utilization: 50.12%/i)).toBeInTheDocument(); - expect(screen.getByText(/Channel utilization: 75.46%/i)) - .toBeInTheDocument(); - expect(screen.getByText(/Battery level: 88.79%/i)).toBeInTheDocument(); - expect(screen.getByText(/Voltage: 4.20V/i)).toBeInTheDocument(); - expect(screen.getByText(/Uptime:/i)).toBeInTheDocument(); - - expect(screen.getByText(/All Raw Metrics:/i)).toBeInTheDocument(); - }); - - it("renders null if node is undefined", () => { - const requestedNodeNum = 5678; - - mockUseAppStore.mockReturnValue({ - nodeNumDetails: requestedNodeNum, - }); - - mockUseDevice.mockReturnValue({ - getNode: (nodeNum: number) => { - if (nodeNum === requestedNodeNum) { - return undefined; - } - if (nodeNum === 1234) { - return mockNode; - } - return undefined; - }, - }); - - const { container } = render( - {}} />, - ); - - expect(container.firstChild).toBeNull(); - expect(screen.queryByText(/Node Details for/i)).not.toBeInTheDocument(); - }); - - it("renders correctly when position is missing", () => { - const nodeWithoutPosition = { ...mockNode, position: undefined }; - mockUseDevice.mockReturnValue({ getNode: () => nodeWithoutPosition }); - mockUseAppStore.mockReturnValue({ nodeNumDetails: 1234 }); - - render( {}} />); - - expect(screen.queryByText(/Coordinates:/i)).not.toBeInTheDocument(); - expect(screen.queryByText(/Altitude:/i)).not.toBeInTheDocument(); - expect(screen.getByText(/Node Details for Test Node/i)).toBeInTheDocument(); - }); - - it("renders correctly when deviceMetrics are missing", () => { - const nodeWithoutMetrics = { ...mockNode, deviceMetrics: undefined }; - mockUseDevice.mockReturnValue({ getNode: () => nodeWithoutMetrics }); - mockUseAppStore.mockReturnValue({ nodeNumDetails: 1234 }); - - render( {}} />); - - expect(screen.queryByText(/Device Metrics:/i)).not.toBeInTheDocument(); - expect(screen.queryByText(/Air TX utilization:/i)).not.toBeInTheDocument(); - expect(screen.getByText(/Node Details for Test Node/i)).toBeInTheDocument(); - }); - - it("renders 'Never' for lastHeard when timestamp is 0", () => { - const nodeNeverHeard = { ...mockNode, lastHeard: 0 }; - mockUseDevice.mockReturnValue({ getNode: () => nodeNeverHeard }); - mockUseAppStore.mockReturnValue({ nodeNumDetails: 1234 }); - - render( {}} />); - - expect(screen.getByText(/Last Heard: Never/i)).toBeInTheDocument(); - }); -}); diff --git a/src/components/Dialog/QRDialog.tsx b/src/components/Dialog/QRDialog.tsx index ae3d259f..0cb914e0 100644 --- a/src/components/Dialog/QRDialog.tsx +++ b/src/components/Dialog/QRDialog.tsx @@ -13,7 +13,6 @@ import { Input } from "@components/UI/Input.tsx"; import { Label } from "@components/UI/Label.tsx"; import { Protobuf, type Types } from "@meshtastic/core"; import { fromByteArray } from "base64-js"; -import { ClipboardIcon } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; import { QRCode } from "react-qrcode-logo"; import { useTranslation } from "react-i18next"; @@ -92,7 +91,7 @@ export const QRDialog = ({ { + onChange={() => { if (selectedChannels.includes(channel.index)) { setSelectedChannels( selectedChannels.filter((c) => @@ -144,13 +143,6 @@ export const QRDialog = ({ diff --git a/src/components/Dialog/RebootDialog.tsx b/src/components/Dialog/RebootDialog.tsx index 9b59baa2..7ed48ce6 100644 --- a/src/components/Dialog/RebootDialog.tsx +++ b/src/components/Dialog/RebootDialog.tsx @@ -9,7 +9,7 @@ import { } from "@components/UI/Dialog.tsx"; import { Input } from "@components/UI/Input.tsx"; import { useDevice } from "@core/stores/deviceStore.ts"; -import { ClockIcon, RefreshCwIcon } from "lucide-react"; +import { RefreshCwIcon } from "lucide-react"; import { useTranslation } from "react-i18next"; import { useState } from "react"; @@ -45,12 +45,6 @@ export const RebootDialog = ({ className="dark:text-slate-900" value={time} onChange={(e) => setTime(Number.parseInt(e.target.value))} - action={{ - icon: ClockIcon, - onClick() { - connection?.reboot(time * 60).then(() => onOpenChange(false)); - }, - }} /> -
- ); - }), -})); - -describe("Device component", () => { - const setWorkingConfigMock = vi.fn(); - const validateRoleSelectionMock = vi.fn(); - const mockDeviceConfig = { - role: "CLIENT", - buttonGpio: 0, - buzzerGpio: 0, - rebroadcastMode: "ALL", - nodeInfoBroadcastSecs: 300, - doubleTapAsButtonPress: false, - disableTripleClick: false, - ledHeartbeatDisabled: false, - }; - - beforeEach(() => { - vi.resetAllMocks(); - - // Mock the useDevice hook - useDevice.mockReturnValue({ - config: { - device: mockDeviceConfig, - }, - setWorkingConfig: setWorkingConfigMock, - }); - - // Mock the useUnsafeRolesDialog hook - validateRoleSelectionMock.mockResolvedValue(true); - useUnsafeRolesDialog.mockReturnValue({ - validateRoleSelection: validateRoleSelectionMock, - }); - }); - - afterEach(() => { - vi.clearAllMocks(); - }); - - it("should render the Device form", () => { - render(); - expect(screen.getByTestId("dynamic-form")).toBeInTheDocument(); - }); - - it("should use the validateRoleSelection from the unsafe roles hook", () => { - render(); - expect(useUnsafeRolesDialog).toHaveBeenCalled(); - }); - - it("should call setWorkingConfig when form is submitted", async () => { - render(); - - fireEvent.click(screen.getByTestId("submit-button")); - - await waitFor(() => { - expect(setWorkingConfigMock).toHaveBeenCalledWith( - expect.objectContaining({ - payloadVariant: { - case: "device", - value: expect.objectContaining({ role: "CLIENT" }), - }, - }), - ); - }); - }); - - it("should create config with proper structure", async () => { - render(); - - // Simulate form submission - fireEvent.click(screen.getByTestId("submit-button")); - - await waitFor(() => { - expect(setWorkingConfigMock).toHaveBeenCalledWith( - expect.objectContaining({ - payloadVariant: { - case: "device", - value: expect.any(Object), - }, - }), - ); - }); - }); -}); diff --git a/src/components/PageComponents/Config/Network/Network.test.tsx b/src/components/PageComponents/Config/Network/Network.test.tsx deleted file mode 100644 index 8eacb277..00000000 --- a/src/components/PageComponents/Config/Network/Network.test.tsx +++ /dev/null @@ -1,173 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; -import { Network } from "@components/PageComponents/Config/Network/index.tsx"; -import { useDevice } from "@core/stores/deviceStore.ts"; -import { Protobuf } from "@meshtastic/core"; - -vi.mock("@core/stores/deviceStore", () => ({ - useDevice: vi.fn(), -})); - -vi.mock("@components/Form/DynamicForm", async () => { - const React = await import("react"); - const { useState } = React; - - return { - DynamicForm: ({ onSubmit, defaultValues }) => { - const [wifiEnabled, setWifiEnabled] = useState( - defaultValues.wifiEnabled ?? false, - ); - const [ssid, setSsid] = useState(defaultValues.wifiSsid ?? ""); - const [psk, setPsk] = useState(defaultValues.wifiPsk ?? ""); - - return ( -
{ - e.preventDefault(); - onSubmit({ - ...defaultValues, - wifiEnabled, - wifiSsid: ssid, - wifiPsk: psk, - }); - }} - data-testid="dynamic-form" - > - setWifiEnabled(e.target.checked)} - /> - setSsid(e.target.value)} - disabled={!wifiEnabled} - /> - setPsk(e.target.value)} - disabled={!wifiEnabled} - /> - -
- ); - }, - }; -}); - -describe("Network component", () => { - const setWorkingConfigMock = vi.fn(); - const mockNetworkConfig = { - wifiEnabled: false, - wifiSsid: "", - wifiPsk: "", - ntpServer: "", - ethEnabled: false, - addressMode: Protobuf.Config.Config_NetworkConfig_AddressMode.DHCP, - ipv4Config: { - ip: 0, - gateway: 0, - subnet: 0, - dns: 0, - }, - enabledProtocols: - Protobuf.Config.Config_NetworkConfig_ProtocolFlags.NO_BROADCAST, - rsyslogServer: "", - }; - - beforeEach(() => { - vi.resetAllMocks(); - - useDevice.mockReturnValue({ - config: { - network: mockNetworkConfig, - }, - setWorkingConfig: setWorkingConfigMock, - }); - }); - - afterEach(() => { - vi.clearAllMocks(); - }); - - it("should render the Network form", () => { - render(); - expect(screen.getByTestId("dynamic-form")).toBeInTheDocument(); - }); - - it("should disable SSID and PSK fields when wifi is off", () => { - render(); - expect(screen.getByLabelText("SSID")).toBeDisabled(); - expect(screen.getByLabelText("PSK")).toBeDisabled(); - }); - - it("should enable SSID and PSK when wifi is toggled on", async () => { - render(); - const toggle = screen.getByLabelText("WiFi Enabled"); - fireEvent.click(toggle); // turns wifiEnabled = true - - await waitFor(() => { - expect(screen.getByLabelText("SSID")).not.toBeDisabled(); - expect(screen.getByLabelText("PSK")).not.toBeDisabled(); - }); - }); - - it("should call setWorkingConfig with the right structure on submit", async () => { - render(); - - fireEvent.click(screen.getByTestId("submit-button")); - - await waitFor(() => { - expect(setWorkingConfigMock).toHaveBeenCalledWith( - expect.objectContaining({ - payloadVariant: { - case: "network", - value: expect.objectContaining({ - wifiEnabled: false, - wifiSsid: "", - wifiPsk: "", - ntpServer: "", - ethEnabled: false, - rsyslogServer: "", - }), - }, - }), - ); - }); - }); - - it("should submit valid data after enabling wifi and entering SSID and PSK", async () => { - render(); - fireEvent.click(screen.getByLabelText("WiFi Enabled")); - - fireEvent.change(screen.getByLabelText("SSID"), { - target: { value: "MySSID" }, - }); - - fireEvent.change(screen.getByLabelText("PSK"), { - target: { value: "MySecretPSK" }, - }); - - fireEvent.click(screen.getByTestId("submit-button")); - - await waitFor(() => { - expect(setWorkingConfigMock).toHaveBeenCalledWith( - expect.objectContaining({ - payloadVariant: { - case: "network", - value: expect.objectContaining({ - wifiEnabled: true, - wifiSsid: "MySSID", - wifiPsk: "MySecretPSK", - }), - }, - }), - ); - }); - }); -}); diff --git a/src/components/PageComponents/Connect/BLE.tsx b/src/components/PageComponents/Connect/BLE.tsx index a3458792..28aec612 100644 --- a/src/components/PageComponents/Connect/BLE.tsx +++ b/src/components/PageComponents/Connect/BLE.tsx @@ -7,6 +7,7 @@ import { subscribeAll } from "@core/subscriptions.ts"; import { randId } from "@core/utils/randId.ts"; import { TransportWebBluetooth } from "@meshtastic/transport-web-bluetooth"; import { MeshDevice } from "@meshtastic/core"; +import type { BluetoothDevice } from "web-bluetooth"; import { useCallback, useEffect, useState } from "react"; import { useMessageStore } from "@core/stores/messageStore/index.ts"; import { useTranslation } from "react-i18next"; @@ -77,7 +78,7 @@ export const BLE = ( if (exists === -1) { setBleDevices(bleDevices.concat(device)); } - }).catch((error) => { + }).catch((error: Error) => { console.error("Error requesting device:", error); setConnectionInProgress(false); }).finally(() => { diff --git a/src/components/PageComponents/Connect/HTTP.tsx b/src/components/PageComponents/Connect/HTTP.tsx index e1bca177..d92733af 100644 --- a/src/components/PageComponents/Connect/HTTP.tsx +++ b/src/components/PageComponents/Connect/HTTP.tsx @@ -66,7 +66,9 @@ export const HTTP = ( subscribeAll(device, connection, messageStore); closeDialog(); } catch (error) { - console.error("Connection error:", error); + if (error instanceof Error) { + console.error("Connection error:", error); + } // Capture all connection errors regardless of type setConnectionError({ host: data.ip, secure: data.tls }); setConnectionInProgress(false); diff --git a/src/components/PageComponents/Connect/Serial.tsx b/src/components/PageComponents/Connect/Serial.tsx index ff92c1e1..1303a016 100644 --- a/src/components/PageComponents/Connect/Serial.tsx +++ b/src/components/PageComponents/Connect/Serial.tsx @@ -9,7 +9,8 @@ import { MeshDevice } from "@meshtastic/core"; import { TransportWebSerial } from "@meshtastic/transport-web-serial"; import { useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; -import { useMessageStore } from "../../../core/stores/messageStore/index.ts"; +import type { SerialPort } from "w3c-web-serial"; +import { useMessageStore } from "@core/stores/messageStore/index.ts"; export const Serial = ( { closeDialog }: TabElementProps, @@ -22,13 +23,13 @@ export const Serial = ( const { t } = useTranslation(); const updateSerialPortList = useCallback(async () => { - setSerialPorts(await navigator?.serial.getPorts()); + setSerialPorts(await navigator.serial.getPorts()); }, []); - navigator?.serial?.addEventListener("connect", () => { + navigator.serial.addEventListener("connect", () => { updateSerialPortList(); }); - navigator?.serial?.addEventListener("disconnect", () => { + navigator.serial.addEventListener("disconnect", () => { updateSerialPortList(); }); useEffect(() => { @@ -89,7 +90,7 @@ export const Serial = ( await navigator.serial.requestPort().then((port) => { setSerialPorts(serialPorts.concat(port)); // No need to setConnectionInProgress(false) here if requestPort is quick - }).catch((error) => { + }).catch((error: Error) => { console.error("Error requesting port:", error); }).finally(() => { setConnectionInProgress(false); diff --git a/src/components/PageComponents/Messages/MessageItem.tsx b/src/components/PageComponents/Messages/MessageItem.tsx index 7a932a1d..ab509741 100644 --- a/src/components/PageComponents/Messages/MessageItem.tsx +++ b/src/components/PageComponents/Messages/MessageItem.tsx @@ -56,21 +56,21 @@ export const MessageItem = ({ message }: MessageItemProps) => { const MESSAGE_STATUS_MAP = useMemo( (): Record => ({ [MessageState.Ack]: { - displayText: t("message_item_status_delivered_displayText"), + displayText: t("deliveryStatus.deliveryStatus."), icon: CheckCircle2, - ariaLabel: t("message_item_status_delivered_ariaLabel"), + ariaLabel: t("deliveryStatus.delivered"), iconClassName: "text-green-500", }, [MessageState.Waiting]: { - displayText: t("message_item_status_waiting_displayText"), + displayText: t("deliveryStatus.waiting"), icon: CircleEllipsis, - ariaLabel: t("message_item_status_waiting_ariaLabel"), + ariaLabel: t("deliveryStatus.waiting"), iconClassName: "text-slate-400", }, [MessageState.Failed]: { - displayText: t("message_item_status_failed_displayText"), + displayText: t("deliveryStatus.failed"), icon: AlertCircle, - ariaLabel: t("message_item_status_failed_ariaLabel"), + ariaLabel: t("deliveryStatus.failed"), iconClassName: "text-red-500 dark:text-red-400", }, }), @@ -78,9 +78,9 @@ export const MessageItem = ({ message }: MessageItemProps) => { ); const UNKNOWN_STATUS = useMemo((): MessageStatusInfo => ({ - displayText: t("message_item_status_unknown_displayText"), + displayText: t("delveryStatus.unknown"), icon: AlertCircle, - ariaLabel: t("message_item_status_unknown_ariaLabel"), + ariaLabel: t("deliveryStatus.unknown"), iconClassName: "text-red-500 dark:text-red-400", }), [t]); diff --git a/src/components/PageComponents/Messages/TraceRoute.test.tsx b/src/components/PageComponents/Messages/TraceRoute.test.tsx index cfd9dc6a..9a0f7298 100644 --- a/src/components/PageComponents/Messages/TraceRoute.test.tsx +++ b/src/components/PageComponents/Messages/TraceRoute.test.tsx @@ -2,29 +2,71 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { render, screen } from "@testing-library/react"; import { TraceRoute } from "@components/PageComponents/Messages/TraceRoute.tsx"; import { useDevice } from "@core/stores/deviceStore.ts"; -import type { Protobuf } from "@meshtastic/core"; +import { mockDeviceStore } from "@core/stores/deviceStore.mock.ts"; +import { Protobuf } from "@meshtastic/core"; vi.mock("@core/stores/deviceStore"); describe("TraceRoute", () => { + const fromUser = { + user: { + $typeName: "meshtastic.User", + longName: "Source Node", + publicKey: new Uint8Array([1, 2, 3]), + shortName: "Source", + hwModel: 1, + macaddr: new Uint8Array([0x01, 0x02, 0x03, 0x04]), + id: "source-node", + isLicensed: false, + role: Protobuf.Config.Config_DeviceConfig_Role["CLIENT"], + } as Protobuf.Mesh.NodeInfo["user"], + }; + + const toUser = { + user: { + $typeName: "meshtastic.User", + longName: "Destination Node", + publicKey: new Uint8Array([4, 5, 6]), + shortName: "Destination", + hwModel: 2, + macaddr: new Uint8Array([0x05, 0x06, 0x07, 0x08]), + id: "destination-node", + isLicensed: false, + role: Protobuf.Config.Config_DeviceConfig_Role["CLIENT"], + } as Protobuf.Mesh.NodeInfo["user"], + }; + const mockNodes = new Map([ [ 1, - { num: 1, user: { longName: "Node A" } } as Protobuf.Mesh.NodeInfo, + { + num: 1, + user: { longName: "Node A", $typeName: "meshtastic.User" }, + $typeName: "meshtastic.NodeInfo", + } as Protobuf.Mesh.NodeInfo, ], [ 2, - { num: 2, user: { longName: "Node B" } } as Protobuf.Mesh.NodeInfo, + { + num: 2, + user: { longName: "Node B", $typeName: "meshtastic.User" }, + $typeName: "meshtastic.NodeInfo", + } as Protobuf.Mesh.NodeInfo, ], [ 3, - { num: 3, user: { longName: "Node C" } } as Protobuf.Mesh.NodeInfo, + { + num: 3, + user: { longName: "Node C", $typeName: "meshtastic.User" }, + $typeName: "meshtastic.NodeInfo", + } as Protobuf.Mesh.NodeInfo, ], ]); beforeEach(() => { vi.resetAllMocks(); vi.mocked(useDevice).mockReturnValue({ + ...mockDeviceStore, getNode: (nodeNum: number): Protobuf.Mesh.NodeInfo | undefined => { return mockNodes.get(nodeNum); }, @@ -34,16 +76,15 @@ describe("TraceRoute", () => { it("renders the route to destination with SNR values", () => { render( , ); - expect(screen.getAllByText("Source Node")).toHaveLength(1); + expect(screen.getByText("Source Node")).toBeInTheDocument(); expect(screen.getByText("Destination Node")).toBeInTheDocument(); - expect(screen.getByText("Node A")).toBeInTheDocument(); expect(screen.getByText("Node B")).toBeInTheDocument(); @@ -56,8 +97,8 @@ describe("TraceRoute", () => { it("renders the route back when provided", () => { render( { />, ); + // Check for the translated title expect(screen.getByText("Route back:")).toBeInTheDocument(); + // With route back, both names appear twice expect(screen.getAllByText("Source Node")).toHaveLength(2); - expect(screen.getAllByText("Destination Node")).toHaveLength(2); - expect(screen.getByText("Node C")).toBeInTheDocument(); expect(screen.getByText("Node A")).toBeInTheDocument(); - - expect(screen.getByText("↓ 35dBm")).toBeInTheDocument(); - expect(screen.getByText("↓ 45dBm")).toBeInTheDocument(); + expect(screen.getByText("Node C")).toBeInTheDocument(); expect(screen.getByText("↓ 15dBm")).toBeInTheDocument(); expect(screen.getByText("↓ 25dBm")).toBeInTheDocument(); + expect(screen.getByText("↓ 35dBm")).toBeInTheDocument(); + expect(screen.getByText("↓ 45dBm")).toBeInTheDocument(); }); it("renders '??' for missing SNR values", () => { render( , ); expect(screen.getByText("Node A")).toBeInTheDocument(); - expect(screen.getAllByText("↓ ??dBm")).toHaveLength(2); - }); - - it("renders hop hex if node is not found", () => { - render( - , - ); - - expect(screen.getByText("↓ 5dBm")).toBeInTheDocument(); - expect(screen.getByText("↓ 15dBm")).toBeInTheDocument(); + // Check for translated '??' placeholder + expect(screen.getAllByText(/↓ \?\?dBm/)).toHaveLength(2); }); }); diff --git a/src/components/PageComponents/Messages/TraceRoute.tsx b/src/components/PageComponents/Messages/TraceRoute.tsx index 6ad25952..a1d85aa6 100644 --- a/src/components/PageComponents/Messages/TraceRoute.tsx +++ b/src/components/PageComponents/Messages/TraceRoute.tsx @@ -3,9 +3,11 @@ import type { Protobuf } from "@meshtastic/core"; import { numberToHexUnpadded } from "@noble/curves/abstract/utils"; import { useTranslation } from "react-i18next"; +type NodeUser = Pick; + export interface TraceRouteProps { - from?: Protobuf.Mesh.NodeInfo; - to?: Protobuf.Mesh.NodeInfo; + from: NodeUser; + to: NodeUser; route: Array; routeBack?: Array; snrTowards?: Array; @@ -14,14 +16,14 @@ export interface TraceRouteProps { interface RoutePathProps { title: string; - startNode?: Protobuf.Mesh.NodeInfo; - endNode?: Protobuf.Mesh.NodeInfo; + from: NodeUser; + to: NodeUser; path: number[]; snr?: number[]; } const RoutePath = ( - { title, startNode, endNode, path, snr }: RoutePathProps, + { title, from, to, path, snr }: RoutePathProps, ) => { const { getNode } = useDevice(); const { t } = useTranslation(); @@ -32,7 +34,7 @@ const RoutePath = ( className="ml-4 border-l-2 pl-2 border-l-slate-900 text-slate-900 dark:text-slate-100 dark:border-l-slate-100" >

{title}

-

{startNode?.user?.longName}

+

{from?.user?.longName}

↓ {snr?.[0] ?? t("unknown.num")} {t("unit.dbm")} @@ -49,7 +51,7 @@ const RoutePath = (

))} -

{endNode?.user?.longName}

+

{to?.user?.longName}

); }; @@ -67,16 +69,16 @@ export const TraceRoute = ({
{routeBack && routeBack.length > 0 && ( diff --git a/src/components/UI/Button.tsx b/src/components/UI/Button.tsx index ef5ab101..905eca30 100644 --- a/src/components/UI/Button.tsx +++ b/src/components/UI/Button.tsx @@ -46,44 +46,36 @@ export interface ButtonProps iconAlignment?: "left" | "right"; } -const Button = React.forwardRef( - ( - { - className, - variant, - size, - disabled, - icon, - iconAlignment = "left", - children, - ...props - }, - ref, - ) => { - return ( - - ); - }, -); -Button.displayName = "Button"; +const Button = ({ + className, + variant, + size, + disabled, + icon, + iconAlignment = "left", + children, + ...props +}: ButtonProps) => { + return ( + + ); +}; export { Button, buttonVariants }; diff --git a/src/components/UI/Checkbox/Checkbox.test.tsx b/src/components/UI/Checkbox/Checkbox.test.tsx index 7e5ba73d..e7badabe 100644 --- a/src/components/UI/Checkbox/Checkbox.test.tsx +++ b/src/components/UI/Checkbox/Checkbox.test.tsx @@ -23,18 +23,6 @@ vi.mock("@components/UI/Label.tsx", () => ({ ), })); -vi.mock("@core/utils/cn.ts", () => ({ - cn: (...args) => args.filter(Boolean).join(" "), -})); - -vi.mock("react", async () => { - const actual = await vi.importActual("react"); - return { - ...actual, - useId: () => "test-id", - }; -}); - describe("Checkbox", () => { beforeEach(cleanup); @@ -67,11 +55,6 @@ describe("Checkbox", () => { expect(screen.getByRole("checkbox").id).toBe("custom-id"); }); - it("generates id when not provided", () => { - render(); - expect(screen.getByRole("checkbox").id).toBe("test-id"); - }); - it("renders children in Label component", () => { render(Test Label); expect(screen.getByTestId("label-component")).toHaveTextContent( diff --git a/src/components/UI/Dialog.tsx b/src/components/UI/Dialog.tsx index 763e53e0..8a820077 100644 --- a/src/components/UI/Dialog.tsx +++ b/src/components/UI/Dialog.tsx @@ -58,9 +58,7 @@ DialogContent.displayName = DialogPrimitive.Content.displayName; const DialogClose = ({ className, ...props -}: DialogPrimitive.DialogCloseProps & React.RefAttributes & { - className?: string; -}) => ( +}: React.ComponentPropsWithoutRef) => ( React.ReactNode); className?: string; } diff --git a/src/components/generic/Filter/useFilterNode.ts b/src/components/generic/Filter/useFilterNode.ts index da3b7347..a55fbe85 100644 --- a/src/components/generic/Filter/useFilterNode.ts +++ b/src/components/generic/Filter/useFilterNode.ts @@ -1,153 +1,185 @@ import { Protobuf } from "@meshtastic/core"; import { numberToHexUnpadded } from "@noble/curves/abstract/utils"; -import { useMemo } from "react"; +import { useCallback, useMemo } from "react"; export type FilterState = { nodeName: string; hopsAway: [number, number]; lastHeard: [number, number]; - isFavorite: boolean | undefined; // undefined -> don't filter - viaMqtt: boolean | undefined; // undefined -> don't filter + isFavorite: boolean | undefined; + viaMqtt: boolean | undefined; snr: [number, number]; channelUtilization: [number, number]; airUtilTx: [number, number]; batteryLevel: [number, number]; voltage: [number, number]; - role: (Protobuf.Config.Config_DeviceConfig_Role)[]; - hwModel: (Protobuf.Mesh.HardwareModel)[]; + role: Protobuf.Config.Config_DeviceConfig_Role[]; + hwModel: Protobuf.Mesh.HardwareModel[]; +}; + +const shallowEqualArray = (a: T[], b: T[]): boolean => { + if (a.length !== b.length) { + return false; + } + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) { + return false; + } + } + return true; }; export function useFilterNode() { - const defaultFilterValues = useMemo(() => ({ - nodeName: "", - hopsAway: [0, 7], - lastHeard: [0, 864000], // 0-10 days - isFavorite: undefined, - viaMqtt: undefined, - snr: [-20, 10], - channelUtilization: [0, 100], - airUtilTx: [0, 100], - batteryLevel: [0, 101], - voltage: [0, 5], - role: Object.values(Protobuf.Config.Config_DeviceConfig_Role).filter( - (v): v is Protobuf.Config.Config_DeviceConfig_Role => - typeof v === "number", - ), - hwModel: Object.values(Protobuf.Mesh.HardwareModel).filter( - (v): v is Protobuf.Mesh.HardwareModel => typeof v === "number", - ), - }), []); + const defaultFilterValues = useMemo( + () => ({ + nodeName: "", + hopsAway: [0, 7], + lastHeard: [0, 864000], // 0-10 days + isFavorite: undefined, + viaMqtt: undefined, + snr: [-20, 10], + channelUtilization: [0, 100], + airUtilTx: [0, 100], + batteryLevel: [0, 101], + voltage: [0, 5], + role: Object.values(Protobuf.Config.Config_DeviceConfig_Role).filter( + (v): v is Protobuf.Config.Config_DeviceConfig_Role => + typeof v === "number", + ), + hwModel: Object.values(Protobuf.Mesh.HardwareModel).filter( + (v): v is Protobuf.Mesh.HardwareModel => typeof v === "number", + ), + }), + [], + ); - function nodeFilter( - node: Protobuf.Mesh.NodeInfo, - filterOverrides?: Partial, - ): boolean { - const filterState: FilterState = { - ...defaultFilterValues, - ...filterOverrides, - }; + const nodeFilter = useCallback( + ( + node: Protobuf.Mesh.NodeInfo, + filterOverrides?: Partial, + ): boolean => { + const filterState: FilterState = { + ...defaultFilterValues, + ...filterOverrides, + }; - if (!node.user) return false; + if (!node.user) return false; - const nodeName = filterState.nodeName.toLowerCase(); - if ( - !( - node.user?.shortName.toLowerCase().includes(nodeName) || - node.user?.longName.toLowerCase().includes(nodeName) || - node?.num.toString().includes(nodeName) || - numberToHexUnpadded(node?.num).includes( - nodeName.replace(/!/g, ""), + const nodeName = filterState.nodeName.toLowerCase(); + if ( + nodeName && + !( + node.user?.shortName.toLowerCase().includes(nodeName) || + node.user?.longName.toLowerCase().includes(nodeName) || + node.num.toString().includes(nodeName) || + numberToHexUnpadded(node.num).includes(nodeName.replace(/!/g, "")) ) - ) - ) return false; + ) { + return false; + } + + const hops = node.hopsAway ?? 7; + if (hops < filterState.hopsAway[0] || hops > filterState.hopsAway[1]) { + return false; + } + + const secondsAgo = Date.now() / 1000 - (node.lastHeard ?? 0); + if ( + secondsAgo < filterState.lastHeard[0] || + (secondsAgo > filterState.lastHeard[1] && + filterState.lastHeard[1] !== defaultFilterValues.lastHeard[1]) + ) { + return false; + } + + if ( + typeof filterState.isFavorite !== "undefined" && + node.isFavorite !== filterState.isFavorite + ) { + return false; + } + + if ( + typeof filterState.viaMqtt !== "undefined" && + node.viaMqtt !== filterState.viaMqtt + ) { + return false; + } + + const snr = node.snr ?? -20; + if (snr < filterState.snr[0] || snr > filterState.snr[1]) return false; + + const channelUtilization = node.deviceMetrics?.channelUtilization ?? 0; + if ( + channelUtilization < filterState.channelUtilization[0] || + channelUtilization > filterState.channelUtilization[1] + ) { + return false; + } + + const airUtilTx = node.deviceMetrics?.airUtilTx ?? 0; + if ( + airUtilTx < filterState.airUtilTx[0] || + airUtilTx > filterState.airUtilTx[1] + ) { + return false; + } + + const batt = node.deviceMetrics?.batteryLevel ?? 101; + if ( + batt < filterState.batteryLevel[0] || + batt > filterState.batteryLevel[1] + ) { + return false; + } + + const voltage = node.deviceMetrics?.voltage ?? 0; + if ( + voltage < filterState.voltage[0] || + voltage > filterState.voltage[1] + ) { + return false; + } + + const role: Protobuf.Config.Config_DeviceConfig_Role = node.user.role ?? + Protobuf.Config.Config_DeviceConfig_Role.CLIENT; + if (!filterState.role.includes(role)) return false; + + const hwModel: Protobuf.Mesh.HardwareModel = node.user.hwModel ?? + Protobuf.Mesh.HardwareModel.UNSET; + if (!filterState.hwModel.includes(hwModel)) return false; + + return true; + }, + [defaultFilterValues], + ); + + const isFilterDirty = useCallback( + ( + current: FilterState, + overrides?: Partial, + ): boolean => { + const base: FilterState = overrides + ? { ...defaultFilterValues, ...overrides } + : defaultFilterValues; + + for (const key of Object.keys(base) as (keyof FilterState)[]) { + const currentValue = current[key]; + const defaultValue = base[key]; + + if (Array.isArray(defaultValue) && Array.isArray(currentValue)) { + if (!shallowEqualArray(currentValue, defaultValue)) { + return true; + } + } else if (currentValue !== defaultValue) { + return true; + } + } - const hops = node?.hopsAway ?? 7; - if (hops < filterState.hopsAway[0] || hops > filterState.hopsAway[1]) { return false; - } - - const secondsAgo = Date.now() / 1000 - (node?.lastHeard ?? 0); - if ( - secondsAgo < filterState.lastHeard[0] || - ( - secondsAgo > filterState.lastHeard[1] && - filterState.lastHeard[1] !== defaultFilterValues.lastHeard[1] - ) - ) return false; - - if ( - typeof filterState.isFavorite !== "undefined" && - node.isFavorite !== filterState.isFavorite - ) return false; - - if ( - typeof filterState.viaMqtt !== "undefined" && - node.viaMqtt !== filterState.viaMqtt - ) return false; - - const snr = node?.snr ?? -20; - if ( - snr < filterState.snr[0] || - snr > filterState.snr[1] - ) return false; - - const channelUtilization = node?.deviceMetrics?.channelUtilization ?? 0; - if ( - channelUtilization < filterState.channelUtilization[0] || - channelUtilization > filterState.channelUtilization[1] - ) return false; - - const airUtilTx = node?.deviceMetrics?.airUtilTx ?? 0; - if ( - airUtilTx < filterState.airUtilTx[0] || - airUtilTx > filterState.airUtilTx[1] - ) return false; - - const batt = node?.deviceMetrics?.batteryLevel ?? 101; - if ( - batt < filterState.batteryLevel[0] || - batt > filterState.batteryLevel[1] - ) return false; - - const voltage = node?.deviceMetrics?.voltage ?? 0; - if ( - voltage < filterState.voltage[0] || - voltage > filterState.voltage[1] - ) return false; - - const role: Protobuf.Config.Config_DeviceConfig_Role = node.user?.role ?? - Protobuf.Config.Config_DeviceConfig_Role.CLIENT; - if (!filterState.role.includes(role)) return false; - - const hwModel: Protobuf.Mesh.HardwareModel = node.user?.hwModel ?? - Protobuf.Mesh.HardwareModel.UNSET; - if (!filterState.hwModel.includes(hwModel)) return false; - - // All conditions are true - return true; - } - - // deno-lint-ignore no-explicit-any - function shallowEqualArray(a: any[], b: any[]) { - return a.length === b.length && a.every((v, i) => v === b[i]); - } - - function isFilterDirty( - current: FilterState, - overrides?: Partial, - ): boolean { - const base: FilterState = overrides - ? { ...defaultFilterValues, ...overrides } - : defaultFilterValues; - - return (Object.keys(base) as (keyof FilterState)[]).some((key) => { - const curr = current[key]; - const def = base[key]; - return Array.isArray(def) && Array.isArray(curr) - ? !shallowEqualArray(curr, def) - : curr !== def; - }); - } + }, + [defaultFilterValues], + ); return { nodeFilter, defaultFilterValues, isFilterDirty }; } diff --git a/src/components/generic/Table/index.test.tsx b/src/components/generic/Table/index.test.tsx index 727357f4..8062144b 100644 --- a/src/components/generic/Table/index.test.tsx +++ b/src/components/generic/Table/index.test.tsx @@ -1,142 +1,128 @@ import { describe, expect, it } from "vitest"; import { fireEvent, render, screen } from "@testing-library/react"; -import { Table } from "@components/generic/Table/index.tsx"; +import { DataRow, Heading, Table } from "@components/generic/Table/index.tsx"; import { TimeAgo } from "@components/generic/TimeAgo.tsx"; import { Mono } from "@components/generic/Mono.tsx"; // @ts-types="react" describe("Generic Table", () => { it("Can render an empty table.", () => { - render( - , - ); + render(
); expect(screen.getByRole("table")).toBeInTheDocument(); }); it("Can render a table with headers and no rows.", async () => { - render( -
, - ); + const headings: Heading[] = [ + { title: "Short Name", sortable: true }, + { title: "Last Heard", sortable: true }, + { title: "Connection", sortable: true }, + ]; + render(
); await screen.findByRole("table"); - expect(screen.getAllByRole("columnheader")).toHaveLength(9); + expect(screen.getAllByRole("columnheader")).toHaveLength(3); }); - // A simplified version of the rows in pages/Nodes.tsx for testing purposes - const mockDevicesWithShortNameAndConnection = [ + // Mock data representing devices + const mockDevices = [ { - user: { shortName: "TST1" }, + id: "TST1", + shortName: "TST1", hopsAway: 1, - lastHeard: Date.now() + 1000, + lastHeard: Date.now() - 3000, viaMqtt: false, }, { - user: { shortName: "TST2" }, + id: "TST2", + shortName: "TST2", hopsAway: 0, - lastHeard: Date.now() + 4000, + lastHeard: Date.now() - 1000, viaMqtt: true, + isFavorite: true, // Favorite device }, { - user: { shortName: "TST3" }, + id: "TST3", + shortName: "TST3", hopsAway: 4, - lastHeard: Date.now(), + lastHeard: Date.now() - 5000, viaMqtt: false, }, { - user: { shortName: "TST4" }, + id: "TST4", + shortName: "TST4", hopsAway: 3, - lastHeard: Date.now() + 2000, + lastHeard: Date.now() - 2000, viaMqtt: true, }, ]; - const mockRows = mockDevicesWithShortNameAndConnection.map((node) => [ -

{node.user.shortName}

, - - - , - - {node.lastHeard !== 0 - ? node.viaMqtt === false && node.hopsAway === 0 - ? "Direct" - : `${node.hopsAway?.toString()} ${ - node.hopsAway ?? 0 > 1 ? "hops" : "hop" - } away` - : "-"} - {node.viaMqtt === true ? ", via MQTT" : ""} - , - ]); + // Transform mock data into the format expected by the Table component + const mockRows: DataRow[] = mockDevices.map((node) => ({ + id: node.id, + isFavorite: node.isFavorite, + cells: [ + { + content: {node.shortName}, + sortValue: node.shortName, + }, + { + content: ( + + + + ), + sortValue: node.lastHeard, + }, + { + content: ( + + {node.lastHeard !== 0 + ? node.viaMqtt === false && node.hopsAway === 0 + ? "Direct" + : `${node.hopsAway} ${node.hopsAway > 1 ? "hops" : "hop"} away` + : "-"} + {node.viaMqtt ? ", via MQTT" : ""} + + ), + sortValue: node.hopsAway, + }, + ], + })); - it("Can sort rows appropriately.", async () => { - render( -
, - ); + const headings: Heading[] = [ + { title: "Short Name", sortable: true }, + { title: "Last Heard", sortable: true }, + { title: "Connection", sortable: true }, + ]; + + it("Can sort rows, keeping favorites at the top", async () => { + render(
); const renderedTable = await screen.findByRole("table"); const columnHeaders = screen.getAllByRole("columnheader"); expect(columnHeaders).toHaveLength(3); - // Will be sorted "Last heard" "asc" by default - expect( - [...renderedTable.querySelectorAll("[data-testshortname]")] - .map((el) => el.textContent) - .map((v) => v?.trim()) - .join(","), - ) - .toMatch("TST2,TST4,TST1,TST3"); + const getRenderedOrder = () => + [...renderedTable.querySelectorAll("[data-testid='short-name']")].map( + (el) => el.textContent?.trim(), + ); + // Default sort: "Last Heard" desc. TST2 is favorite, so it's first. + // Then the rest are sorted by lastHeard timestamp (most recent first). + // Order of timestamps: TST2 (latest, but favorite), TST4, TST1, TST3 (oldest). + expect(getRenderedOrder()).toEqual(["TST2", "TST4", "TST1", "TST3"]); + + // Click "Short Name" to sort asc fireEvent.click(columnHeaders[0]); + // TST2 is favorite, so it's first. Then TST1, TST3, TST4 alphabetically. + expect(getRenderedOrder()).toEqual(["TST2", "TST1", "TST3", "TST4"]); - // Re-sort by Short Name asc - expect( - [...renderedTable.querySelectorAll("[data-testshortname]")] - .map((el) => el.textContent) - .map((v) => v?.trim()) - .join(","), - ) - .toMatch("TST1,TST2,TST3,TST4"); - + // Click "Short Name" again to sort desc fireEvent.click(columnHeaders[0]); + // TST2 is favorite, so it's first. Then TST4, TST3, TST1 reverse alphabetically. + expect(getRenderedOrder()).toEqual(["TST2", "TST4", "TST3", "TST1"]); - // Re-sort by Short Name desc - expect( - [...renderedTable.querySelectorAll("[data-testshortname]")] - .map((el) => el.textContent) - .map((v) => v?.trim()) - .join(","), - ) - .toMatch("TST4,TST3,TST2,TST1"); - + // Click "Connection" to sort by hops asc fireEvent.click(columnHeaders[2]); - - // Re-sort by Hops Away - expect( - [...renderedTable.querySelectorAll("[data-testshortname]")] - .map((el) => el.textContent) - .map((v) => v?.trim()) - .join(","), - ) - .toMatch("TST2,TST1,TST4,TST3"); + // TST2 is favorite (and also has 0 hops). Then sorted by hops: TST1 (1), TST4 (3), TST3 (4). + expect(getRenderedOrder()).toEqual(["TST2", "TST1", "TST4", "TST3"]); }); }); diff --git a/src/components/generic/Table/index.tsx b/src/components/generic/Table/index.tsx index ae66a730..af699644 100755 --- a/src/components/generic/Table/index.tsx +++ b/src/components/generic/Table/index.tsx @@ -1,30 +1,32 @@ import { ChevronDownIcon, ChevronUpIcon } from "lucide-react"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import React from "react"; import { cn } from "@core/utils/cn.ts"; -interface FavoriteIconProps { - showFavorite: boolean; +export interface Heading { + title: string; + sortable: boolean; } -interface AvatarCellProps { - children: React.ReactElement; +interface Cell { + content: React.ReactNode; + sortValue: string | number; +} + +export interface DataRow { + id: string | number; + isFavorite?: boolean; + cells: Cell[]; } export interface TableProps { headings: Heading[]; - rows: React.ReactElement[][]; + rows: DataRow[]; } -export interface Heading { - title: string; - type: "blank" | "normal"; - sortable: boolean; -} - -function numericHops(hopsAway: string | unknown): number { - if (typeof hopsAway !== "string") { - return Number.MAX_SAFE_INTEGER; +function numericHops(hopsAway: string | number): number { + if (typeof hopsAway === "number") { + return hopsAway; } if (hopsAway.match(/direct/i)) { return 0; @@ -37,7 +39,7 @@ export const Table = ({ headings, rows }: TableProps) => { const [sortColumn, setSortColumn] = useState("Last Heard"); const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc"); - const headingSort = (title: string) => { + const handleSort = (title: string) => { if (sortColumn === title) { setSortOrder(sortOrder === "asc" ? "desc" : "asc"); } else { @@ -46,72 +48,36 @@ export const Table = ({ headings, rows }: TableProps) => { } }; - const getElement = (cell: React.ReactNode): React.ReactElement | null => { - if (!React.isValidElement(cell)) { - return null; - } - if (cell.type === React.Fragment) { - const childrenArray = React.Children.toArray(cell.props.children); - const firstElement = childrenArray.find((child) => - React.isValidElement(child) - ); - return (firstElement as React.ReactElement) ?? null; - } - // If not a fragment, return the element itself - return cell; - }; - - const sortedRows = rows.slice().sort((a, b) => { - if (!sortColumn) return 0; + const sortedRows = useMemo(() => { + if (!sortColumn) return rows; const columnIndex = headings.findIndex((h) => h.title === sortColumn); - if (columnIndex === -1) return 0; + if (columnIndex === -1) return rows; - const elementA = getElement(a[columnIndex]); - const elementB = getElement(b[columnIndex]); + return [...rows].sort((a, b) => { + if (a.isFavorite !== b.isFavorite) { + return a.isFavorite ? -1 : 1; + } - // Avatar contains the prop showFavorite which indicates isFavorite - const favA = a[0]?.props?.children?.props?.showFavorite ?? false; - const favB = b[0]?.props?.children?.props?.showFavorite ?? false; + const aCell = a.cells[columnIndex]; + const bCell = b.cells[columnIndex]; - // Always put favorites at the top - if (favA !== favB) return favA ? -1 : 1; + let aValue: string | number; + let bValue: string | number; - if (sortColumn === "Last Heard") { - const aTimestamp = elementA?.props?.children?.props?.timestamp ?? 0; - const bTimestamp = elementB?.props?.children?.props?.timestamp ?? 0; - if (aTimestamp < bTimestamp) return sortOrder === "asc" ? -1 : 1; - if (aTimestamp > bTimestamp) return sortOrder === "asc" ? 1 : -1; + if (sortColumn === "Connection") { + aValue = numericHops(aCell.sortValue); + bValue = numericHops(bCell.sortValue); + } else { + aValue = aCell.sortValue; + bValue = bCell.sortValue; + } + + if (aValue < bValue) return sortOrder === "asc" ? -1 : 1; + if (aValue > bValue) return sortOrder === "asc" ? 1 : -1; return 0; - } - - if (sortColumn === "Connection") { - const aHopsStr = elementA?.props?.children[0]; - const bHopsStr = elementB?.props?.children[0]; - const aNumHops = numericHops(aHopsStr); - const bNumHops = numericHops(bHopsStr); - if (aNumHops < bNumHops) return sortOrder === "asc" ? -1 : 1; - if (aNumHops > bNumHops) return sortOrder === "asc" ? 1 : -1; - return 0; - } - - const aValue = elementA?.props?.children; - const bValue = elementB?.props?.children; - const valA = aValue ?? ""; - const valB = bValue ?? ""; - - // Ensure consistent comparison for potentially different types - const compareA = typeof valA === "string" || typeof valA === "number" - ? valA - : String(valA); - const compareB = typeof valB === "string" || typeof valB === "number" - ? valB - : String(valB); - - if (compareA < compareB) return sortOrder === "asc" ? -1 : 1; - if (compareA > compareB) return sortOrder === "asc" ? 1 : -1; - return 0; - }); + }); + }, [rows, sortColumn, sortOrder, headings]); return (
@@ -121,17 +87,15 @@ export const Table = ({ headings, rows }: TableProps) => { - {sortedRows.map((row) => { - const firstCellKey = - (React.isValidElement(row[0]) && row[0].key !== null) - ? String(row[0].key) - : null; - const rowKey = firstCellKey ?? Math.random().toString(); // Use random only as last resort - - const isFavorite = row[0]?.props?.children?.props?.showFavorite ?? - false; - return ( - - {row.map((item, cellIndex) => { - const cellKey = `${rowKey}_${cellIndex}`; - return cellIndex === 0 - ? ( - - ) - : ( - - ); - })} - - ); - })} + {sortedRows.map((row) => ( + + {row.cells.map((cell, cellIndex) => + cellIndex === 0 + ? ( + + ) + : ( + + ) + )} + + ))}
heading.sortable && headingSort(heading.title)} + className={cn( + "py-2 pr-3 text-left", + heading.sortable && + "cursor-pointer hover:brightness-hover active:brightness-press", + )} + onClick={() => heading.sortable && handleSort(heading.title)} onKeyUp={(e) => { - if ( - heading.sortable && (e.key === "Enter" || e.key === " ") - ) { - headingSort(heading.title); + if (heading.sortable && (e.key === "Enter" || e.key === " ")) { + handleSort(heading.title); } }} tabIndex={heading.sortable ? 0 : -1} @@ -153,49 +117,37 @@ export const Table = ({ headings, rows }: TableProps) => {
- {item} - - {item} -
+ {cell.content} + + {cell.content} +
); diff --git a/src/core/hooks/useBrowserFeatureDetection.ts b/src/core/hooks/useBrowserFeatureDetection.ts index 3f2667f2..e792ca1b 100644 --- a/src/core/hooks/useBrowserFeatureDetection.ts +++ b/src/core/hooks/useBrowserFeatureDetection.ts @@ -10,8 +10,8 @@ interface BrowserSupport { export function useBrowserFeatureDetection(): BrowserSupport { const support = useMemo(() => { const features: [BrowserFeature, boolean][] = [ - ["Web Bluetooth", !!navigator?.bluetooth], - ["Web Serial", !!navigator?.serial], + ["Web Bluetooth", !!navigator.bluetooth], + ["Web Serial", !!navigator.serial], [ "Secure Context", globalThis.location.protocol === "https:" || diff --git a/src/core/hooks/useCookie.ts b/src/core/hooks/useCookie.ts index df3d9d82..24e97b01 100644 --- a/src/core/hooks/useCookie.ts +++ b/src/core/hooks/useCookie.ts @@ -1,9 +1,9 @@ -import Cookies, { type CookieAttributes } from "js-cookie"; +import Cookies from "js-cookie"; import { useCallback, useState } from "react"; interface CookieHookResult { value: T | undefined; - setCookie: (value: T, options?: CookieAttributes) => void; + setCookie: (value: T, options?: Cookies.CookieAttributes) => void; removeCookie: () => void; } @@ -22,7 +22,7 @@ function useCookie( }); const setCookie = useCallback( - (value: T, options?: CookieAttributes) => { + (value: T, options?: Cookies.CookieAttributes) => { try { Cookies.set(cookieName, JSON.stringify(value), options); setCookieValue(value); diff --git a/src/core/hooks/usePinnedItems.test.ts b/src/core/hooks/usePinnedItems.test.ts deleted file mode 100644 index 3741ffdb..00000000 --- a/src/core/hooks/usePinnedItems.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { act, renderHook } from "@testing-library/react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { usePinnedItems } from "./usePinnedItems.ts"; - -const mockSetPinnedItems = vi.fn(); -const mockUseLocalStorage = vi.fn(); - -vi.mock("@core/hooks/useLocalStorage.ts", () => ({ - default: (...args) => mockUseLocalStorage(...args), -})); - -describe("usePinnedItems", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("returns default pinnedItems and togglePinnedItem", () => { - mockUseLocalStorage.mockReturnValue([[], mockSetPinnedItems]); - - const { result } = renderHook(() => - usePinnedItems({ storageName: "test-storage" }) - ); - - expect(result.current.pinnedItems).toEqual([]); - expect(typeof result.current.togglePinnedItem).toBe("function"); - }); - - it("adds an item if it's not already pinned", () => { - mockUseLocalStorage.mockReturnValue([["item1"], mockSetPinnedItems]); - - const { result } = renderHook(() => - usePinnedItems({ storageName: "test-storage" }) - ); - - act(() => { - result.current.togglePinnedItem("item2"); - }); - - expect(mockSetPinnedItems).toHaveBeenCalledWith(expect.any(Function)); - - const updater = mockSetPinnedItems.mock.calls[0][0]; - const updated = updater(["item1"]); - - expect(updated).toEqual(["item1", "item2"]); - }); - - it("removes an item if it's already pinned", () => { - mockUseLocalStorage.mockReturnValue([ - ["item1", "item2"], - mockSetPinnedItems, - ]); - - const { result } = renderHook(() => - usePinnedItems({ storageName: "test-storage" }) - ); - - act(() => { - result.current.togglePinnedItem("item1"); - }); - - expect(mockSetPinnedItems).toHaveBeenCalledWith(expect.any(Function)); - - const updater = mockSetPinnedItems.mock.calls[0][0]; - const updated = updater(["item1", "item2"]); - - expect(updated).toEqual(["item2"]); - }); -}); diff --git a/src/core/stores/deviceStore.mock.ts b/src/core/stores/deviceStore.mock.ts new file mode 100644 index 00000000..fb97c9d3 --- /dev/null +++ b/src/core/stores/deviceStore.mock.ts @@ -0,0 +1,82 @@ +import { vi } from "vitest"; +import { type Device } from "./deviceStore.ts"; +import { Protobuf } from "@meshtastic/core"; + +/** + * You can spread this base mock in your tests and override only the + * properties relevant to a specific test case. + * + * @example + * vi.mocked(useDevice).mockReturnValue({ + * ...mockDeviceStore, + * getNode: (nodeNum) => mockNodes.get(nodeNum), + * }); + */ +export const mockDeviceStore: Device = { + id: 0, + status: 5 as const, + channels: new Map(), + config: {} as Protobuf.LocalOnly.LocalConfig, + moduleConfig: {} as Protobuf.LocalOnly.LocalModuleConfig, + workingConfig: [], + workingModuleConfig: [], + hardware: {} as Protobuf.Mesh.MyNodeInfo, + metadata: new Map(), + traceroutes: new Map(), + nodeErrors: new Map(), + connection: undefined, + activeNode: 0, + waypoints: [], + pendingSettingsChanges: false, + messageDraft: "", + unreadCounts: new Map(), + nodesMap: new Map(), + dialog: { + import: false, + QR: false, + shutdown: false, + reboot: false, + rebootOTA: false, + deviceName: false, + nodeRemoval: false, + pkiBackup: false, + nodeDetails: false, + unsafeRoles: false, + refreshKeys: false, + deleteMessages: false, + }, + setStatus: vi.fn(), + setConfig: vi.fn(), + setModuleConfig: vi.fn(), + setWorkingConfig: vi.fn(), + setWorkingModuleConfig: vi.fn(), + setHardware: vi.fn(), + setActiveNode: vi.fn(), + setPendingSettingsChanges: vi.fn(), + addChannel: vi.fn(), + addWaypoint: vi.fn(), + addNodeInfo: vi.fn(), + addUser: vi.fn(), + addPosition: vi.fn(), + addConnection: vi.fn(), + addTraceRoute: vi.fn(), + addMetadata: vi.fn(), + removeNode: vi.fn(), + setDialogOpen: vi.fn(), + getDialogOpen: vi.fn().mockReturnValue(false), + processPacket: vi.fn(), + setMessageDraft: vi.fn(), + setNodeError: vi.fn(), + clearNodeError: vi.fn(), + getNodeError: vi.fn().mockReturnValue(undefined), + hasNodeError: vi.fn().mockReturnValue(false), + incrementUnread: vi.fn(), + resetUnread: vi.fn(), + getNodes: vi.fn().mockReturnValue([]), + getNodesLength: vi.fn().mockReturnValue(0), + getNode: vi.fn().mockReturnValue(undefined), + getMyNode: vi.fn(), + sendAdminMessage: vi.fn(), + updateFavorite: vi.fn(), + updateIgnored: vi.fn(), +}; diff --git a/src/core/stores/deviceStore.ts b/src/core/stores/deviceStore.ts index d8b7b03d..488a5d5d 100644 --- a/src/core/stores/deviceStore.ts +++ b/src/core/stores/deviceStore.ts @@ -114,7 +114,7 @@ export const useDeviceStore = createStore((set, get) => ({ addDevice: (id: number) => { set( - produce((draft) => { + produce((draft) => { draft.devices.set(id, { id, status: Types.DeviceStatusEnum.DeviceDisconnected, @@ -151,7 +151,7 @@ export const useDeviceStore = createStore((set, get) => ({ setStatus: (status: Types.DeviceStatusEnum) => { set( - produce((draft) => { + produce((draft) => { const device = draft.devices.get(id); if (device) { device.status = status; @@ -161,7 +161,7 @@ export const useDeviceStore = createStore((set, get) => ({ }, setConfig: (config: Protobuf.Config.Config) => { set( - produce((draft) => { + produce((draft) => { const device = draft.devices.get(id); if (device) { switch (config.payloadVariant.case) { @@ -203,7 +203,7 @@ export const useDeviceStore = createStore((set, get) => ({ }, setModuleConfig: (config: Protobuf.ModuleConfig.ModuleConfig) => { set( - produce((draft) => { + produce((draft) => { const device = draft.devices.get(id); if (device) { switch (config.payloadVariant.case) { @@ -271,7 +271,7 @@ export const useDeviceStore = createStore((set, get) => ({ }, setWorkingConfig: (config: Protobuf.Config.Config) => { set( - produce((draft) => { + produce((draft) => { const device = draft.devices.get(id); if (!device) return; const index = device.workingConfig.findIndex( @@ -289,7 +289,7 @@ export const useDeviceStore = createStore((set, get) => ({ moduleConfig: Protobuf.ModuleConfig.ModuleConfig, ) => { set( - produce((draft) => { + produce((draft) => { const device = draft.devices.get(id); if (!device) return; const index = device.workingModuleConfig.findIndex( @@ -307,7 +307,7 @@ export const useDeviceStore = createStore((set, get) => ({ }, setHardware: (hardware: Protobuf.Mesh.MyNodeInfo) => { set( - produce((draft) => { + produce((draft) => { const device = draft.devices.get(id); if (device) { device.hardware = hardware; @@ -317,7 +317,7 @@ export const useDeviceStore = createStore((set, get) => ({ }, setPendingSettingsChanges: (state) => { set( - produce((draft) => { + produce((draft) => { const device = draft.devices.get(id); if (device) { device.pendingSettingsChanges = state; @@ -327,7 +327,7 @@ export const useDeviceStore = createStore((set, get) => ({ }, addChannel: (channel: Protobuf.Channel.Channel) => { set( - produce((draft) => { + produce((draft) => { const device = draft.devices.get(id); if (device) { device.channels.set(channel.index, channel); @@ -337,7 +337,7 @@ export const useDeviceStore = createStore((set, get) => ({ }, addWaypoint: (waypoint: Protobuf.Mesh.Waypoint) => { set( - produce((draft) => { + produce((draft) => { const device = draft.devices.get(id); if (device) { const index = device.waypoints.findIndex((wp) => @@ -354,7 +354,7 @@ export const useDeviceStore = createStore((set, get) => ({ }, addNodeInfo: (nodeInfo) => { set( - produce((draft) => { + produce((draft) => { const device = draft.devices.get(id); if (!device) return; @@ -364,7 +364,7 @@ export const useDeviceStore = createStore((set, get) => ({ }, setActiveNode: (node) => { set( - produce((draft) => { + produce((draft) => { const device = draft.devices.get(id); if (device) { device.activeNode = node; @@ -374,7 +374,7 @@ export const useDeviceStore = createStore((set, get) => ({ }, addUser: (user) => { set( - produce((draft) => { + produce((draft) => { const device = draft.devices.get(id); if (!device) { return; @@ -389,7 +389,7 @@ export const useDeviceStore = createStore((set, get) => ({ }, addPosition: (position) => { set( - produce((draft) => { + produce((draft) => { const device = draft.devices.get(id); if (!device) { return; @@ -404,7 +404,7 @@ export const useDeviceStore = createStore((set, get) => ({ }, addConnection: (connection) => { set( - produce((draft) => { + produce((draft) => { const device = draft.devices.get(id); if (device) { device.connection = connection; @@ -414,7 +414,7 @@ export const useDeviceStore = createStore((set, get) => ({ }, addMetadata: (from, metadata) => { set( - produce((draft) => { + produce((draft) => { const device = draft.devices.get(id); if (device) { device.metadata.set(from, metadata); @@ -424,7 +424,7 @@ export const useDeviceStore = createStore((set, get) => ({ }, addTraceRoute: (traceroute) => { set( - produce((draft) => { + produce((draft) => { const device = draft.devices.get(id); if (!device) return; const routes = device.traceroutes.get(traceroute.from) ?? []; @@ -433,9 +433,9 @@ export const useDeviceStore = createStore((set, get) => ({ }), ); }, - removeNode: (nodeNum) => { + removeNode: (nodeNum: number) => { set( - produce((draft) => { + produce((draft) => { const device = draft.devices.get(id); if (!device) { return; @@ -446,7 +446,7 @@ export const useDeviceStore = createStore((set, get) => ({ }, setDialogOpen: (dialog: DialogVariant, open: boolean) => { set( - produce((draft) => { + produce((draft) => { const device = draft.devices.get(id); if (device) { device.dialog[dialog] = open; @@ -461,7 +461,7 @@ export const useDeviceStore = createStore((set, get) => ({ }, processPacket(data: ProcessPacketParams) { set( - produce((draft) => { + produce((draft) => { const device = draft.devices.get(id); if (!device) return; const node = device.nodesMap.get(data.from); @@ -484,7 +484,7 @@ export const useDeviceStore = createStore((set, get) => ({ }, setMessageDraft: (message: string) => { set( - produce((draft) => { + produce((draft) => { const device = draft.devices.get(id); if (device) { device.messageDraft = message; @@ -492,9 +492,9 @@ export const useDeviceStore = createStore((set, get) => ({ }), ); }, - setNodeError: (nodeNum, error) => { + setNodeError: (nodeNum: number, error: string) => { set( - produce((draft) => { + produce((draft) => { const device = draft.devices.get(id); if (device) { device.nodeErrors.set(nodeNum, { node: nodeNum, error }); @@ -504,7 +504,7 @@ export const useDeviceStore = createStore((set, get) => ({ }, clearNodeError: (nodeNum: number) => { set( - produce((draft) => { + produce((draft) => { const device = draft.devices.get(id); if (device) { device.nodeErrors.delete(nodeNum); @@ -524,7 +524,7 @@ export const useDeviceStore = createStore((set, get) => ({ }, incrementUnread: (nodeNum: number) => { set( - produce((draft) => { + produce((draft) => { const device = draft.devices.get(id); if (!device) return; const currentCount = device.unreadCounts.get(nodeNum) ?? 0; @@ -534,7 +534,7 @@ export const useDeviceStore = createStore((set, get) => ({ }, resetUnread: (nodeNum: number) => { set( - produce((draft) => { + produce((draft) => { const device = draft.devices.get(id); if (!device) return; device.unreadCounts.set(nodeNum, 0); @@ -610,10 +610,12 @@ export const useDeviceStore = createStore((set, get) => ({ })); set( - produce((draft) => { + produce((draft) => { const device = draft.devices.get(id); const node = device?.nodesMap.get(nodeNum); - node.isFavorite = isFavorite; + if (node) { + node.isFavorite = isFavorite; + } }), ); }, @@ -631,10 +633,12 @@ export const useDeviceStore = createStore((set, get) => ({ })); set( - produce((draft) => { + produce((draft) => { const device = draft.devices.get(id); const node = device?.nodesMap.get(nodeNum); - node.isIgnored = isIgnored; + if (node) { + node.isIgnored = isIgnored; + } }), ); }, @@ -651,7 +655,7 @@ export const useDeviceStore = createStore((set, get) => ({ removeDevice: (id) => { set( - produce((draft) => { + produce((draft) => { draft.devices.delete(id); }), ); diff --git a/src/core/stores/storage/indexDB.ts b/src/core/stores/storage/indexDB.ts index d7d89342..ce2c2790 100644 --- a/src/core/stores/storage/indexDB.ts +++ b/src/core/stores/storage/indexDB.ts @@ -1,4 +1,4 @@ -import { PersistStorage, StateStorage } from "zustand/middleware"; +import { PersistStorage, StateStorage, StorageValue } from "zustand/middleware"; import { del, get, set } from "idb-keyval"; import { ChannelId, MessageLogMap } from "@core/stores/messageStore/types.ts"; @@ -56,18 +56,25 @@ const reviver: JsonReviver = (_, value) => { }; export const storageWithMapSupport: PersistStorage = { - getItem: async (name): Promise => { + getItem: async ( + name, + ): Promise | null> => { const str = await zustandIndexDBStorage.getItem(name); if (!str) return null; try { - const parsed = JSON.parse(str, reviver) as PersistedMessageState; + const parsed = JSON.parse(str, reviver) as StorageValue< + PersistedMessageState + >; return parsed; } catch (error) { console.error(`Error parsing persisted state (${name}):`, error); return null; } }, - setItem: async (name, newValue: PersistedMessageState): Promise => { + setItem: async ( + name, + newValue: StorageValue, + ): Promise => { try { const str = JSON.stringify(newValue, replacer); await zustandIndexDBStorage.setItem(name, str); diff --git a/src/core/utils/eventBus.test.ts b/src/core/utils/eventBus.test.ts index 6d2fc6ee..2d874066 100644 --- a/src/core/utils/eventBus.test.ts +++ b/src/core/utils/eventBus.test.ts @@ -4,7 +4,7 @@ import { eventBus } from "@core/utils/eventBus.ts"; describe("EventBus", () => { beforeEach(() => { // Reset event listeners before each test - eventBus.listeners = {}; + eventBus.offAll(); }); it("should register an event listener and trigger it on emit", () => { diff --git a/src/core/utils/eventBus.ts b/src/core/utils/eventBus.ts index 618ab5a6..75c48d8c 100644 --- a/src/core/utils/eventBus.ts +++ b/src/core/utils/eventBus.ts @@ -34,6 +34,14 @@ class EventBus { } } + public offAll(event?: T): void { + if (event) { + this.listeners[event] = []; + } else { + this.listeners = {}; + } + } + public emit(event: T, data: EventMap[T]): void { if (!this.listeners[event]) return; diff --git a/src/core/utils/ip.test.ts b/src/core/utils/ip.test.ts index 2da62e57..6208e6d3 100644 --- a/src/core/utils/ip.test.ts +++ b/src/core/utils/ip.test.ts @@ -60,7 +60,7 @@ describe("IP Address Conversion Functions", () => { for (const ip of testIps) { const int = convertIpAddressToInt(ip); expect(int).not.toBeNull(); - if (int !== null) { + if (int !== null && typeof int === "number") { const convertedBack = convertIntToIpAddress(int); expect(convertedBack).toBe(ip); } diff --git a/src/core/utils/sort.ts b/src/core/utils/sort.ts new file mode 100644 index 00000000..2d1494f0 --- /dev/null +++ b/src/core/utils/sort.ts @@ -0,0 +1,18 @@ +export function intlSort( + arr: T[], + order: "asc" | "desc" = "asc", + locale: Intl.Locale, +): T[] { + const collator = new Intl.Collator(locale, { sensitivity: "base" }); + + return arr.sort((a, b) => { + const stringA = String(a); + const stringB = String(b); + + if (order === "asc") { + return collator.compare(stringA, stringB); + } else { + return collator.compare(stringB, stringA); + } + }); +} diff --git a/src/core/utils/test.tsx b/src/core/utils/test.tsx deleted file mode 100644 index cb9906ae..00000000 --- a/src/core/utils/test.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import { - createMemoryHistory, - createRouter, - Outlet, - RootRoute, - Route, - RouterProvider, -} from "@tanstack/react-router"; -import { render as rtlRender, RenderOptions } from "@testing-library/react"; -import type { FunctionComponent, ReactElement, ReactNode } from "react"; - -// a root route for the test router. -const rootRoute = new RootRoute({ - component: () => ( - <> - - - ), -}); - -interface CustomRenderOptions extends Omit { - initialEntries?: string[]; - ui?: ReactElement; -} - -let currentRouter: ReturnType | null = null; - -/** - * Custom render function for testing components that need TanStack Router context. - * @param ui The main ReactElement to render (your component under test). - * @param options Custom render options including initialEntries for the router. - * @returns An object containing the testing-library render result and the router instance. - */ -const customRender = ( - ui: ReactElement, - options: CustomRenderOptions = {}, -) => { - const { initialEntries = ["/"], ...renderOptions } = options; - - // A specific route that renders the component under test (ui). - // It defaults to the first path in initialEntries or '/'. - const testComponentRoute = new Route({ - getParentRoute: () => rootRoute, - path: initialEntries[0] || "/", - component: () => ui, // The component passed to render will be the element for this route - }); - - const routeTree = rootRoute.addChildren([testComponentRoute]); - - const router = createRouter({ - history: createMemoryHistory({ initialEntries }), - routeTree, - // You can add default error components or other router options if needed for tests. - // defaultErrorComponent: ({ error }) =>
Test Error: {error.message}
, - }); - - currentRouter = router; // Store the router instance for access in tests - - const Wrapper: FunctionComponent<{ children?: ReactNode }> = ( - { children }, - ) => { - return ( - <> - - {children} - - ); - }; - - const renderResult = rtlRender(ui, { wrapper: Wrapper, ...renderOptions }); - - return { - ...renderResult, - router, - }; -}; - -export * from "@testing-library/react"; -export { customRender as render }; -export const getTestRouter = () => currentRouter; diff --git a/src/i18n/config.ts b/src/i18n/config.ts index 4a43392f..f203dab3 100644 --- a/src/i18n/config.ts +++ b/src/i18n/config.ts @@ -35,7 +35,7 @@ i18next "default": ["en"], }, fallbackNS: ["common", "ui", "dialog"], - debug: import.meta.env.DEV, + debug: import.meta.env.MODE === "development", supportedLngs: supportedLanguages?.map((lang) => lang.code), ns: [ "channels", diff --git a/src/i18n/locales/en/commandPalette.json b/src/i18n/locales/en/commandPalette.json index 7b82e97b..1eed8987 100644 --- a/src/i18n/locales/en/commandPalette.json +++ b/src/i18n/locales/en/commandPalette.json @@ -1,7 +1,7 @@ { "emptyState": "No results found.", "page": { - "title": "Command Palette" + "title": "Command Menu" }, "pinGroup": { "label": "Pin command group" diff --git a/src/i18n/locales/en/ui.json b/src/i18n/locales/en/ui.json index 6f0b6aa3..7e2e4708 100644 --- a/src/i18n/locales/en/ui.json +++ b/src/i18n/locales/en/ui.json @@ -80,6 +80,12 @@ }, "showPassword": { "label": "Show password" + }, + "deliveryStatus": { + "delivered": "Delivered", + "failed": "Delivery Failed", + "waiting": "Waiting", + "unknown": "Unknown" } }, "general": { diff --git a/src/index.css b/src/index.css index 1ae593b4..56103edb 100644 --- a/src/index.css +++ b/src/index.css @@ -3,6 +3,10 @@ @custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *)); +@view-transition { + navigation: auto; +} + @theme { --font-mono: Cascadia Code, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, diff --git a/src/pages/Dashboard/index.tsx b/src/pages/Dashboard/index.tsx index 69cfa2e2..6afc4546 100644 --- a/src/pages/Dashboard/index.tsx +++ b/src/pages/Dashboard/index.tsx @@ -4,14 +4,7 @@ import { useDeviceStore } from "@core/stores/deviceStore.ts"; import { Button } from "@components/UI/Button.tsx"; import { Separator } from "@components/UI/Seperator.tsx"; import { Subtle } from "@components/UI/Typography/Subtle.tsx"; -import { - BluetoothIcon, - ListPlusIcon, - NetworkIcon, - PlusIcon, - UsbIcon, - UsersIcon, -} from "lucide-react"; +import { ListPlusIcon, PlusIcon, UsersIcon } from "lucide-react"; import { useMemo } from "react"; import { useTranslation } from "react-i18next"; import LanguageSwitcher from "@components/LanguageSwitcher.tsx"; @@ -60,32 +53,6 @@ export const Dashboard = () => { ?.longName ?? t("unknown.shortName")}

-
- {device.connection?.connType === "ble" && ( - <> - - {t( - "dashboard.connectionType_ble", - )} - - )} - {device.connection?.connType === "serial" && ( - <> - - {t( - "dashboard.connectionType_serial", - )} - - )} - {device.connection?.connType === "http" && ( - <> - - {t( - "dashboard.connectionType_network", - )} - - )} -
{ {t("dashboard.noDevicesTitle")} - {/* */} {t("dashboard.noDevicesDescription")} diff --git a/src/pages/Messages.test.tsx b/src/pages/Messages.test.tsx deleted file mode 100644 index 46fc0115..00000000 --- a/src/pages/Messages.test.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { fireEvent, render, screen } from "@testing-library/react"; -import { MessagesPage } from "./Messages.tsx"; -import { useDevice } from "../core/stores/deviceStore.ts"; -import { Protobuf } from "@meshtastic/core"; - -vi.mock("../core/stores/deviceStore", () => ({ - useDevice: vi.fn(), -})); - -const mockUseDevice = { - channels: new Map([ - [0, { - index: 0, - settings: { name: "Primary" }, - role: Protobuf.Channel.Channel_Role.PRIMARY, - }], - ]), - nodes: new Map([ - [0, { - num: 0, - user: { longName: "Test Node 0", shortName: "TN0", publicKey: "0000" }, - }], - [1111, { - num: 1111, - user: { longName: "Test Node 1", shortName: "TN1", publicKey: "12345" }, - }], - [2222, { - num: 2222, - user: { longName: "Test Node 2", shortName: "TN2", publicKey: "67890" }, - }], - [3333, { - num: 3333, - user: { longName: "Test Node 3", shortName: "TN3", publicKey: "11111" }, - }], - ]), - hardware: { myNodeNum: 1 }, - messages: { broadcast: new Map(), direct: new Map() }, - metadata: new Map(), - unreadCounts: new Map([[1111, 3], [2222, 10]]), - resetUnread: vi.fn(), - hasNodeError: vi.fn(), -}; - -describe.skip("Messages Page", () => { - beforeEach(() => { - vi.mocked(useDevice).mockReturnValue(mockUseDevice); - }); - - it("sorts unreads to the top", () => { - render(); - const buttonOrder = screen.getAllByRole("button").filter((b) => - b.textContent.includes("Test Node") - ); - expect(buttonOrder[0].textContent).toContain("TN2Test Node 210"); - expect(buttonOrder[1].textContent).toContain("TN1Test Node 13"); - expect(buttonOrder[2].textContent).toContain("TN0Test Node 0"); - expect(buttonOrder[3].textContent).toContain("TN3Test Node 3"); - }); - - it("updates unread when active chat changes", () => { - render(); - const nodeButton = - screen.getAllByRole("button").filter((b) => - b.textContent.includes("TN1Test Node 13") - )[0]; - fireEvent.click(nodeButton); - expect(mockUseDevice.resetUnread).toHaveBeenCalledWith(1111, 0); - }); - - it("does not update the incorrect node", () => { - render(); - const nodeButton = - screen.getAllByRole("button").filter((b) => - b.textContent.includes("TN1Test Node 1") - )[0]; - fireEvent.click(nodeButton); - expect(mockUseDevice.resetUnread).toHaveBeenCalledWith(1111, 0); - expect(mockUseDevice.unreadCounts.get(2222)).toBe(10); - }); -}); diff --git a/src/pages/Messages.tsx b/src/pages/Messages.tsx index 7e58d9ab..6a13fb35 100644 --- a/src/pages/Messages.tsx +++ b/src/pages/Messages.tsx @@ -28,9 +28,19 @@ import { Input } from "@components/UI/Input.tsx"; import { randId } from "@core/utils/randId.ts"; import { useTranslation } from "react-i18next"; import { useNavigate, useParams } from "@tanstack/react-router"; +import { messagesWithParamsRoute } from "@app/routes.tsx"; type NodeInfoWithUnread = Protobuf.Mesh.NodeInfo & { unreadCount: number }; +function SelectMessageChat() { + const { t } = useTranslation("messages"); + return ( +
+ {t("selectChatPrompt.text", { ns: "messages" })} +
+ ); +} + export const MessagesPage = () => { const { channels, @@ -46,7 +56,9 @@ export const MessagesPage = () => { getMessages, setMessageState, } = useMessageStore(); - const params = useParams({ from: "", shouldThrow: false }); + + const { type, chatId } = useParams({ from: messagesWithParamsRoute.id }); + const navigate = useNavigate(); const { toast } = useToast(); const { isCollapsed } = useSidebar(); @@ -54,35 +66,34 @@ export const MessagesPage = () => { const { t } = useTranslation(["messages", "channels", "ui"]); const deferredSearch = useDeferredValue(searchTerm); - const chatType = params.type === "direct" + const navigateToChat = useCallback((type: MessageType, id: string) => { + const typeParam = type === MessageType.Direct ? "direct" : "broadcast"; + navigate({ to: `/messages/${typeParam}/${id}` }); + }, [navigate]); + + const chatType = type === "direct" ? MessageType.Direct - : params.type === "broadcast" - ? MessageType.Broadcast - : undefined; - const activeChat = params.chatId ? Number(params.chatId) : undefined; + : MessageType.Broadcast; + const numericChatId = Number(chatId); const allChannels = Array.from(channels.values()); const filteredChannels = allChannels.filter( (ch) => ch.role !== Protobuf.Channel.Channel_Role.DISABLED, ); - const currentChannel = channels.get(activeChat); - const otherNode = getNode(activeChat); + + useEffect(() => { + if (!type && !chatId && filteredChannels.length > 0) { + const defaultChannel = filteredChannels[0]; + navigateToChat(MessageType.Broadcast, defaultChannel.index.toString()); + } + }, [type, chatId, filteredChannels, navigateToChat]); + + const currentChannel = channels.get(numericChatId); + const otherNode = getNode(numericChatId); const isDirect = chatType === MessageType.Direct; const isBroadcast = chatType === MessageType.Broadcast; - const navigateToChat = useCallback((type: MessageType, chatId: number) => { - const typeParam = type === MessageType.Direct ? "direct" : "broadcast"; - navigate({ to: `/messages/${typeParam}/${chatId}` }); - }, [navigate]); - - useEffect(() => { - if (!params.type && !params.chatId && filteredChannels.length > 0) { - const defaultChannel = filteredChannels[0]; - navigateToChat(MessageType.Broadcast, defaultChannel.index); - } - }, [params.type, params.chatId, filteredChannels, navigateToChat]); - const filteredNodes = (): NodeInfoWithUnread[] => { const lowerCaseSearchTerm = deferredSearch.toLowerCase(); @@ -104,12 +115,8 @@ export const MessagesPage = () => { }; const sendText = useCallback(async (message: string) => { - const isDirect = chatType === MessageType.Direct; - const toValue = isDirect ? activeChat : MessageType.Broadcast; - - const channelValue = isDirect - ? Types.ChannelNumber.Primary - : activeChat ?? 0; + const toValue = isDirect ? numericChatId : MessageType.Broadcast; + const channelValue = isDirect ? Types.ChannelNumber.Primary : numericChatId; let messageId: number | undefined; @@ -123,16 +130,16 @@ export const MessagesPage = () => { if (messageId !== undefined) { if (chatType === MessageType.Broadcast) { setMessageState({ - type: chatType, + type: MessageType.Broadcast, channelId: channelValue, messageId, newState: MessageState.Ack, }); } else { setMessageState({ - type: chatType, + type: MessageType.Direct, nodeA: getMyNodeNum(), - nodeB: activeChat, + nodeB: numericChatId, messageId, newState: MessageState.Ack, }); @@ -145,23 +152,29 @@ export const MessagesPage = () => { const failedId = messageId ?? randId(); if (chatType === MessageType.Broadcast) { setMessageState({ - type: chatType, + type: MessageType.Broadcast, channelId: channelValue, messageId: failedId, newState: MessageState.Failed, }); - } else { // MessageType.Direct - const failedId = messageId ?? randId(); + } else { setMessageState({ - type: chatType, + type: MessageType.Direct, nodeA: getMyNodeNum(), - nodeB: activeChat, + nodeB: numericChatId, messageId: failedId, newState: MessageState.Failed, }); } } - }, [activeChat, chatType, connection, getMyNodeNum, setMessageState]); + }, [ + numericChatId, + chatId, + chatType, + connection, + getMyNodeNum, + setMessageState, + ]); const renderChatContent = () => { switch (chatType) { @@ -170,7 +183,7 @@ export const MessagesPage = () => { ); @@ -180,16 +193,12 @@ export const MessagesPage = () => { messages={getMessages({ type: MessageType.Direct, nodeA: getMyNodeNum(), - nodeB: activeChat, + nodeB: numericChatId, }).reverse()} /> ); default: - return ( -
- {t("selectChatPrompt.text", { ns: "messages" })} -
- ); + return ; } }; @@ -210,10 +219,10 @@ export const MessagesPage = () => { index: channel.index, ns: "channels", }))} - active={activeChat === channel.index && + active={numericChatId === channel.index && chatType === MessageType.Broadcast} onClick={() => { - navigateToChat(MessageType.Broadcast, channel.index); + navigateToChat(MessageType.Broadcast, channel.index.toString()); resetUnread(channel.index); }} > @@ -228,11 +237,12 @@ export const MessagesPage = () => { ), [ filteredChannels, unreadCounts, - activeChat, + numericChatId, chatType, isCollapsed, navigateToChat, resetUnread, + t, ]); const rightSidebar = useMemo( @@ -262,10 +272,10 @@ export const MessagesPage = () => { label={node.user?.longName ?? t("unknown.shortName")} count={node.unreadCount > 0 ? node.unreadCount : undefined} - active={activeChat === node.num && + active={numericChatId === node.num && chatType === MessageType.Direct} onClick={() => { - navigateToChat(MessageType.Direct, node.num); + navigateToChat(MessageType.Direct, node.num.toString()); resetUnread(node.num); }} > @@ -285,11 +295,12 @@ export const MessagesPage = () => { [ filteredNodes, searchTerm, - activeChat, + numericChatId, chatType, navigateToChat, resetUnread, hasNodeError, + t, ], ); @@ -330,7 +341,7 @@ export const MessagesPage = () => { {(isBroadcast || isDirect) ? ( diff --git a/src/pages/Nodes.tsx b/src/pages/Nodes/index.tsx similarity index 54% rename from src/pages/Nodes.tsx rename to src/pages/Nodes/index.tsx index c349cde3..8400ca2a 100644 --- a/src/pages/Nodes.tsx +++ b/src/pages/Nodes/index.tsx @@ -3,7 +3,11 @@ import { TracerouteResponseDialog } from "@app/components/Dialog/TracerouteRespo import { Sidebar } from "@components/Sidebar.tsx"; import { Avatar } from "@components/UI/Avatar.tsx"; import { Mono } from "@components/generic/Mono.tsx"; -import { Table } from "@components/generic/Table/index.tsx"; +import { + type DataRow, + type Heading, + Table, +} from "@components/generic/Table/index.tsx"; import { TimeAgo } from "@components/generic/TimeAgo.tsx"; import { useDevice } from "@core/stores/deviceStore.ts"; import { useAppStore } from "@core/stores/appStore.ts"; @@ -93,6 +97,123 @@ const NodesPage = (): JSX.Element => { setDialogOpen("nodeDetails", true); } + const tableHeadings: Heading[] = [ + { title: "", sortable: false }, + { title: t("nodesTable.headings.longName"), sortable: true }, + { title: t("nodesTable.headings.connection"), sortable: true }, + { title: t("nodesTable.headings.lastHeard"), sortable: true }, + { title: t("nodesTable.headings.encryption"), sortable: false }, + { title: t("unit.snr"), sortable: true }, + { title: t("nodesTable.headings.model"), sortable: true }, + { title: t("nodesTable.headings.macAddress"), sortable: true }, + ]; + + const tableRows: DataRow[] = filteredNodes.map((node) => { + const macAddress = base16 + .stringify(node.user?.macaddr ?? []) + .match(/.{1,2}/g) + ?.join(":") ?? t("unknown.shortName"); + + return { + id: node.num, + isFavorite: node.isFavorite, + cells: [ + { + content: ( + + ), + sortValue: node.user?.shortName ?? "", // Non-sortable column + }, + { + content: ( +

handleNodeInfoDialog(node.num)} + onKeyUp={(evt) => { + evt.key === "Enter" && handleNodeInfoDialog(node.num); + }} + className="cursor-pointer underline ml-2 whitespace-break-spaces" + tabIndex={0} + role="button" + > + {node.user?.longName ?? numberToHexUnpadded(node.num)} +

+ ), + sortValue: node.user?.longName ?? numberToHexUnpadded(node.num), + }, + { + content: ( + + {node.hopsAway !== undefined + ? node?.viaMqtt === false && node.hopsAway === 0 + ? t("nodesTable.connectionStatus.direct") + : `${node.hopsAway?.toString()} ${ + node.hopsAway ?? 0 > 1 + ? t("unit.hop.plural") + : t("unit.hops_one") + } ${t("nodesTable.connectionStatus.away")}` + : t("nodesTable.connectionStatus.unknown")} + {node?.viaMqtt === true + ? t("nodesTable.connectionStatus.viaMqtt") + : ""} + + ), + sortValue: node.hopsAway ?? Number.MAX_SAFE_INTEGER, + }, + { + content: ( + + {node.lastHeard === 0 + ?

{t("nodesTable.lastHeardStatus.never")}

+ : } +
+ ), + sortValue: node.lastHeard, + }, + { + content: ( + + {node.user?.publicKey && node.user?.publicKey.length > 0 + ? + : } + + ), + sortValue: "", // Non-sortable column + }, + { + content: ( + + {node.snr} + {t("unit.dbm")}/ + {Math.min( + Math.max((node.snr + 10) * 5, 0), + 100, + )}%/{/* Percentage */} + {(node.snr + 10) * 5} + {t("unit.raw")} + + ), + sortValue: node.snr, + }, + { + content: ( + + {Protobuf.Mesh.HardwareModel[node.user?.hwModel ?? 0]} + + ), + sortValue: Protobuf.Mesh.HardwareModel[node.user?.hwModel ?? 0], + }, + { + content: {macAddress}, + sortValue: macAddress, + }, + ], + }; + }); + return ( <> {
[ -
- -
, -

handleNodeInfoDialog(node.num)} - onKeyUp={(evt) => { - evt.key === "Enter" && handleNodeInfoDialog(node.num); - }} - className="cursor-pointer underline ml-2 whitespace-break-spaces" - tabIndex={0} - role="button" - > - {node.user?.longName ?? numberToHexUnpadded(node.num)} -

, - - {node.hopsAway !== undefined - ? node?.viaMqtt === false && node.hopsAway === 0 - ? t("nodesTable.connectionStatus.direct") - : `${node.hopsAway?.toString()} ${ - node.hopsAway ?? 0 > 1 - ? t("unit.hop.plural") - : t("unit.hops_one") - } ${t("nodesTable.connectionStatus.away")}` - : t("nodesTable.connectionStatus.unknown")} - {node?.viaMqtt === true - ? t("nodesTable.connectionStatus.viaMqtt") - : ""} - , - - {node.lastHeard === 0 - ?

{t("nodesTable.lastHeardStatus.never")}

- : } -
, - - {node.user?.publicKey && node.user?.publicKey.length > 0 - ? - : } - , - - {node.snr} - {t("unit.dbm")}/ - {Math.min( - Math.max((node.snr + 10) * 5, 0), - 100, - )}%/{/* Percentage */} - {(node.snr + 10) * 5} - {t("unit.raw")} - , - - {Protobuf.Mesh.HardwareModel[node.user?.hwModel ?? 0]} - , - - {base16 - .stringify(node.user?.macaddr ?? []) - .match(/.{1,2}/g) - ?.join(":") ?? t("unknown.shortName")} - , - ])} + headings={tableHeadings} + rows={tableRows} /> () - -/* ROUTE_MANIFEST_START -{ - "routes": { - "__root__": { - "filePath": "__root.tsx", - "children": [] - } - } -} -ROUTE_MANIFEST_END */ diff --git a/src/routes.tsx b/src/routes.tsx index f936fd12..6cdbb7da 100644 --- a/src/routes.tsx +++ b/src/routes.tsx @@ -4,10 +4,11 @@ import MessagesPage from "@pages/Messages.tsx"; import MapPage from "@pages/Map/index.tsx"; import ConfigPage from "@pages/Config/index.tsx"; import ChannelsPage from "@pages/Channels.tsx"; -import NodesPage from "@pages/Nodes.tsx"; +import NodesPage from "@pages/Nodes/index.tsx"; import { createRootRoute } from "@tanstack/react-router"; import { App } from "./App.tsx"; import { DialogManager } from "@components/Dialog/DialogManager.tsx"; +import { z } from "zod"; const rootRoute = createRootRoute({ component: App, @@ -27,12 +28,42 @@ const messagesRoute = createRoute({ getParentRoute: () => rootRoute, path: "/messages", component: MessagesPage, + beforeLoad: ({ params }) => { + const DEFAULT_CHANNEL = 0; + + if (Object.values(params).length === 0) { + throw redirect({ + to: `/messages/broadcast/${DEFAULT_CHANNEL}`, + replace: true, + }); + } + }, }); -const messagesWithParamsRoute = createRoute({ +const chatIdSchema = z.string().refine((val) => { + const num = Number(val); + if (isNaN(num) || !Number.isInteger(num)) { + return false; + } + + const isChannelId = num >= 0 && num <= 10; + const isNodeId = num >= 1000000000 && num <= 9999999999; + + return isChannelId || isNodeId; +}, { + message: "Chat ID must be a channel (0-10) or a valid node ID.", +}); + +export const messagesWithParamsRoute = createRoute({ getParentRoute: () => rootRoute, path: "/messages/$type/$chatId", component: MessagesPage, + parseParams: (params) => ({ + type: z.enum(["direct", "broadcast"], { + errorMap: () => ({ message: 'Type must be "direct" or "broadcast".' }), + }).parse(params.type), + chatId: chatIdSchema.parse(params.chatId), + }), }); const mapRoute = createRoute({ diff --git a/src/tests/setupTests.ts b/src/tests/setup.ts similarity index 100% rename from src/tests/setupTests.ts rename to src/tests/setup.ts diff --git a/src/tests/test-utils.tsx b/src/tests/test-utils.tsx new file mode 100644 index 00000000..e28d5a11 --- /dev/null +++ b/src/tests/test-utils.tsx @@ -0,0 +1,37 @@ +import { ReactElement } from "react"; +import { render, RenderOptions } from "@testing-library/react"; +import { + createMemoryHistory, + createRouter, + RouterProvider, +} from "@tanstack/react-router"; +import "../i18n/config.ts"; +import { routeTree } from "../routeTree.gen.ts"; + +import { DeviceWrapper } from "@app/DeviceWrapper.tsx"; + +const Providers = () => { + const memoryHistory = createMemoryHistory({ + initialEntries: ["/"], + }); + + const router = createRouter({ + routeTree, + history: memoryHistory, + }); + + return ( + + + + ); +}; + +const renderWithProviders = ( + ui: ReactElement, + options?: Omit, +) => render(ui, { wrapper: Providers, ...options }); + +export * from "@testing-library/react"; + +export { renderWithProviders as render }; diff --git a/vite-env.d.ts b/vite-env.d.ts new file mode 100644 index 00000000..484e03a4 --- /dev/null +++ b/vite-env.d.ts @@ -0,0 +1,11 @@ +/// + +interface ImportMetaEnv { + readonly env: { + readonly VITE_COMMIT_HASH: string; + }; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/vite.config.ts b/vite.config.ts index 05daf0e3..83bba01f 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -32,9 +32,9 @@ export default defineConfig({ targets: [ { src: "src/i18n/locales/**/*", - dest: "src/i18n/locales" - } - ] + dest: "src/i18n/locales", + }, + ], }), ], define: { diff --git a/vitest.config.ts b/vitest.config.ts index 537096e0..ec6d3d71 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -3,6 +3,8 @@ import react from "@vitejs/plugin-react"; import { defineConfig } from "vitest/config"; import { enableMapSet } from "immer"; +import process from "node:process"; + enableMapSet(); export default defineConfig({ plugins: [ @@ -25,6 +27,6 @@ export default defineConfig({ restoreMocks: true, root: path.resolve(process.cwd(), "./src"), include: ["**/*.{test,spec}.{ts,tsx}"], - setupFiles: ["./src/tests/setupTests.ts", "./src/core/utils/test.tsx"], + setupFiles: ["./src/tests/setup.ts"], }, });