diff --git a/interface/package.json b/interface/package.json index 30122c905..84ce39639 100644 --- a/interface/package.json +++ b/interface/package.json @@ -30,7 +30,6 @@ "@mui/material": "^9.1.1", "@table-library/react-table-library": "4.1.15", "alova": "^3.5.1", - "async-validator": "^4.2.5", "etag": "^1.8.1", "jwt-decode": "^4.0.0", "mime-types": "^3.0.2", @@ -38,8 +37,7 @@ "react": "^19.2.7", "react-dom": "^19.2.7", "react-icons": "^5.6.0", - "react-router": "^7.17.0", - "react-toastify": "^11.1.0", + "react-router": "^8.0.1", "typesafe-i18n": "^5.27.1", "typescript": "^6.0.3" }, @@ -47,7 +45,7 @@ "@eslint/js": "^10.0.1", "@preact/preset-vite": "^2.10.5", "@trivago/prettier-plugin-sort-imports": "^6.0.2", - "@types/node": "^25.9.3", + "@types/node": "^26.0.0", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "concurrently": "^10.0.3", @@ -56,9 +54,9 @@ "prettier": "^3.8.4", "rollup-plugin-visualizer": "^7.0.1", "terser": "^5.48.0", - "typescript-eslint": "^8.61.0", + "typescript-eslint": "^8.61.1", "vite": "^8.0.16", "vite-plugin-imagemin": "^0.6.1" }, - "packageManager": "pnpm@11.7.0+sha512.19cc852c120c7125760f2443ee6be0ca5b40f9f50598de1a09a1f177503e010e57c23c77646e01e761de59bf874fb22a3398c33ab9691fc13eb946b6f0f4d620" + "packageManager": "pnpm@11.8.0+sha512.c1f5e7c4cb241c8f174b743851d82f42b802324afc8b0f116b96adb15aa06664948dde36960a3ba1079ba5b4b29dd0140135b94b5b5f5263592249d68e555f26" } diff --git a/interface/pnpm-lock.yaml b/interface/pnpm-lock.yaml index 7bf4e03e2..45dbfc879 100644 --- a/interface/pnpm-lock.yaml +++ b/interface/pnpm-lock.yaml @@ -29,9 +29,6 @@ importers: alova: specifier: ^3.5.1 version: 3.5.1 - async-validator: - specifier: ^4.2.5 - version: 4.2.5 etag: specifier: ^1.8.1 version: 1.8.1 @@ -54,11 +51,8 @@ importers: specifier: ^5.6.0 version: 5.6.0(react@19.2.7) react-router: - specifier: ^7.17.0 - version: 7.17.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - react-toastify: - specifier: ^11.1.0 - version: 11.1.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + specifier: ^8.0.1 + version: 8.0.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) typesafe-i18n: specifier: ^5.27.1 version: 5.27.1(typescript@6.0.3) @@ -71,13 +65,13 @@ importers: version: 10.0.1(eslint@10.5.0) '@preact/preset-vite': specifier: ^2.10.5 - version: 2.10.5(@babel/core@7.29.7)(preact@10.29.2)(vite@8.0.16(@types/node@25.9.3)(terser@5.48.0)) + version: 2.10.5(@babel/core@7.29.7)(preact@10.29.2)(vite@8.0.16(@types/node@26.0.0)(terser@5.48.0)) '@trivago/prettier-plugin-sort-imports': specifier: ^6.0.2 version: 6.0.2(prettier@3.8.4) '@types/node': - specifier: ^25.9.3 - version: 25.9.3 + specifier: ^26.0.0 + version: 26.0.0 '@types/react': specifier: ^19.2.17 version: 19.2.17 @@ -103,14 +97,14 @@ importers: specifier: ^5.48.0 version: 5.48.0 typescript-eslint: - specifier: ^8.61.0 - version: 8.61.0(eslint@10.5.0)(typescript@6.0.3) + specifier: ^8.61.1 + version: 8.61.1(eslint@10.5.0)(typescript@6.0.3) vite: specifier: ^8.0.16 - version: 8.0.16(@types/node@25.9.3)(terser@5.48.0) + version: 8.0.16(@types/node@26.0.0)(terser@5.48.0) vite-plugin-imagemin: specifier: ^0.6.1 - version: 0.6.1(vite@8.0.16(@types/node@25.9.3)(terser@5.48.0)) + version: 0.6.1(vite@8.0.16(@types/node@26.0.0)(terser@5.48.0)) packages: @@ -688,8 +682,8 @@ packages: resolution: {integrity: sha512-zmPitbQ8+6zNutpwgcQuLcsEpn/Cj54Kbn7L5pX0Os5kdWplB7xPgEh/g+SWOB/qmows2gpuCaPyduq8ZZRnxA==} deprecated: This is a stub types definition. minimatch provides its own type definitions, so you do not need this installed. - '@types/node@25.9.3': - resolution: {integrity: sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==} + '@types/node@26.0.0': + resolution: {integrity: sha512-vf2YFi1iY9lHGwNJMs01biZFbKJkrZR1T6/MlzjhJLPdntOHLhTrDSnSVcdtvjihi4VQNlrFRIxLsDBlQpAipA==} '@types/parse-json@4.0.2': resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==} @@ -716,63 +710,63 @@ packages: '@types/svgo@2.6.4': resolution: {integrity: sha512-l4cmyPEckf8moNYHdJ+4wkHvFxjyW6ulm9l4YGaOxeyBWPhBOT0gvni1InpFPdzx1dKf/2s62qGITwxNWnPQng==} - '@typescript-eslint/eslint-plugin@8.61.0': - resolution: {integrity: sha512-bFNvl9ZczlVb+wR2Akszf3gHfKVj/8WanXaGJ3UstTA7brNKg0cNdk6X1Psu5V7MZ2oQtzZKOEzIUehaoxbDGw==} + '@typescript-eslint/eslint-plugin@8.61.1': + resolution: {integrity: sha512-ZPlVl3PB3et/59Ne0fv/sci6ZXz4T4Hp4nTJ56i/Y0gR89ARb+KphojTq6j+56E5PIezmOIOOWyY+aWQFd+IkQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.61.0 + '@typescript-eslint/parser': ^8.61.1 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/parser@8.61.0': - resolution: {integrity: sha512-5B7PfA2e1NQGCnDHd/0lW7W3gvp3d59Ryw54FYO8Uswxo9f6ikw3AZV+Xj/TvpImmpsiYyUqAfhC6kJID1jF6w==} + '@typescript-eslint/parser@8.61.1': + resolution: {integrity: sha512-PJ5vePq5/ognBbrIcoC5+SHO5dfpeLPzP9FpLkzWrguoYQEeeSjlJpVwOpo1JRSTEi7dRcwNy4h4dzV70PqHcg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/project-service@8.61.0': - resolution: {integrity: sha512-DV42F7MLJO6Rax7SK1yg43tcnEfGUrurSpSxKuVX+a3RCTzBlH3fuxprrOJXKCJGAaw82xXocikJ0uQaqwXgGA==} + '@typescript-eslint/project-service@8.61.1': + resolution: {integrity: sha512-PrC4JYGmR241lYnfhmKGTXkFqv8+ymbTFgSAY0fVXpY82/QkMw5TZPl+vGzuDDU2QYJk9fIDOBTntF+yDv9LEA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/scope-manager@8.61.0': - resolution: {integrity: sha512-IWdXFHFSb6mlC3HPc7QsLDm5zYEbUla6trDEHf32D3/dnuUyXd87plScSNXSbm0/RxMvObpI17sv/EDTGrGZkA==} + '@typescript-eslint/scope-manager@8.61.1': + resolution: {integrity: sha512-L2bdIeoQS8FlKAvONAr20w6OcLXeB+qiDKbAooS9A0Ben+iSIkBef0FxqwKWYqt5sa0i4KJtxVyVmhMylKzF5w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/tsconfig-utils@8.61.0': - resolution: {integrity: sha512-O5Amvdv9ztMpxpf+vmFULGG78IE6Qwdr3bCGvqwG4nwc9H2qXkOYJJnRbRHyMkQTjv1d03olqwwwzHLMqpFePQ==} + '@typescript-eslint/tsconfig-utils@8.61.1': + resolution: {integrity: sha512-UN/H4di+OO7EWx2ovME+8t31YO+KVnK0RRKEHR3kOt21/Ay8BOq3M1OMvWs5vNiqcFCYGYoxK3MXPZzmMUE+yg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/type-utils@8.61.0': - resolution: {integrity: sha512-TuBiQYIkd97yBfInHCTKVYMbX4kvEmpOEuixIuzCU9p8BGT1SfyyO0d0IfDMbPIHcjn/hWnusUX5e8v5Xg+X8A==} + '@typescript-eslint/type-utils@8.61.1': + resolution: {integrity: sha512-GYRicKmVK0C4fsKgaACaknOUAq9Oa2kwsjnpFhFcS/5p4Ht5IP9OVLbgIgcK4SRk92nVHFluurg1lumD9dBcLw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/types@8.61.0': - resolution: {integrity: sha512-9QTQpZ5Iin4CdIodfbDQFSeiSJKidgYJYug1P9CC2xWgUTvlmixViqDZNciMjwLBZyJnG4tGmPl97rVAFb1AJg==} + '@typescript-eslint/types@8.61.1': + resolution: {integrity: sha512-G+CRlPqLv7Bz1IZVs03x5K59F1veqL0EJUROAdGhKsEq8qOiRiZbI+HUojPq5l0fEGOKModD9br6lObhB8zkoA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@8.61.0': - resolution: {integrity: sha512-42zatd5qSvvcV1JdDBCLxYRznvP4eIHpPoZXdkPFnAmanA4FuZ5dibSnCBggY8hQnqajPpoGjXFdZ7fIJKQnlA==} + '@typescript-eslint/typescript-estree@8.61.1': + resolution: {integrity: sha512-u+oQD3BqYWPc8YV9Zab4vaJElJuwOLPRc10Jm1o/qS+6Qwen14HCWwx0Seo4LnSn2wxea2Ik8DxPt2/FHmuhrg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/utils@8.61.0': - resolution: {integrity: sha512-3bzFt7ImFMW/jVYwJamDoe/dMOdFLSC6pom6rRjdh4SZJEYupyMzem8e7vKZLclLfpHjlwSAXOUxtKxGXUiLqA==} + '@typescript-eslint/utils@8.61.1': + resolution: {integrity: sha512-1+P/3Dj6jvtybE1q0HQ6yBt/gq+oKJyLdEv4HdnqasaEXRSYCAsD59mXEVQnM/ULNdQxbX77tdG4jPRjIS6knA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/visitor-keys@8.61.0': - resolution: {integrity: sha512-QVLZu3ZPQEE+HICQyAMZ2yLQhxf0meY/wx6Hx14YcTNj13JB3qHlX3lJ02L3fLGHgERRH71kvYDwiXIguT3AjQ==} + '@typescript-eslint/visitor-keys@8.61.1': + resolution: {integrity: sha512-6fJ9MHWtK14C1DSkiMlHUSOmrVebL7150xZJBlJiL62jjhIA4JmOq6flwBgDxIdBKKdoiZRel+dfPD5MLfny3w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} acorn-jsx@5.3.2: @@ -827,9 +821,6 @@ packages: resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} engines: {node: '>=8'} - async-validator@4.2.5: - resolution: {integrity: sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==} - available-typed-arrays@1.0.7: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} @@ -853,8 +844,8 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - baseline-browser-mapping@2.10.37: - resolution: {integrity: sha512-girxaJ7WZssDOFhzCGZTDKoTa1gk6A1TbflaYTpykLJ4UU9Fz9kx1aREM8JCuoVHbL8X8T/mJg7w2oYSq72Oig==} + baseline-browser-mapping@2.10.38: + resolution: {integrity: sha512-31/02mVB4yuQU6adKk5SlY6m+mxDwUq5KZkyYgnLrrKl7TEm1+3PyDtDBz2kOv/wxZz41GHsvV1A/u6RmiyBvw==} engines: {node: '>=6.0.0'} hasBin: true @@ -1024,9 +1015,8 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} - cookie@1.1.1: - resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} - engines: {node: '>=18'} + cookie-es@3.1.1: + resolution: {integrity: sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==} core-util-is@1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} @@ -1185,8 +1175,8 @@ packages: duplexer3@0.1.5: resolution: {integrity: sha512-1A8za6ws41LQgv9HrE/66jyC5yuSjQ3L/KOpFtoBilsAK2iA2wuS5rTt1OCzIvtS2V7nVmedsUU+DGRcjBmOYA==} - electron-to-chromium@1.5.372: - resolution: {integrity: sha512-M3yhbAlilnwqC8D21t28UCDGHyitShTmmLRU/H+b74P6Ski16Nb9HONYEaVpMj/pwC7BEo5B95FpjODLCWbtfA==} + electron-to-chromium@1.5.376: + resolution: {integrity: sha512-cUVA7/RvbFTEuw/i3obUwDTRIXojaxkResf+ibByPFxjc6XK3VNtcQXV0NSbAlJ0FMjcJGgftVVB4Qo184EXvA==} emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} @@ -2146,8 +2136,8 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - nanoid@3.3.12: - resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + nanoid@3.3.13: + resolution: {integrity: sha512-sPdqC6ByMVVGvF1ynvvMo0/o+oD1VX7DaHhijt1bFgjvBkHBib4t49GoNDhf2NDta4oeUNlaGbSt5K7qjZ955Q==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true @@ -2160,8 +2150,8 @@ packages: node-html-parser@6.1.13: resolution: {integrity: sha512-qIsTMOY4C/dAa5Q5vsobRpOOvPfC4pB61UVW2uSwZNUp0QU/jCekTal1vMmbO0DgdHeLUJpv/ARmDqErVxA3Sg==} - node-releases@2.0.47: - resolution: {integrity: sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==} + node-releases@2.0.48: + resolution: {integrity: sha512-1uz8041X6LoI6ZSdZacM9lVY28vuzDlSKitnpbSNK0RfKoIJkX29NBPVEFXhnuSuEOA9Ww0xnPJ+ILWbGAv8DA==} engines: {node: '>=18'} normalize-package-data@2.5.0: @@ -2438,22 +2428,16 @@ packages: react-is@19.2.7: resolution: {integrity: sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==} - react-router@7.17.0: - resolution: {integrity: sha512-FDELK7rTMlCHO5+reyXsPlmfr7N1F91lPHsWYfMEGQm/KQ+F4JFM8jGoeQDmDvdTs93Fw9aSilH+uKRb4/jXvQ==} - engines: {node: '>=20.0.0'} + react-router@8.0.1: + resolution: {integrity: sha512-5EL/fANovVUhRK50NLS8RYfX0BxrimoKsHWUPPy8v5UEl8i6vzF7e4POo3u+AhPItDwccUAJjMfIOmydxBJmQw==} + engines: {node: '>=22.22.0'} peerDependencies: - react: '>=18' - react-dom: '>=18' + react: '>=19.2.7' + react-dom: '>=19.2.7' peerDependenciesMeta: react-dom: optional: true - react-toastify@11.1.0: - resolution: {integrity: sha512-e9h23x3phN0wbFeB6yovmWp7lobzV4CaCH0LO8nVP6H7Y+3GbcLpIzMm9dJhcp1RXbpyfvjgpfXqO80QAmn7sg==} - peerDependencies: - react: ^18 || ^19 - react-dom: ^18 || ^19 - react-transition-group@4.4.5: resolution: {integrity: sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==} peerDependencies: @@ -2582,14 +2566,11 @@ packages: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true - semver@7.8.4: - resolution: {integrity: sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==} + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} engines: {node: '>=10'} hasBin: true - set-cookie-parser@2.7.2: - resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} - set-function-length@1.2.2: resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} engines: {node: '>= 0.4'} @@ -2827,8 +2808,8 @@ packages: peerDependencies: typescript: '>=3.5.1' - typescript-eslint@8.61.0: - resolution: {integrity: sha512-8y31Rd0eGTrDKqhy6vT0HtzhN+YLjQizwX3aA3hPXP/ynSfnrBXcQY5IzsP9/DM7+klX4IUncZZjkchP0z+rUw==} + typescript-eslint@8.61.1: + resolution: {integrity: sha512-V7PayAfJokV3pEHgN7/v03D1SpujhRfQtYLbLIiBfDDncdg4PAiRBfoS4cnCANK4jmAPncczi59QO3afiXUlNw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 @@ -2842,8 +2823,8 @@ packages: unbzip2-stream@1.4.3: resolution: {integrity: sha512-mlExGW4w71ebDJviH16lQLtZS32VKqsSfk80GCfUlwT/4/hNRFsoscrF/c++9xinkMzECL1uL9DDwXqFWkruPg==} - undici-types@7.24.6: - resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + undici-types@8.3.0: + resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} universalify@2.0.1: resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} @@ -3424,19 +3405,19 @@ snapshots: '@popperjs/core@2.11.8': {} - '@preact/preset-vite@2.10.5(@babel/core@7.29.7)(preact@10.29.2)(vite@8.0.16(@types/node@25.9.3)(terser@5.48.0))': + '@preact/preset-vite@2.10.5(@babel/core@7.29.7)(preact@10.29.2)(vite@8.0.16(@types/node@26.0.0)(terser@5.48.0))': dependencies: '@babel/core': 7.29.7 '@babel/plugin-transform-react-jsx': 7.29.7(@babel/core@7.29.7) '@babel/plugin-transform-react-jsx-development': 7.29.7(@babel/core@7.29.7) - '@prefresh/vite': 2.4.12(preact@10.29.2)(vite@8.0.16(@types/node@25.9.3)(terser@5.48.0)) + '@prefresh/vite': 2.4.12(preact@10.29.2)(vite@8.0.16(@types/node@26.0.0)(terser@5.48.0)) '@rollup/pluginutils': 5.4.0 babel-plugin-transform-hook-names: 1.0.2(@babel/core@7.29.7) debug: 4.4.3 magic-string: 0.30.21 picocolors: 1.1.1 - vite: 8.0.16(@types/node@25.9.3)(terser@5.48.0) - vite-prerender-plugin: 0.5.13(vite@8.0.16(@types/node@25.9.3)(terser@5.48.0)) + vite: 8.0.16(@types/node@26.0.0)(terser@5.48.0) + vite-prerender-plugin: 0.5.13(vite@8.0.16(@types/node@26.0.0)(terser@5.48.0)) zimmerframe: 1.1.4 transitivePeerDependencies: - preact @@ -3451,7 +3432,7 @@ snapshots: '@prefresh/utils@1.2.1': {} - '@prefresh/vite@2.4.12(preact@10.29.2)(vite@8.0.16(@types/node@25.9.3)(terser@5.48.0))': + '@prefresh/vite@2.4.12(preact@10.29.2)(vite@8.0.16(@types/node@26.0.0)(terser@5.48.0))': dependencies: '@babel/core': 7.29.7 '@prefresh/babel-plugin': 0.5.3 @@ -3459,7 +3440,7 @@ snapshots: '@prefresh/utils': 1.2.1 '@rollup/pluginutils': 4.2.1 preact: 10.29.2 - vite: 8.0.16(@types/node@25.9.3)(terser@5.48.0) + vite: 8.0.16(@types/node@26.0.0)(terser@5.48.0) transitivePeerDependencies: - supports-color @@ -3562,7 +3543,7 @@ snapshots: '@types/glob@7.2.0': dependencies: '@types/minimatch': 6.0.0 - '@types/node': 25.9.3 + '@types/node': 26.0.0 '@types/imagemin-gifsicle@7.0.4': dependencies: @@ -3591,21 +3572,21 @@ snapshots: '@types/imagemin@7.0.1': dependencies: - '@types/node': 25.9.3 + '@types/node': 26.0.0 '@types/json-schema@7.0.15': {} '@types/keyv@3.1.4': dependencies: - '@types/node': 25.9.3 + '@types/node': 26.0.0 '@types/minimatch@6.0.0': dependencies: minimatch: 10.2.5 - '@types/node@25.9.3': + '@types/node@26.0.0': dependencies: - undici-types: 7.24.6 + undici-types: 8.3.0 '@types/parse-json@4.0.2': {} @@ -3625,20 +3606,20 @@ snapshots: '@types/responselike@1.0.3': dependencies: - '@types/node': 25.9.3 + '@types/node': 26.0.0 '@types/svgo@2.6.4': dependencies: - '@types/node': 25.9.3 + '@types/node': 26.0.0 - '@typescript-eslint/eslint-plugin@8.61.0(@typescript-eslint/parser@8.61.0(eslint@10.5.0)(typescript@6.0.3))(eslint@10.5.0)(typescript@6.0.3)': + '@typescript-eslint/eslint-plugin@8.61.1(@typescript-eslint/parser@8.61.1(eslint@10.5.0)(typescript@6.0.3))(eslint@10.5.0)(typescript@6.0.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.61.0(eslint@10.5.0)(typescript@6.0.3) - '@typescript-eslint/scope-manager': 8.61.0 - '@typescript-eslint/type-utils': 8.61.0(eslint@10.5.0)(typescript@6.0.3) - '@typescript-eslint/utils': 8.61.0(eslint@10.5.0)(typescript@6.0.3) - '@typescript-eslint/visitor-keys': 8.61.0 + '@typescript-eslint/parser': 8.61.1(eslint@10.5.0)(typescript@6.0.3) + '@typescript-eslint/scope-manager': 8.61.1 + '@typescript-eslint/type-utils': 8.61.1(eslint@10.5.0)(typescript@6.0.3) + '@typescript-eslint/utils': 8.61.1(eslint@10.5.0)(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.61.1 eslint: 10.5.0 ignore: 7.0.5 natural-compare: 1.4.0 @@ -3647,41 +3628,41 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.61.0(eslint@10.5.0)(typescript@6.0.3)': + '@typescript-eslint/parser@8.61.1(eslint@10.5.0)(typescript@6.0.3)': dependencies: - '@typescript-eslint/scope-manager': 8.61.0 - '@typescript-eslint/types': 8.61.0 - '@typescript-eslint/typescript-estree': 8.61.0(typescript@6.0.3) - '@typescript-eslint/visitor-keys': 8.61.0 + '@typescript-eslint/scope-manager': 8.61.1 + '@typescript-eslint/types': 8.61.1 + '@typescript-eslint/typescript-estree': 8.61.1(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.61.1 debug: 4.4.3 eslint: 10.5.0 typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.61.0(typescript@6.0.3)': + '@typescript-eslint/project-service@8.61.1(typescript@6.0.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.61.0(typescript@6.0.3) - '@typescript-eslint/types': 8.61.0 + '@typescript-eslint/tsconfig-utils': 8.61.1(typescript@6.0.3) + '@typescript-eslint/types': 8.61.1 debug: 4.4.3 typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@8.61.0': + '@typescript-eslint/scope-manager@8.61.1': dependencies: - '@typescript-eslint/types': 8.61.0 - '@typescript-eslint/visitor-keys': 8.61.0 + '@typescript-eslint/types': 8.61.1 + '@typescript-eslint/visitor-keys': 8.61.1 - '@typescript-eslint/tsconfig-utils@8.61.0(typescript@6.0.3)': + '@typescript-eslint/tsconfig-utils@8.61.1(typescript@6.0.3)': dependencies: typescript: 6.0.3 - '@typescript-eslint/type-utils@8.61.0(eslint@10.5.0)(typescript@6.0.3)': + '@typescript-eslint/type-utils@8.61.1(eslint@10.5.0)(typescript@6.0.3)': dependencies: - '@typescript-eslint/types': 8.61.0 - '@typescript-eslint/typescript-estree': 8.61.0(typescript@6.0.3) - '@typescript-eslint/utils': 8.61.0(eslint@10.5.0)(typescript@6.0.3) + '@typescript-eslint/types': 8.61.1 + '@typescript-eslint/typescript-estree': 8.61.1(typescript@6.0.3) + '@typescript-eslint/utils': 8.61.1(eslint@10.5.0)(typescript@6.0.3) debug: 4.4.3 eslint: 10.5.0 ts-api-utils: 2.5.0(typescript@6.0.3) @@ -3689,37 +3670,37 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/types@8.61.0': {} + '@typescript-eslint/types@8.61.1': {} - '@typescript-eslint/typescript-estree@8.61.0(typescript@6.0.3)': + '@typescript-eslint/typescript-estree@8.61.1(typescript@6.0.3)': dependencies: - '@typescript-eslint/project-service': 8.61.0(typescript@6.0.3) - '@typescript-eslint/tsconfig-utils': 8.61.0(typescript@6.0.3) - '@typescript-eslint/types': 8.61.0 - '@typescript-eslint/visitor-keys': 8.61.0 + '@typescript-eslint/project-service': 8.61.1(typescript@6.0.3) + '@typescript-eslint/tsconfig-utils': 8.61.1(typescript@6.0.3) + '@typescript-eslint/types': 8.61.1 + '@typescript-eslint/visitor-keys': 8.61.1 debug: 4.4.3 minimatch: 10.2.5 - semver: 7.8.4 + semver: 7.8.5 tinyglobby: 0.2.17 ts-api-utils: 2.5.0(typescript@6.0.3) typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.61.0(eslint@10.5.0)(typescript@6.0.3)': + '@typescript-eslint/utils@8.61.1(eslint@10.5.0)(typescript@6.0.3)': dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@10.5.0) - '@typescript-eslint/scope-manager': 8.61.0 - '@typescript-eslint/types': 8.61.0 - '@typescript-eslint/typescript-estree': 8.61.0(typescript@6.0.3) + '@typescript-eslint/scope-manager': 8.61.1 + '@typescript-eslint/types': 8.61.1 + '@typescript-eslint/typescript-estree': 8.61.1(typescript@6.0.3) eslint: 10.5.0 typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/visitor-keys@8.61.0': + '@typescript-eslint/visitor-keys@8.61.1': dependencies: - '@typescript-eslint/types': 8.61.0 + '@typescript-eslint/types': 8.61.1 eslint-visitor-keys: 5.0.1 acorn-jsx@5.3.2(acorn@8.17.0): @@ -3762,8 +3743,6 @@ snapshots: array-union@2.1.0: {} - async-validator@4.2.5: {} - available-typed-arrays@1.0.7: dependencies: possible-typed-array-names: 1.1.0 @@ -3784,7 +3763,7 @@ snapshots: base64-js@1.5.1: {} - baseline-browser-mapping@2.10.37: {} + baseline-browser-mapping@2.10.38: {} bin-build@3.0.0: dependencies: @@ -3845,10 +3824,10 @@ snapshots: browserslist@4.28.2: dependencies: - baseline-browser-mapping: 2.10.37 + baseline-browser-mapping: 2.10.38 caniuse-lite: 1.0.30001799 - electron-to-chromium: 1.5.372 - node-releases: 2.0.47 + electron-to-chromium: 1.5.376 + node-releases: 2.0.48 update-browserslist-db: 1.2.3(browserslist@4.28.2) buffer-alloc-unsafe@1.1.0: {} @@ -3983,7 +3962,7 @@ snapshots: convert-source-map@2.0.0: {} - cookie@1.1.1: {} + cookie-es@3.1.1: {} core-util-is@1.0.3: {} @@ -4202,7 +4181,7 @@ snapshots: duplexer3@0.1.5: {} - electron-to-chromium@1.5.372: {} + electron-to-chromium@1.5.376: {} emoji-regex@10.6.0: {} @@ -5121,7 +5100,7 @@ snapshots: ms@2.1.3: {} - nanoid@3.3.12: {} + nanoid@3.3.13: {} natural-compare@1.4.0: {} @@ -5132,7 +5111,7 @@ snapshots: css-select: 5.2.2 he: 1.2.0 - node-releases@2.0.47: {} + node-releases@2.0.48: {} normalize-package-data@2.5.0: dependencies: @@ -5320,7 +5299,7 @@ snapshots: postcss@8.5.15: dependencies: - nanoid: 3.3.12 + nanoid: 3.3.13 picocolors: 1.1.1 source-map-js: 1.2.1 @@ -5378,20 +5357,13 @@ snapshots: react-is@19.2.7: {} - react-router@7.17.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + react-router@8.0.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7): dependencies: - cookie: 1.1.1 + cookie-es: 3.1.1 react: 19.2.7 - set-cookie-parser: 2.7.2 optionalDependencies: react-dom: 19.2.7(react@19.2.7) - react-toastify@11.1.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7): - dependencies: - clsx: 2.1.1 - react: 19.2.7 - react-dom: 19.2.7(react@19.2.7) - react-transition-group@4.4.5(react-dom@19.2.7(react@19.2.7))(react@19.2.7): dependencies: '@babel/runtime': 7.29.7 @@ -5528,9 +5500,7 @@ snapshots: semver@6.3.1: {} - semver@7.8.4: {} - - set-cookie-parser@2.7.2: {} + semver@7.8.5: {} set-function-length@1.2.2: dependencies: @@ -5753,12 +5723,12 @@ snapshots: dependencies: typescript: 6.0.3 - typescript-eslint@8.61.0(eslint@10.5.0)(typescript@6.0.3): + typescript-eslint@8.61.1(eslint@10.5.0)(typescript@6.0.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.61.0(@typescript-eslint/parser@8.61.0(eslint@10.5.0)(typescript@6.0.3))(eslint@10.5.0)(typescript@6.0.3) - '@typescript-eslint/parser': 8.61.0(eslint@10.5.0)(typescript@6.0.3) - '@typescript-eslint/typescript-estree': 8.61.0(typescript@6.0.3) - '@typescript-eslint/utils': 8.61.0(eslint@10.5.0)(typescript@6.0.3) + '@typescript-eslint/eslint-plugin': 8.61.1(@typescript-eslint/parser@8.61.1(eslint@10.5.0)(typescript@6.0.3))(eslint@10.5.0)(typescript@6.0.3) + '@typescript-eslint/parser': 8.61.1(eslint@10.5.0)(typescript@6.0.3) + '@typescript-eslint/typescript-estree': 8.61.1(typescript@6.0.3) + '@typescript-eslint/utils': 8.61.1(eslint@10.5.0)(typescript@6.0.3) eslint: 10.5.0 typescript: 6.0.3 transitivePeerDependencies: @@ -5771,7 +5741,7 @@ snapshots: buffer: 5.7.1 through: 2.3.8 - undici-types@7.24.6: {} + undici-types@8.3.0: {} universalify@2.0.1: {} @@ -5804,7 +5774,7 @@ snapshots: spdx-correct: 3.2.0 spdx-expression-parse: 3.0.1 - vite-plugin-imagemin@0.6.1(vite@8.0.16(@types/node@25.9.3)(terser@5.48.0)): + vite-plugin-imagemin@0.6.1(vite@8.0.16(@types/node@26.0.0)(terser@5.48.0)): dependencies: '@types/imagemin': 7.0.1 '@types/imagemin-gifsicle': 7.0.4 @@ -5829,11 +5799,11 @@ snapshots: imagemin-webp: 6.1.0 jpegtran-bin: 6.0.1 pathe: 0.2.0 - vite: 8.0.16(@types/node@25.9.3)(terser@5.48.0) + vite: 8.0.16(@types/node@26.0.0)(terser@5.48.0) transitivePeerDependencies: - supports-color - vite-prerender-plugin@0.5.13(vite@8.0.16(@types/node@25.9.3)(terser@5.48.0)): + vite-prerender-plugin@0.5.13(vite@8.0.16(@types/node@26.0.0)(terser@5.48.0)): dependencies: kolorist: 1.8.0 magic-string: 0.30.21 @@ -5841,9 +5811,9 @@ snapshots: simple-code-frame: 1.3.0 source-map: 0.7.6 stack-trace: 1.0.0 - vite: 8.0.16(@types/node@25.9.3)(terser@5.48.0) + vite: 8.0.16(@types/node@26.0.0)(terser@5.48.0) - vite@8.0.16(@types/node@25.9.3)(terser@5.48.0): + vite@8.0.16(@types/node@26.0.0)(terser@5.48.0): dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 @@ -5851,7 +5821,7 @@ snapshots: rolldown: 1.0.3 tinyglobby: 0.2.17 optionalDependencies: - '@types/node': 25.9.3 + '@types/node': 26.0.0 fsevents: 2.3.3 terser: 5.48.0 diff --git a/interface/pnpm-workspace.yaml b/interface/pnpm-workspace.yaml index 613c8cdaf..3626b4c8a 100644 --- a/interface/pnpm-workspace.yaml +++ b/interface/pnpm-workspace.yaml @@ -9,3 +9,4 @@ allowBuilds: minimumReleaseAgeExclude: - '@types/node@25.9.2' - '@types/react@19.2.17' + - react-router@8.0.1 diff --git a/interface/progmem-generator.js b/interface/progmem-generator.js index be5cead21..79f4f4f18 100644 --- a/interface/progmem-generator.js +++ b/interface/progmem-generator.js @@ -46,6 +46,38 @@ ${fileInfo.map((f) => `${INDENT}{"${f.uri}", "${f.mimeType}", ${f.variable}, ${f static constexpr size_t WWW_ASSETS_COUNT = sizeof(WWW_ASSETS) / sizeof(WWW_ASSETS[0]); `; +// Optional locale allow-list, shared with the Vite build via VITE_APP_LOCALES +// (e.g. "en,de,nl"). When set, locale chunks outside the list are NOT embedded +// into firmware flash. `en` is always kept as the fallback. Unset => embed all. +const ALL_LOCALES = [ + 'cz', + 'de', + 'en', + 'fr', + 'it', + 'nl', + 'no', + 'pl', + 'sk', + 'sv', + 'tr' +]; +const localeAllowList = (process.env.VITE_APP_LOCALES || '') + .split(',') + .map((locale) => locale.trim()) + .filter(Boolean); + +const isExcludedLocaleChunk = (relativeFilePath) => { + if (localeAllowList.length === 0) return false; + const base = relativeFilePath.split(sep).pop(); + const match = /^([a-z]{2})-[A-Za-z0-9_-]+\.js$/.exec(base); + if (!match) return false; + const code = match[1]; + // Only treat known locale codes as locale chunks; never drop the en fallback. + if (!ALL_LOCALES.includes(code) || code === 'en') return false; + return !localeAllowList.includes(code); +}; + const getFilesSync = (dir, files = []) => { readdirSync(dir, { withFileTypes: true }).forEach((entry) => { const entryPath = resolve(dir, entry.name); @@ -116,7 +148,12 @@ writeStream.write(ARDUINO_INCLUDES); const buildPath = resolve(sourcePath); for (const filePath of getFilesSync(buildPath)) { - writeFile(relative(buildPath, filePath), readFileSync(filePath)); + const relativeFilePath = relative(buildPath, filePath); + if (isExcludedLocaleChunk(relativeFilePath)) { + console.log(`Skipping locale (not in VITE_APP_LOCALES): ${relativeFilePath}`); + continue; + } + writeFile(relativeFilePath, readFileSync(filePath)); } writeStream.write(generateWWWClass()); diff --git a/interface/src/App.tsx b/interface/src/App.tsx index b2fc2749e..8db9f2f28 100644 --- a/interface/src/App.tsx +++ b/interface/src/App.tsx @@ -1,14 +1,15 @@ import { memo, useEffect, useState } from 'react'; -import { ToastContainer, Zoom } from 'react-toastify'; +import { Outlet } from 'react-router'; -import AppRouting from 'AppRouting'; import CustomTheme from 'CustomTheme'; +import { Toaster } from 'components/toast'; +import { Authentication } from 'contexts/authentication'; import TypesafeI18n from 'i18n/i18n-react'; import type { Locales } from 'i18n/i18n-types'; import { loadLocaleAsync } from 'i18n/i18n-util.async'; import { detectLocale, navigatorDetector } from 'typesafe-i18n/detectors'; -const AVAILABLE_LOCALES = [ +const ALL_LOCALES = [ 'de', 'en', 'it', @@ -22,25 +23,19 @@ const AVAILABLE_LOCALES = [ 'cz' ] as Locales[]; -// Static toast configuration - no need to recreate on every render -const TOAST_CONTAINER_PROPS = { - position: 'bottom-left' as const, - autoClose: 3000, - hideProgressBar: false, - newestOnTop: false, - closeOnClick: true, - rtl: false, - pauseOnFocusLoss: true, - draggable: false, - pauseOnHover: false, - transition: Zoom, - closeButton: false, - theme: 'dark' as const, - toastStyle: { - border: '1px solid #177ac9', - width: 'fit-content' - } -}; +// Optional build-time allow-list (e.g. VITE_APP_LOCALES="en,de,nl"). When unset, +// every locale is available. `en` is always kept as the fallback locale, and the +// progmem generator embeds the matching subset into firmware flash. +const localeAllowList = (import.meta.env.VITE_APP_LOCALES ?? '') + .split(',') + .map((locale) => locale.trim()) + .filter(Boolean); + +const AVAILABLE_LOCALES: Locales[] = localeAllowList.length + ? ALL_LOCALES.filter( + (locale) => locale === 'en' || localeAllowList.includes(locale) + ) + : ALL_LOCALES; const App = memo(() => { const [wasLoaded, setWasLoaded] = useState(false); @@ -49,7 +44,12 @@ const App = memo(() => { useEffect(() => { const initializeLocale = async () => { const browserLocale = detectLocale('en', AVAILABLE_LOCALES, navigatorDetector); - const newLocale = (localStorage.getItem('lang') || browserLocale) as Locales; + const stored = localStorage.getItem('lang'); + // Ignore a stored locale that isn't available (e.g. trimmed from this build). + const newLocale = + stored && AVAILABLE_LOCALES.includes(stored as Locales) + ? (stored as Locales) + : browserLocale; localStorage.setItem('lang', newLocale); setLocale(newLocale); await loadLocaleAsync(newLocale); @@ -63,8 +63,10 @@ const App = memo(() => { return ( - - + + + + ); diff --git a/interface/src/AppRouting.tsx b/interface/src/AppRouting.tsx deleted file mode 100644 index aee79140d..000000000 --- a/interface/src/AppRouting.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import { type FC, memo, useContext, useEffect, useRef } from 'react'; -import { Navigate, Route, Routes } from 'react-router'; -import { toast } from 'react-toastify'; - -import AuthenticatedRouting from 'AuthenticatedRouting'; -import SignIn from 'SignIn'; -import { RequireAuthenticated, RequireUnauthenticated } from 'components'; -import { Authentication, AuthenticationContext } from 'contexts/authentication'; -import { useI18nContext } from 'i18n/i18n-react'; - -interface SecurityRedirectProps { - readonly message: string; - readonly signOut?: boolean; -} - -const RootRedirect: FC = memo( - ({ message, signOut = false }) => { - const { signOut: contextSignOut } = useContext(AuthenticationContext); - const hasShownToast = useRef(false); - - useEffect(() => { - // Prevent duplicate toasts on strict mode or re-renders - if (!hasShownToast.current) { - hasShownToast.current = true; - if (signOut) { - contextSignOut(false); - } - toast.success(message); - } - // Only run once on mount - using ref to track execution - }, []); - - return ; - } -); - -const AppRouting: FC = memo(() => { - const { LL } = useI18nContext(); - - return ( - - - } - /> - } - /> - - - - } - /> - - - - } - /> - - - ); -}); - -export default AppRouting; diff --git a/interface/src/AuthenticatedRouting.tsx b/interface/src/AuthenticatedRouting.tsx deleted file mode 100644 index ae4eeb1b6..000000000 --- a/interface/src/AuthenticatedRouting.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import { memo, useContext } from 'react'; -import { Navigate, Route, Routes } from 'react-router'; - -import Commands from 'app/main/Commands'; -import CustomEntities from 'app/main/CustomEntities'; -import Customizations from 'app/main/Customizations'; -import Dashboard from 'app/main/Dashboard'; -import Devices from 'app/main/Devices'; -import Help from 'app/main/Help'; -import Modules from 'app/main/Modules'; -import Scheduler from 'app/main/Scheduler'; -import Sensors from 'app/main/Sensors'; -import UserProfile from 'app/main/UserProfile'; -import APSettings from 'app/settings/APSettings'; -import ApplicationSettings from 'app/settings/ApplicationSettings'; -import DownloadUpload from 'app/settings/DownloadUpload'; -import MqttSettings from 'app/settings/MqttSettings'; -import NTPSettings from 'app/settings/NTPSettings'; -import Settings from 'app/settings/Settings'; -import Version from 'app/settings/Version'; -import Network from 'app/settings/network/Network'; -import Security from 'app/settings/security/Security'; -import APStatus from 'app/status/APStatus'; -import Activity from 'app/status/Activity'; -import HardwareStatus from 'app/status/HardwareStatus'; -import MqttStatus from 'app/status/MqttStatus'; -import NTPStatus from 'app/status/NTPStatus'; -import NetworkStatus from 'app/status/NetworkStatus'; -import Status from 'app/status/Status'; -import SystemLog from 'app/status/SystemLog'; -import { Layout } from 'components'; -import { AuthenticatedContext } from 'contexts/authentication'; - -const AuthenticatedRouting = memo(() => { - const { me } = useContext(AuthenticatedContext); - return ( - - - } /> - } /> - } /> - } /> - } /> - - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - {me.admin && ( - <> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - } /> - } /> - - } /> - } /> - } /> - } /> - - )} - - } /> - - - ); -}); - -export default AuthenticatedRouting; diff --git a/interface/src/SignIn.tsx b/interface/src/SignIn.tsx index 0b59d9d70..00f8f36de 100644 --- a/interface/src/SignIn.tsx +++ b/interface/src/SignIn.tsx @@ -1,5 +1,4 @@ import { memo, useCallback, useContext, useEffect, useMemo, useState } from 'react'; -import { toast } from 'react-toastify'; import ForwardIcon from '@mui/icons-material/Forward'; import { Box, Button, Paper, Typography } from '@mui/material'; @@ -7,18 +6,19 @@ import type { Theme } from '@mui/material/styles'; import * as AuthenticationApi from 'components/routing/authentication'; import { useRequest } from 'alova/client'; -import type { ValidateFieldsError } from 'async-validator'; import { LanguageSelector, ValidatedPasswordField, ValidatedTextField } from 'components'; +import { toast } from 'components/toast'; import { AuthenticationContext } from 'contexts/authentication'; import { PROJECT_NAME } from 'env'; import { useI18nContext } from 'i18n/i18n-react'; import type { SignInRequest } from 'types'; import { onEnterCallback, updateValue } from 'utils'; import { SIGN_IN_REQUEST_VALIDATOR, ValidationError, validate } from 'validators'; +import type { ValidateFieldsError } from 'validators/schema'; const SignIn = memo(() => { const authenticationContext = useContext(AuthenticationContext); diff --git a/interface/src/app/main/Commands.tsx b/interface/src/app/main/Commands.tsx index ba0fe821b..43cf2e380 100644 --- a/interface/src/app/main/Commands.tsx +++ b/interface/src/app/main/Commands.tsx @@ -1,6 +1,5 @@ import { useState } from 'react'; import { useBlocker } from 'react-router'; -import { toast } from 'react-toastify'; import AddIcon from '@mui/icons-material/Add'; import CancelIcon from '@mui/icons-material/Cancel'; @@ -27,6 +26,7 @@ import { SectionContent, useLayoutTitle } from 'components'; +import { toast } from 'components/toast'; import { useI18nContext } from 'i18n/i18n-react'; import { useInterval } from 'utils'; diff --git a/interface/src/app/main/CommandsDialog.tsx b/interface/src/app/main/CommandsDialog.tsx index 6462d3696..5e7c7bc80 100644 --- a/interface/src/app/main/CommandsDialog.tsx +++ b/interface/src/app/main/CommandsDialog.tsx @@ -1,5 +1,4 @@ import { useEffect, useState } from 'react'; -import { toast } from 'react-toastify'; import AddIcon from '@mui/icons-material/Add'; import CancelIcon from '@mui/icons-material/Cancel'; @@ -19,12 +18,13 @@ import { import { callAction } from '@/api/app'; import { dialogStyle } from 'CustomTheme'; import { useRequest } from 'alova/client'; -import type Schema from 'async-validator'; -import type { ValidateFieldsError } from 'async-validator'; import { ValidatedTextField } from 'components'; +import { toast } from 'components/toast'; import { useI18nContext } from 'i18n/i18n-react'; import { updateValue } from 'utils'; import { ValidationError, validate } from 'validators'; +import type Schema from 'validators/schema'; +import type { ValidateFieldsError } from 'validators/schema'; import type { CommandItem } from './types'; diff --git a/interface/src/app/main/CustomEntities.tsx b/interface/src/app/main/CustomEntities.tsx index 4e394ba7b..20adc06d1 100644 --- a/interface/src/app/main/CustomEntities.tsx +++ b/interface/src/app/main/CustomEntities.tsx @@ -1,6 +1,5 @@ import { useState } from 'react'; import { useBlocker } from 'react-router'; -import { toast } from 'react-toastify'; import AddIcon from '@mui/icons-material/Add'; import CancelIcon from '@mui/icons-material/Cancel'; @@ -26,6 +25,7 @@ import { SectionContent, useLayoutTitle } from 'components'; +import { toast } from 'components/toast'; import { useI18nContext } from 'i18n/i18n-react'; import { useInterval } from 'utils'; diff --git a/interface/src/app/main/CustomEntitiesDialog.tsx b/interface/src/app/main/CustomEntitiesDialog.tsx index 8ede016b4..d63336738 100644 --- a/interface/src/app/main/CustomEntitiesDialog.tsx +++ b/interface/src/app/main/CustomEntitiesDialog.tsx @@ -23,12 +23,12 @@ import { } from '@mui/material'; import { dialogStyle } from 'CustomTheme'; -import type Schema from 'async-validator'; -import type { ValidateFieldsError } from 'async-validator'; import { BlockFormControlLabel, ValidatedTextField } from 'components'; import { useI18nContext } from 'i18n/i18n-react'; import { numberValue, updateValue } from 'utils'; import { ValidationError, validate } from 'validators'; +import type Schema from 'validators/schema'; +import type { ValidateFieldsError } from 'validators/schema'; import { DeviceValueType, DeviceValueTypeNames, DeviceValueUOM_s } from './types'; import type { EntityItem } from './types'; diff --git a/interface/src/app/main/Customizations.tsx b/interface/src/app/main/Customizations.tsx index a127f9a04..99d042732 100644 --- a/interface/src/app/main/Customizations.tsx +++ b/interface/src/app/main/Customizations.tsx @@ -1,6 +1,5 @@ import { useEffect, useState } from 'react'; import { useBlocker, useLocation } from 'react-router'; -import { toast } from 'react-toastify'; import CancelIcon from '@mui/icons-material/Cancel'; import EditIcon from '@mui/icons-material/Edit'; @@ -46,6 +45,7 @@ import { SectionContent, useLayoutTitle } from 'components'; +import { toast } from 'components/toast'; import { useI18nContext } from 'i18n/i18n-react'; import { diff --git a/interface/src/app/main/Dashboard.tsx b/interface/src/app/main/Dashboard.tsx index 3a1c0055e..2bbcebcea 100644 --- a/interface/src/app/main/Dashboard.tsx +++ b/interface/src/app/main/Dashboard.tsx @@ -1,7 +1,6 @@ import { memo, useContext, useEffect, useState } from 'react'; import { IconContext } from 'react-icons/lib'; import { Link } from 'react-router'; -import { toast } from 'react-toastify'; import ChevronRightIcon from '@mui/icons-material/ChevronRight'; import EditIcon from '@mui/icons-material/Edit'; @@ -30,6 +29,7 @@ import { SectionContent, useLayoutTitle } from 'components'; +import { toast } from 'components/toast'; import { AuthenticatedContext } from 'contexts/authentication'; import { useI18nContext } from 'i18n/i18n-react'; import { useInterval, usePersistState } from 'utils'; diff --git a/interface/src/app/main/Devices.tsx b/interface/src/app/main/Devices.tsx index 869006cdd..3b74cf11c 100644 --- a/interface/src/app/main/Devices.tsx +++ b/interface/src/app/main/Devices.tsx @@ -8,7 +8,6 @@ import { } from 'react'; import { IconContext } from 'react-icons'; import { Link, useNavigate } from 'react-router'; -import { toast } from 'react-toastify'; import CommentsDisabledOutlinedIcon from '@mui/icons-material/CommentsDisabledOutlined'; import ConstructionIcon from '@mui/icons-material/Construction'; @@ -64,6 +63,7 @@ import { SectionContent, useLayoutTitle } from 'components'; +import { toast } from 'components/toast'; import { AuthenticatedContext } from 'contexts/authentication'; import { useI18nContext } from 'i18n/i18n-react'; import { useInterval } from 'utils'; diff --git a/interface/src/app/main/DevicesDialog.tsx b/interface/src/app/main/DevicesDialog.tsx index 3b98822d8..96d095ad6 100644 --- a/interface/src/app/main/DevicesDialog.tsx +++ b/interface/src/app/main/DevicesDialog.tsx @@ -1,5 +1,4 @@ import { useEffect, useState } from 'react'; -import { toast } from 'react-toastify'; import CancelIcon from '@mui/icons-material/Cancel'; import PlayArrowIcon from '@mui/icons-material/PlayArrow'; @@ -23,12 +22,13 @@ import { import { callAction } from '@/api/app'; import { dialogStyle } from 'CustomTheme'; import { useRequest } from 'alova/client'; -import type Schema from 'async-validator'; -import type { ValidateFieldsError } from 'async-validator'; import { ValidatedTextField } from 'components'; +import { toast } from 'components/toast'; import { useI18nContext } from 'i18n/i18n-react'; import { numberValue, updateValue } from 'utils'; import { ValidationError, validate } from 'validators'; +import type Schema from 'validators/schema'; +import type { ValidateFieldsError } from 'validators/schema'; import { DeviceValueUOM, DeviceValueUOM_s } from './types'; import type { DeviceValue } from './types'; @@ -84,10 +84,9 @@ const DevicesDialog = ({ } else { await validate(validator, editItem); } + onSave(editItem); } catch (error) { setFieldErrors((error as ValidationError).fieldErrors); - } finally { - onSave(editItem); } }; diff --git a/interface/src/app/main/Help.tsx b/interface/src/app/main/Help.tsx index ff1fe6a56..59d969ad2 100644 --- a/interface/src/app/main/Help.tsx +++ b/interface/src/app/main/Help.tsx @@ -1,6 +1,5 @@ import { memo, useContext, useState } from 'react'; import type { ReactElement } from 'react'; -import { toast } from 'react-toastify'; import CommentIcon from '@mui/icons-material/CommentTwoTone'; import DownloadIcon from '@mui/icons-material/GetApp'; @@ -25,6 +24,7 @@ import type { SxProps, Theme } from '@mui/material/styles'; import { useRequest } from 'alova/client'; import { SectionContent, useLayoutTitle } from 'components'; +import { toast } from 'components/toast'; import { AuthenticatedContext } from 'contexts/authentication'; import { useI18nContext } from 'i18n/i18n-react'; import { saveFile } from 'utils'; diff --git a/interface/src/app/main/Modules.tsx b/interface/src/app/main/Modules.tsx index 82ea16079..7028577d5 100644 --- a/interface/src/app/main/Modules.tsx +++ b/interface/src/app/main/Modules.tsx @@ -1,6 +1,5 @@ import { memo, useState } from 'react'; import { useBlocker } from 'react-router'; -import { toast } from 'react-toastify'; import CancelIcon from '@mui/icons-material/Cancel'; import CircleIcon from '@mui/icons-material/Circle'; @@ -25,6 +24,7 @@ import { SectionContent, useLayoutTitle } from 'components'; +import { toast } from 'components/toast'; import { useI18nContext } from 'i18n/i18n-react'; import { readModules, writeModules } from '../../api/app'; diff --git a/interface/src/app/main/Scheduler.tsx b/interface/src/app/main/Scheduler.tsx index b372cbc9a..9bc53b8a8 100644 --- a/interface/src/app/main/Scheduler.tsx +++ b/interface/src/app/main/Scheduler.tsx @@ -1,6 +1,5 @@ import { useEffect, useState } from 'react'; import { useBlocker } from 'react-router'; -import { toast } from 'react-toastify'; import AddIcon from '@mui/icons-material/Add'; import CancelIcon from '@mui/icons-material/Cancel'; @@ -26,6 +25,7 @@ import { SectionContent, useLayoutTitle } from 'components'; +import { toast } from 'components/toast'; import { useI18nContext } from 'i18n/i18n-react'; import { useInterval } from 'utils'; diff --git a/interface/src/app/main/SchedulerDialog.tsx b/interface/src/app/main/SchedulerDialog.tsx index b1d120077..b75d12627 100644 --- a/interface/src/app/main/SchedulerDialog.tsx +++ b/interface/src/app/main/SchedulerDialog.tsx @@ -22,12 +22,12 @@ import { } from '@mui/material'; import { dialogStyle } from 'CustomTheme'; -import type Schema from 'async-validator'; -import type { ValidateFieldsError } from 'async-validator'; import { BlockFormControlLabel, ValidatedTextField } from 'components'; import { useI18nContext } from 'i18n/i18n-react'; import { updateValue } from 'utils'; import { ValidationError, validate } from 'validators'; +import type Schema from 'validators/schema'; +import type { ValidateFieldsError } from 'validators/schema'; import { ScheduleFlag } from './types'; import type { ScheduleItem } from './types'; diff --git a/interface/src/app/main/Sensors.tsx b/interface/src/app/main/Sensors.tsx index b19a5c146..d77eb7521 100644 --- a/interface/src/app/main/Sensors.tsx +++ b/interface/src/app/main/Sensors.tsx @@ -1,5 +1,4 @@ import { useContext, useRef, useState } from 'react'; -import { toast } from 'react-toastify'; import AddCircleOutlineOutlinedIcon from '@mui/icons-material/AddCircleOutlineOutlined'; import KeyboardArrowDownOutlinedIcon from '@mui/icons-material/KeyboardArrowDownOutlined'; @@ -21,6 +20,7 @@ import { useTheme } from '@table-library/react-table-library/theme'; import type { State } from '@table-library/react-table-library/types/common'; import { useRequest } from 'alova/client'; import { SectionContent, useLayoutTitle } from 'components'; +import { toast } from 'components/toast'; import { AuthenticatedContext } from 'contexts/authentication'; import { useI18nContext } from 'i18n/i18n-react'; import { useInterval } from 'utils'; diff --git a/interface/src/app/main/SensorsAnalogDialog.tsx b/interface/src/app/main/SensorsAnalogDialog.tsx index 5073a2490..03bc18563 100644 --- a/interface/src/app/main/SensorsAnalogDialog.tsx +++ b/interface/src/app/main/SensorsAnalogDialog.tsx @@ -18,12 +18,12 @@ import { } from '@mui/material'; import { dialogStyle } from 'CustomTheme'; -import type Schema from 'async-validator'; -import type { ValidateFieldsError } from 'async-validator'; import { ValidatedTextField } from 'components'; import { useI18nContext } from 'i18n/i18n-react'; import { numberValue, updateValue } from 'utils'; import { ValidationError, validate } from 'validators'; +import type Schema from 'validators/schema'; +import type { ValidateFieldsError } from 'validators/schema'; import { AnalogType, AnalogTypeNames, DeviceValueUOM_s } from './types'; import type { AnalogSensor } from './types'; diff --git a/interface/src/app/main/SensorsTemperatureDialog.tsx b/interface/src/app/main/SensorsTemperatureDialog.tsx index 714156a85..6c4194199 100644 --- a/interface/src/app/main/SensorsTemperatureDialog.tsx +++ b/interface/src/app/main/SensorsTemperatureDialog.tsx @@ -16,12 +16,12 @@ import { } from '@mui/material'; import { dialogStyle } from 'CustomTheme'; -import type Schema from 'async-validator'; -import type { ValidateFieldsError } from 'async-validator'; import { ValidatedTextField } from 'components'; import { useI18nContext } from 'i18n/i18n-react'; import { numberValue, updateValue } from 'utils'; import { ValidationError, validate } from 'validators'; +import type Schema from 'validators/schema'; +import type { ValidateFieldsError } from 'validators/schema'; import type { TemperatureSensor } from './types'; diff --git a/interface/src/app/main/validators.ts b/interface/src/app/main/validators.ts index a1c2e7d26..58b9c98b1 100644 --- a/interface/src/app/main/validators.ts +++ b/interface/src/app/main/validators.ts @@ -1,5 +1,5 @@ -import Schema from 'async-validator'; -import type { InternalRuleItem } from 'async-validator'; +import Schema from 'validators/schema'; +import type { InternalRuleItem } from 'validators/schema'; import { IP_OR_HOSTNAME_VALIDATOR } from 'validators/shared'; import type { diff --git a/interface/src/app/settings/APSettings.tsx b/interface/src/app/settings/APSettings.tsx index 0f04aba3f..5f781c9c3 100644 --- a/interface/src/app/settings/APSettings.tsx +++ b/interface/src/app/settings/APSettings.tsx @@ -6,7 +6,6 @@ import { Button, Checkbox, MenuItem } from '@mui/material'; import * as APApi from 'api/ap'; -import type { ValidateFieldsError } from 'async-validator'; import { BlockFormControlLabel, BlockNavigation, @@ -22,6 +21,7 @@ import type { APSettingsType } from 'types'; import { APProvisionMode } from 'types'; import { numberValue, updateValueDirty, useRest } from 'utils'; import { ValidationError, createAPSettingsValidator, validate } from 'validators'; +import type { ValidateFieldsError } from 'validators/schema'; export const isAPEnabled = ({ provision_mode }: APSettingsType) => provision_mode === APProvisionMode.AP_MODE_DISCONNECTED; diff --git a/interface/src/app/settings/ApplicationSettings.tsx b/interface/src/app/settings/ApplicationSettings.tsx index 17e55e7e3..9f2a63819 100644 --- a/interface/src/app/settings/ApplicationSettings.tsx +++ b/interface/src/app/settings/ApplicationSettings.tsx @@ -1,5 +1,4 @@ import { useState } from 'react'; -import { toast } from 'react-toastify'; import CancelIcon from '@mui/icons-material/Cancel'; import PowerSettingsNewIcon from '@mui/icons-material/PowerSettingsNew'; @@ -20,7 +19,6 @@ import { readSystemStatus } from 'api/system'; import { useRequest } from 'alova/client'; import SystemMonitor from 'app/status/SystemMonitor'; -import type { ValidateFieldsError } from 'async-validator'; import { BlockFormControlLabel, BlockNavigation, @@ -32,9 +30,11 @@ import { ValidatedTextField, useLayoutTitle } from 'components'; +import { toast } from 'components/toast'; import { useI18nContext } from 'i18n/i18n-react'; import { numberValue, updateValueDirty, useRest } from 'utils'; import { ValidationError, validate } from 'validators'; +import type { ValidateFieldsError } from 'validators/schema'; import { API, getBoardProfile, readSettings, writeSettings } from '../../api/app'; import { BOARD_PROFILES } from '../main/types'; @@ -140,10 +140,9 @@ const ApplicationSettings = () => { try { setFieldErrors(undefined); await validate(createSettingsValidator(data), data); + await saveData(); } catch (error) { setFieldErrors((error as ValidationError).fieldErrors); - } finally { - await saveData(); } }; diff --git a/interface/src/app/settings/DownloadUpload.tsx b/interface/src/app/settings/DownloadUpload.tsx index b47b7f242..24bbab1e8 100644 --- a/interface/src/app/settings/DownloadUpload.tsx +++ b/interface/src/app/settings/DownloadUpload.tsx @@ -1,5 +1,4 @@ import { useState } from 'react'; -import { toast } from 'react-toastify'; import CancelIcon from '@mui/icons-material/Cancel'; import DownloadIcon from '@mui/icons-material/GetApp'; @@ -27,6 +26,7 @@ import { SingleUpload, useLayoutTitle } from 'components'; +import { toast } from 'components/toast'; import { useI18nContext } from 'i18n/i18n-react'; import { saveFile } from 'utils'; diff --git a/interface/src/app/settings/MqttSettings.tsx b/interface/src/app/settings/MqttSettings.tsx index a1e8efe18..69dc1db15 100644 --- a/interface/src/app/settings/MqttSettings.tsx +++ b/interface/src/app/settings/MqttSettings.tsx @@ -1,5 +1,4 @@ import { useState } from 'react'; -import { toast } from 'react-toastify'; import CancelIcon from '@mui/icons-material/Cancel'; import SettingsBackupRestoreIcon from '@mui/icons-material/SettingsBackupRestore'; @@ -17,7 +16,6 @@ import { import * as MqttApi from 'api/mqtt'; -import type { ValidateFieldsError } from 'async-validator'; import { BlockFormControlLabel, BlockNavigation, @@ -28,10 +26,12 @@ import { ValidatedTextField, useLayoutTitle } from 'components'; +import { toast } from 'components/toast'; import { useI18nContext } from 'i18n/i18n-react'; import type { MqttSettingsType } from 'types'; import { numberValue, updateValueDirty, useRest } from 'utils'; import { ValidationError, createMqttSettingsValidator, validate } from 'validators'; +import type { ValidateFieldsError } from 'validators/schema'; import { callAction } from '../../api/app'; diff --git a/interface/src/app/settings/NTPSettings.tsx b/interface/src/app/settings/NTPSettings.tsx index f85221a0a..18ce8ced8 100644 --- a/interface/src/app/settings/NTPSettings.tsx +++ b/interface/src/app/settings/NTPSettings.tsx @@ -1,5 +1,4 @@ import { useState } from 'react'; -import { toast } from 'react-toastify'; import AccessTimeIcon from '@mui/icons-material/AccessTime'; import CancelIcon from '@mui/icons-material/Cancel'; @@ -23,7 +22,6 @@ import { readNTPSettings } from 'api/ntp'; import { dialogStyle } from 'CustomTheme'; import { useRequest } from 'alova/client'; import { updateState } from 'alova/client'; -import type { ValidateFieldsError } from 'async-validator'; import { BlockFormControlLabel, BlockNavigation, @@ -33,11 +31,13 @@ import { ValidatedTextField, useLayoutTitle } from 'components'; +import { toast } from 'components/toast'; import { useI18nContext } from 'i18n/i18n-react'; import type { NTPSettingsType, Time } from 'types'; import { formatLocalDateTime, updateValueDirty, useRest } from 'utils'; import { ValidationError, validate } from 'validators'; import { NTP_SETTINGS_VALIDATOR } from 'validators/ntp'; +import type { ValidateFieldsError } from 'validators/schema'; import { TIME_ZONES, selectedTimeZone, useTimeZoneSelectItems } from './TZ'; diff --git a/interface/src/app/settings/Version.tsx b/interface/src/app/settings/Version.tsx index 7fb066a59..812daf959 100644 --- a/interface/src/app/settings/Version.tsx +++ b/interface/src/app/settings/Version.tsx @@ -1,6 +1,5 @@ import { memo, useContext, useMemo, useState } from 'react'; import { Link } from 'react-router'; -import { toast } from 'react-toastify'; import CancelIcon from '@mui/icons-material/Cancel'; import CloseIcon from '@mui/icons-material/Close'; @@ -39,6 +38,7 @@ import { SingleUpload, useLayoutTitle } from 'components'; +import { toast } from 'components/toast'; import { AuthenticatedContext } from 'contexts/authentication'; import { useI18nContext } from 'i18n/i18n-react'; import type { TranslationFunctions } from 'i18n/i18n-types'; diff --git a/interface/src/app/settings/network/Network.tsx b/interface/src/app/settings/network/Network.tsx index e90e5b835..5f4ef6528 100644 --- a/interface/src/app/settings/network/Network.tsx +++ b/interface/src/app/settings/network/Network.tsx @@ -1,12 +1,5 @@ import { memo, useState } from 'react'; -import { - Navigate, - Route, - Routes, - matchRoutes, - useLocation, - useNavigate -} from 'react-router'; +import { Outlet, useMatch, useNavigate } from 'react-router'; import { Tab } from '@mui/material'; @@ -14,27 +7,13 @@ import { RouterTabs, useLayoutTitle } from 'components'; import { useI18nContext } from 'i18n/i18n-react'; import type { WiFiNetwork } from 'types'; -import NetworkSettings from './NetworkSettings'; import { WiFiConnectionContext } from './WiFiConnectionContext'; -import WiFiNetworkScanner from './WiFiNetworkScanner'; const Network = () => { const { LL } = useI18nContext(); useLayoutTitle(LL.NETWORK(0)); - // this also works! - // const routerTab = useMatch(`settings/network/:path/*`)?.pathname || false; - const matchedRoutes = matchRoutes( - [ - { - path: '/settings/network/settings', - element: - }, - { path: '/settings/network/scan', element: } - ], - useLocation() - ); - const routerTab = matchedRoutes?.[0]?.route.path || false; + const routerTab = useMatch('/settings/network/:tab')?.pathname || false; const navigate = useNavigate(); @@ -64,14 +43,7 @@ const Network = () => { /> - - } /> - } /> - } - /> - + ); }; diff --git a/interface/src/app/settings/network/NetworkSettings.tsx b/interface/src/app/settings/network/NetworkSettings.tsx index 8a8b9c6cd..ab6f4b503 100644 --- a/interface/src/app/settings/network/NetworkSettings.tsx +++ b/interface/src/app/settings/network/NetworkSettings.tsx @@ -1,5 +1,4 @@ import { memo, useCallback, useContext, useEffect, useState } from 'react'; -import { toast } from 'react-toastify'; import CancelIcon from '@mui/icons-material/Cancel'; import DeleteIcon from '@mui/icons-material/Delete'; @@ -26,7 +25,6 @@ import { API } from 'api/app'; import { updateState, useRequest } from 'alova/client'; import type { APIcall } from 'app/main/types'; -import type { ValidateFieldsError } from 'async-validator'; import { BlockFormControlLabel, BlockNavigation, @@ -37,11 +35,13 @@ import { ValidatedPasswordField, ValidatedTextField } from 'components'; +import { toast } from 'components/toast'; import { useI18nContext } from 'i18n/i18n-react'; import type { NetworkSettingsType } from 'types'; import { updateValueDirty, useRest } from 'utils'; import { ValidationError, validate } from 'validators'; import { createNetworkSettingsValidator } from 'validators/network'; +import type { ValidateFieldsError } from 'validators/schema'; import SystemMonitor from '../../status/SystemMonitor'; import { WiFiConnectionContext } from './WiFiConnectionContext'; diff --git a/interface/src/app/settings/security/Security.tsx b/interface/src/app/settings/security/Security.tsx index 5701a907c..1ddc639fc 100644 --- a/interface/src/app/settings/security/Security.tsx +++ b/interface/src/app/settings/security/Security.tsx @@ -1,31 +1,16 @@ import { memo } from 'react'; -import { Navigate, Route, Routes, matchRoutes, useLocation } from 'react-router'; +import { Outlet, useMatch } from 'react-router'; import { Tab } from '@mui/material'; import { RouterTabs, useLayoutTitle } from 'components'; import { useI18nContext } from 'i18n/i18n-react'; -import ManageUsers from './ManageUsers'; -import SecuritySettings from './SecuritySettings'; - const Security = () => { const { LL } = useI18nContext(); useLayoutTitle(LL.SECURITY(0)); - const location = useLocation(); - - const matchedRoutes = matchRoutes( - [ - { - path: '/settings/security/settings', - element: - }, - { path: '/settings/security/users', element: } - ], - location - ); - const routerTab = matchedRoutes?.[0]?.route.path || false; + const routerTab = useMatch('/settings/security/:tab')?.pathname || false; return ( <> @@ -36,14 +21,7 @@ const Security = () => { /> - - } /> - } /> - } - /> - + ); }; diff --git a/interface/src/app/settings/security/SecuritySettings.tsx b/interface/src/app/settings/security/SecuritySettings.tsx index 40cac3de6..f4221bcc7 100644 --- a/interface/src/app/settings/security/SecuritySettings.tsx +++ b/interface/src/app/settings/security/SecuritySettings.tsx @@ -6,7 +6,6 @@ import { Button } from '@mui/material'; import * as SecurityApi from 'api/security'; -import type { ValidateFieldsError } from 'async-validator'; import { BlockNavigation, ButtonRow, @@ -20,6 +19,7 @@ import { useI18nContext } from 'i18n/i18n-react'; import type { SecuritySettingsType } from 'types'; import { updateValueDirty, useRest } from 'utils'; import { SECURITY_SETTINGS_VALIDATOR, ValidationError, validate } from 'validators'; +import type { ValidateFieldsError } from 'validators/schema'; const SecuritySettings = () => { const { LL } = useI18nContext(); diff --git a/interface/src/app/settings/security/User.tsx b/interface/src/app/settings/security/User.tsx index 093ad5d33..ddd79660e 100644 --- a/interface/src/app/settings/security/User.tsx +++ b/interface/src/app/settings/security/User.tsx @@ -14,8 +14,6 @@ import { } from '@mui/material'; import { dialogStyle } from 'CustomTheme'; -import type Schema from 'async-validator'; -import type { ValidateFieldsError } from 'async-validator'; import { BlockFormControlLabel, ValidatedPasswordField, @@ -25,6 +23,8 @@ import { useI18nContext } from 'i18n/i18n-react'; import type { UserType } from 'types'; import { updateValue } from 'utils'; import { ValidationError, validate } from 'validators'; +import type Schema from 'validators/schema'; +import type { ValidateFieldsError } from 'validators/schema'; interface UserFormProps { creating: boolean; diff --git a/interface/src/app/status/SystemLog.tsx b/interface/src/app/status/SystemLog.tsx index 76d384135..c88adaf61 100644 --- a/interface/src/app/status/SystemLog.tsx +++ b/interface/src/app/status/SystemLog.tsx @@ -1,5 +1,4 @@ import { memo, useEffect, useLayoutEffect, useRef, useState } from 'react'; -import { toast } from 'react-toastify'; import DownloadIcon from '@mui/icons-material/GetApp'; import PlayArrowIcon from '@mui/icons-material/PlayArrow'; @@ -26,6 +25,7 @@ import { SectionContent, useLayoutTitle } from 'components'; +import { toast } from 'components/toast'; import { useI18nContext } from 'i18n/i18n-react'; import type { LogEntry, LogSettings } from 'types'; import { LogLevel } from 'types'; diff --git a/interface/src/components/inputs/ValidatedTextField.tsx b/interface/src/components/inputs/ValidatedTextField.tsx index 1660af29d..1a705fabc 100644 --- a/interface/src/components/inputs/ValidatedTextField.tsx +++ b/interface/src/components/inputs/ValidatedTextField.tsx @@ -4,7 +4,7 @@ import type { FC } from 'react'; import { FormHelperText, TextField } from '@mui/material'; import type { TextFieldProps } from '@mui/material'; -import type { ValidateFieldsError } from 'async-validator'; +import type { ValidateFieldsError } from 'validators/schema'; interface ValidatedFieldProps { fieldErrors?: ValidateFieldsError; diff --git a/interface/src/components/routing/RequireAdmin.tsx b/interface/src/components/routing/RequireAdmin.tsx index 20be11eda..c8f6942c1 100644 --- a/interface/src/components/routing/RequireAdmin.tsx +++ b/interface/src/components/routing/RequireAdmin.tsx @@ -1,17 +1,14 @@ import { memo, useContext } from 'react'; -import type { FC } from 'react'; -import { Navigate } from 'react-router'; +import { Navigate, Outlet } from 'react-router'; import { AuthenticatedContext } from 'contexts/authentication'; -import type { RequiredChildrenProps } from 'utils'; -const RequireAdmin: FC = ({ children }) => { - const authenticatedContext = useContext(AuthenticatedContext); - return authenticatedContext.me.admin ? ( - <>{children} - ) : ( - - ); +// Layout-route guard: renders nested admin routes only for admins, otherwise +// redirects home. Must be used inside the authenticated route subtree so that +// AuthenticatedContext (and `me`) is available. +const RequireAdmin = () => { + const { me } = useContext(AuthenticatedContext); + return me.admin ? : ; }; export default memo(RequireAdmin); diff --git a/interface/src/components/routing/RootRedirect.tsx b/interface/src/components/routing/RootRedirect.tsx new file mode 100644 index 000000000..03b305ed0 --- /dev/null +++ b/interface/src/components/routing/RootRedirect.tsx @@ -0,0 +1,35 @@ +import { memo, useContext, useEffect, useRef } from 'react'; +import { Navigate } from 'react-router'; + +import { toast } from 'components/toast'; +import { AuthenticationContext } from 'contexts/authentication'; +import { useI18nContext } from 'i18n/i18n-react'; + +type RootRedirectKind = 'unauthorized' | 'fileUpdated'; + +// Shows a one-shot toast and bounces back to "/". Used by the /unauthorized and +// /fileUpdated routes. Resolves its own i18n message so it can be used directly +// as a static route element. +const RootRedirect = ({ kind }: { kind: RootRedirectKind }) => { + const { LL } = useI18nContext(); + const { signOut } = useContext(AuthenticationContext); + const hasShownToast = useRef(false); + + useEffect(() => { + // Guard against StrictMode double-invoke / re-renders. + if (hasShownToast.current) return; + hasShownToast.current = true; + + if (kind === 'unauthorized') { + signOut(false); + toast.success(LL.PLEASE_SIGNIN()); + } else { + toast.success(LL.UPLOAD_SUCCESSFUL()); + } + // Run once on mount. + }, []); + + return ; +}; + +export default memo(RootRedirect); diff --git a/interface/src/components/routing/index.ts b/interface/src/components/routing/index.ts index db622f2c3..31d376c1b 100644 --- a/interface/src/components/routing/index.ts +++ b/interface/src/components/routing/index.ts @@ -2,3 +2,4 @@ export { default as RouterTabs } from './RouterTabs'; export { default as RequireAdmin } from './RequireAdmin'; export { default as RequireAuthenticated } from './RequireAuthenticated'; export { default as RequireUnauthenticated } from './RequireUnauthenticated'; +export { default as RootRedirect } from './RootRedirect'; diff --git a/interface/src/components/toast/Toaster.tsx b/interface/src/components/toast/Toaster.tsx new file mode 100644 index 000000000..2639981ad --- /dev/null +++ b/interface/src/components/toast/Toaster.tsx @@ -0,0 +1,140 @@ +import { memo, useEffect, useRef, useState, useSyncExternalStore } from 'react'; + +import CloseIcon from '@mui/icons-material/Close'; +import Alert from '@mui/material/Alert'; +import Grow from '@mui/material/Grow'; +import IconButton from '@mui/material/IconButton'; +import LinearProgress from '@mui/material/LinearProgress'; +import Stack from '@mui/material/Stack'; + +import { + type ToastItem, + type ToastSeverity, + getSnapshot, + removeToast, + subscribe +} from './toastStore'; + +const AUTO_CLOSE_MS = 3000; +const TICK_MS = 50; + +// Semantic accent colors users already expect: +// success → green, error → red, warning → amber, info → blue. +const ACCENT: Record = { + success: '#16a34a', + error: '#dc2626', + warning: '#f59e0b', + info: '#2563eb' +}; + +// Single toast row: owns its auto-dismiss timer + countdown progress bar, pauses +// while the window is unfocused (matching react-toastify's pauseOnFocusLoss). +const ToastRow = memo(({ item }: { item: ToastItem }) => { + const [open, setOpen] = useState(true); + const [remaining, setRemaining] = useState(AUTO_CLOSE_MS); + const remainingRef = useRef(AUTO_CLOSE_MS); + + useEffect(() => { + let paused = document.hidden; + const onVisibility = () => { + paused = document.hidden; + }; + document.addEventListener('visibilitychange', onVisibility); + + const timer = setInterval(() => { + if (paused) return; + remainingRef.current = Math.max(0, remainingRef.current - TICK_MS); + setRemaining(remainingRef.current); + if (remainingRef.current === 0) setOpen(false); + }, TICK_MS); + + return () => { + clearInterval(timer); + document.removeEventListener('visibilitychange', onVisibility); + }; + }, []); + + const accent = ACCENT[item.severity]; + + return ( + removeToast(item.id)}> + setOpen(false)} + action={ + { + e.stopPropagation(); + setOpen(false); + }} + sx={{ color: '#9ca3af', '&:hover': { color: '#374151' } }} + > + + + } + sx={{ + position: 'relative', + width: 'fit-content', + minWidth: 300, + maxWidth: 360, + cursor: 'pointer', + overflow: 'hidden', + borderRadius: '8px', + bgcolor: '#f3f4f6', + color: '#1f2937', + boxShadow: '0 4px 12px rgba(0, 0, 0, 0.18)', + borderLeft: `4px solid ${accent}`, + alignItems: 'center', + '& .MuiAlert-icon': { color: accent, alignItems: 'center' }, + // '& .MuiAlert-message': { + // py: '8px', + // color: '#1f2937' + // }, + '& .MuiAlert-action': { alignItems: 'center', pt: 0, mr: '-4px' } + }} + > + {item.message} + + + + ); +}); + +const Toaster = memo(() => { + const toasts = useSyncExternalStore(subscribe, getSnapshot); + + return ( + theme.zIndex.snackbar, + pointerEvents: 'none', + '& > *': { pointerEvents: 'auto' } + }} + > + {toasts.map((item) => ( + + ))} + + ); +}); + +export default Toaster; diff --git a/interface/src/components/toast/index.ts b/interface/src/components/toast/index.ts new file mode 100644 index 000000000..63733fa50 --- /dev/null +++ b/interface/src/components/toast/index.ts @@ -0,0 +1,3 @@ +export { default as Toaster } from './Toaster'; +export { toast } from './toastStore'; +export type { ToastSeverity } from './toastStore'; diff --git a/interface/src/components/toast/toastStore.ts b/interface/src/components/toast/toastStore.ts new file mode 100644 index 000000000..0c6ef5fcd --- /dev/null +++ b/interface/src/components/toast/toastStore.ts @@ -0,0 +1,47 @@ +export type ToastSeverity = 'success' | 'error' | 'info' | 'warning'; + +export interface ToastItem { + id: number; + severity: ToastSeverity; + message: string; +} + +let toasts: ToastItem[] = []; +let nextId = 1; +const listeners = new Set<() => void>(); + +const emit = () => { + for (const listener of listeners) listener(); +}; + +export const subscribe = (listener: () => void): (() => void) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +}; + +export const getSnapshot = (): ToastItem[] => toasts; + +const add = (severity: ToastSeverity, message: string): number => { + const id = nextId++; + toasts = [...toasts, { id, severity, message }]; + emit(); + return id; +}; + +export const removeToast = (id: number): void => { + const next = toasts.filter((t) => t.id !== id); + if (next.length !== toasts.length) { + toasts = next; + emit(); + } +}; + +// Imperative API mirroring the subset of react-toastify used across the app. +export const toast = { + success: (message: string) => add('success', message), + error: (message: string) => add('error', message), + info: (message: string) => add('info', message), + warning: (message: string) => add('warning', message) +}; diff --git a/interface/src/components/upload/DragNdrop.tsx b/interface/src/components/upload/DragNdrop.tsx index 9febbfa8e..b5495d63d 100644 --- a/interface/src/components/upload/DragNdrop.tsx +++ b/interface/src/components/upload/DragNdrop.tsx @@ -7,7 +7,6 @@ import { useState } from 'react'; import { Link } from 'react-router'; -import { toast } from 'react-toastify'; import CancelIcon from '@mui/icons-material/Cancel'; import CloudUploadIcon from '@mui/icons-material/CloudUpload'; @@ -28,6 +27,7 @@ import { callAction } from 'api/app'; import { dialogStyle } from '@/CustomTheme'; import { useRequest } from 'alova/client'; +import { toast } from 'components/toast'; import { useI18nContext } from 'i18n/i18n-react'; const DocumentUploader = styled(Box)<{ active?: boolean }>(({ theme, active }) => ({ diff --git a/interface/src/components/upload/SingleUpload.tsx b/interface/src/components/upload/SingleUpload.tsx index 0363cd445..3f698b29e 100644 --- a/interface/src/components/upload/SingleUpload.tsx +++ b/interface/src/components/upload/SingleUpload.tsx @@ -1,5 +1,4 @@ import { useEffect, useState } from 'react'; -import { toast } from 'react-toastify'; import CancelIcon from '@mui/icons-material/Cancel'; import { Box, Button, Typography } from '@mui/material'; @@ -7,6 +6,7 @@ import { Box, Button, Typography } from '@mui/material'; import * as SystemApi from 'api/system'; import { useRequest } from 'alova/client'; +import { toast } from 'components/toast'; import { useI18nContext } from 'i18n/i18n-react'; import DragNdrop from './DragNdrop'; diff --git a/interface/src/contexts/authentication/Authentication.tsx b/interface/src/contexts/authentication/Authentication.tsx index af0a0055c..823a5c9fc 100644 --- a/interface/src/contexts/authentication/Authentication.tsx +++ b/interface/src/contexts/authentication/Authentication.tsx @@ -1,7 +1,6 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import type { FC } from 'react'; -import { redirect } from 'react-router'; -import { toast } from 'react-toastify'; +import { useNavigate } from 'react-router'; import { callAction } from 'api/app'; import { ACCESS_TOKEN } from 'api/endpoints'; @@ -10,6 +9,7 @@ import * as AuthenticationApi from 'components/routing/authentication'; import { useRequest } from 'alova/client'; import { LoadingSpinner } from 'components'; import { verifyAuthorization } from 'components/routing/authentication'; +import { toast } from 'components/toast'; import { useI18nContext } from 'i18n/i18n-react'; import type { Me, VersionsResponse } from 'types'; import type { RequiredChildrenProps } from 'utils'; @@ -18,6 +18,7 @@ import { AuthenticationContext } from './context'; const Authentication: FC = ({ children }) => { const { LL } = useI18nContext(); + const navigate = useNavigate(); const [initialized, setInitialized] = useState(false); const [me, setMe] = useState(); @@ -60,7 +61,7 @@ const Authentication: FC = ({ children }) => { setMe(undefined); setVersions(undefined); if (doRedirect) { - redirect('/'); + void navigate('/', { replace: true }); } }; diff --git a/interface/src/index.tsx b/interface/src/index.tsx index f3420463d..c21620263 100644 --- a/interface/src/index.tsx +++ b/interface/src/index.tsx @@ -1,6 +1,8 @@ import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import { + Navigate, + Outlet, Route, RouterProvider, createBrowserRouter, @@ -9,6 +11,45 @@ import { } from 'react-router'; import App from 'App'; +import SignIn from 'SignIn'; +import Commands from 'app/main/Commands'; +import CustomEntities from 'app/main/CustomEntities'; +import Customizations from 'app/main/Customizations'; +import Dashboard from 'app/main/Dashboard'; +import Devices from 'app/main/Devices'; +import Help from 'app/main/Help'; +import Modules from 'app/main/Modules'; +import Scheduler from 'app/main/Scheduler'; +import Sensors from 'app/main/Sensors'; +import UserProfile from 'app/main/UserProfile'; +import APSettings from 'app/settings/APSettings'; +import ApplicationSettings from 'app/settings/ApplicationSettings'; +import DownloadUpload from 'app/settings/DownloadUpload'; +import MqttSettings from 'app/settings/MqttSettings'; +import NTPSettings from 'app/settings/NTPSettings'; +import Settings from 'app/settings/Settings'; +import Version from 'app/settings/Version'; +import Network from 'app/settings/network/Network'; +import NetworkSettings from 'app/settings/network/NetworkSettings'; +import WiFiNetworkScanner from 'app/settings/network/WiFiNetworkScanner'; +import ManageUsers from 'app/settings/security/ManageUsers'; +import Security from 'app/settings/security/Security'; +import SecuritySettings from 'app/settings/security/SecuritySettings'; +import APStatus from 'app/status/APStatus'; +import Activity from 'app/status/Activity'; +import HardwareStatus from 'app/status/HardwareStatus'; +import MqttStatus from 'app/status/MqttStatus'; +import NTPStatus from 'app/status/NTPStatus'; +import NetworkStatus from 'app/status/NetworkStatus'; +import Status from 'app/status/Status'; +import SystemLog from 'app/status/SystemLog'; +import { + Layout, + RequireAdmin, + RequireAuthenticated, + RequireUnauthenticated, + RootRedirect +} from 'components'; const errorPageStyles = { container: { @@ -105,7 +146,87 @@ function ErrorPage() { const router = createBrowserRouter( createRoutesFromElements( - } errorElement={} /> + } errorElement={}> + + + + } + /> + } /> + } /> + + + + + + + } + > + } /> + } /> + } /> + } /> + } /> + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + }> + } + /> + } /> + } /> + } + /> + + + }> + } + /> + } /> + } /> + } + /> + + + } /> + } /> + } /> + } /> + + + } /> + + ) ); diff --git a/interface/src/preact-react-shim.ts b/interface/src/preact-react-shim.ts new file mode 100644 index 000000000..5360c825b --- /dev/null +++ b/interface/src/preact-react-shim.ts @@ -0,0 +1,62 @@ +// preact/compat shim used as the `react` alias. +// +// react-router v8 statically imports/uses a couple of React 19 APIs that +// preact/compat doesn't implement. Aliasing `react` straight to preact/compat +// therefore (a) fails Rolldown's static export analysis during dependency +// optimization / build and (b) could crash at runtime. We re-export everything +// from preact/compat and add the missing pieces: +// +// - useOptimistic: called unconditionally at the top of `RouterProvider` +// (lib/components.js). The optimistic setter is only invoked when +// `unstable_useTransitions === true`, which this app doesn't enable, so a +// passthrough state + no-op setter is correct. +// - use: only invoked (on a promise) in react-router's RSC server-SSR path +// (lib/rsc/server.ssr.js), which a client-only `createBrowserRouter` app +// never executes. It still must exist as an export to avoid Rolldown's +// IMPORT_IS_UNDEFINED warning; we provide a Suspense-style promise unwrap +// just in case it is ever reached. + +// preact/compat's type declarations use CommonJS `export =`, which TypeScript +// won't let us `export *` from, but Rolldown/Vite correctly re-export its named +// members at runtime (this is what makes react-router's `React.useState` etc. +// resolve through the alias). +// @ts-expect-error -- CJS `export =` interop; valid at bundle time. +export * from 'preact/compat'; +export { default } from 'preact/compat'; + +export function useOptimistic(passthrough: S): [S, (action: unknown) => void] { + return [passthrough, () => {}]; +} + +interface TrackedThenable extends PromiseLike { + status?: 'pending' | 'fulfilled' | 'rejected'; + value?: T; + reason?: unknown; +} + +// React 19's `use`, narrowed to the promise-unwrap (Suspense) behavior that +// react-router relies on. +export function use(usable: TrackedThenable): T { + switch (usable.status) { + case 'fulfilled': + return usable.value as T; + case 'rejected': + throw usable.reason; + default: + if (usable.status === undefined) { + usable.status = 'pending'; + void usable.then( + (value) => { + usable.status = 'fulfilled'; + usable.value = value; + }, + (reason) => { + usable.status = 'rejected'; + usable.reason = reason; + } + ); + } + // eslint-disable-next-line @typescript-eslint/only-throw-error -- Suspense throws the thenable + throw usable; + } +} diff --git a/interface/src/utils/useRest.ts b/interface/src/utils/useRest.ts index 791c6e4e0..4573f6592 100644 --- a/interface/src/utils/useRest.ts +++ b/interface/src/utils/useRest.ts @@ -1,9 +1,9 @@ import { useCallback, useState } from 'react'; import { useBlocker } from 'react-router'; -import { toast } from 'react-toastify'; import type { AlovaGenerics, Method } from 'alova'; import { useRequest } from 'alova/client'; +import { toast } from 'components/toast'; import { useI18nContext } from 'i18n/i18n-react'; export interface RestRequestOptions { diff --git a/interface/src/validators/ap.ts b/interface/src/validators/ap.ts index 4aa5638a2..fe75857b7 100644 --- a/interface/src/validators/ap.ts +++ b/interface/src/validators/ap.ts @@ -1,6 +1,6 @@ import { isAPEnabled } from 'app/settings/APSettings'; -import Schema from 'async-validator'; import type { APSettingsType } from 'types'; +import Schema from 'validators/schema'; import { IP_ADDRESS_VALIDATOR } from './shared'; diff --git a/interface/src/validators/authentication.ts b/interface/src/validators/authentication.ts index e061f254a..676fbfe32 100644 --- a/interface/src/validators/authentication.ts +++ b/interface/src/validators/authentication.ts @@ -1,4 +1,4 @@ -import Schema from 'async-validator'; +import Schema from 'validators/schema'; export const SIGN_IN_REQUEST_VALIDATOR = new Schema({ username: { diff --git a/interface/src/validators/mqtt.ts b/interface/src/validators/mqtt.ts index c3ffc92f2..720ad5c6e 100644 --- a/interface/src/validators/mqtt.ts +++ b/interface/src/validators/mqtt.ts @@ -1,5 +1,5 @@ -import Schema from 'async-validator'; import type { MqttSettingsType } from 'types'; +import Schema from 'validators/schema'; import { IP_OR_HOSTNAME_VALIDATOR } from './shared'; diff --git a/interface/src/validators/network.ts b/interface/src/validators/network.ts index 5a4cb87dc..5295d01ea 100644 --- a/interface/src/validators/network.ts +++ b/interface/src/validators/network.ts @@ -1,5 +1,5 @@ -import Schema from 'async-validator'; import type { NetworkSettingsType } from 'types'; +import Schema from 'validators/schema'; import { HOSTNAME_VALIDATOR, IP_ADDRESS_VALIDATOR } from './shared'; diff --git a/interface/src/validators/ntp.ts b/interface/src/validators/ntp.ts index e59329b12..68bd3e19a 100644 --- a/interface/src/validators/ntp.ts +++ b/interface/src/validators/ntp.ts @@ -1,4 +1,4 @@ -import Schema from 'async-validator'; +import Schema from 'validators/schema'; import { IP_OR_HOSTNAME_VALIDATOR } from './shared'; diff --git a/interface/src/validators/schema.ts b/interface/src/validators/schema.ts new file mode 100644 index 000000000..44eaa6df5 --- /dev/null +++ b/interface/src/validators/schema.ts @@ -0,0 +1,182 @@ +// Minimal drop-in replacement for the subset of `async-validator` used by this +// app's form validators. It intentionally mirrors async-validator's runtime +// semantics for the rule features in use (required, type string/number with +// min/max/pattern, and custom `validator(rule, value, callback)` functions) so +// the existing schema definitions and the `ValidateFieldsError` consumers keep +// working unchanged. +// +// Notable async-validator semantics preserved: +// - A non-required built-in rule is skipped when the value is "empty" +// (undefined / null / ''); `0` is NOT empty. +// - Custom `validator` functions are always invoked (they guard empties +// themselves), matching async-validator's behavior for validator rules. +// - `min`/`max` mean numeric bounds for `type: 'number'` and length bounds +// otherwise. +// - All rule errors for a field are collected (no early-exit per field). + +export interface ValidateError { + message?: string; + field?: string; + fieldValue?: unknown; +} + +export type ValidateFieldsError = Record; + +export interface InternalRuleItem { + field?: string; + fullField?: string; + [key: string]: unknown; +} + +export type RuleValidator = ( + rule: InternalRuleItem, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + value: any, + callback: (error?: string | Error) => void +) => void | Promise; + +export interface RuleItem { + type?: string; + required?: boolean; + pattern?: RegExp | string; + min?: number; + max?: number; + message?: string; + validator?: RuleValidator; + [key: string]: unknown; +} + +export type Rules = Record; + +export interface ValidateOption { + first?: boolean; + firstFields?: boolean | string[]; + suppressWarning?: boolean; + [key: string]: unknown; +} + +export type ValidateCallback = ( + errors: ValidateError[] | null, + fields: ValidateFieldsError +) => void; + +const isEmpty = (value: unknown): boolean => + value === undefined || value === null || value === ''; + +const runValidator = async ( + rule: RuleItem, + field: string, + value: unknown +): Promise => { + let captured: string | undefined; + const callback = (error?: string | Error) => { + if (error) captured = typeof error === 'string' ? error : error.message; + }; + try { + const result = rule.validator!( + { ...rule, field, fullField: field }, + value, + callback + ); + if (result instanceof Promise) { + await result; + } + } catch (error) { + captured = error instanceof Error ? error.message : String(error); + } + return captured; +}; + +const runRule = async ( + rule: RuleItem, + field: string, + value: unknown, + requiredMessage?: string +): Promise => { + // Custom validators own their empty-value handling and run unconditionally. + if (typeof rule.validator === 'function') { + return runValidator(rule, field, value); + } + + const empty = isEmpty(value); + + if (rule.required && empty) { + return ( + rule.message ?? requiredMessage?.replace('%s', field) ?? `${field} is required` + ); + } + + // Non-required built-in rules don't validate empty values. + if (empty) return undefined; + + if (rule.type === 'number') { + if (typeof value !== 'number' || Number.isNaN(value)) return rule.message; + if (typeof rule.min === 'number' && value < rule.min) return rule.message; + if (typeof rule.max === 'number' && value > rule.max) return rule.message; + return undefined; + } + + // type 'string' or any rule carrying length/pattern constraints. + if ( + rule.type === 'string' || + typeof rule.min === 'number' || + typeof rule.max === 'number' || + rule.pattern != null + ) { + const str = String(value); + if (typeof rule.min === 'number' && str.length < rule.min) return rule.message; + if (typeof rule.max === 'number' && str.length > rule.max) return rule.message; + if (rule.pattern != null) { + const re = + rule.pattern instanceof RegExp ? rule.pattern : new RegExp(rule.pattern); + if (!re.test(str)) return rule.message; + } + } + + return undefined; +}; + +export default class Schema { + private readonly rules: Rules; + private requiredMessage?: string; + + constructor(descriptor: Rules) { + this.rules = descriptor; + } + + // Mirrors async-validator's `messages()`. Only the `required` template (with + // a `%s` placeholder for the field name) is consumed by this minimal schema. + messages(messages: { required?: string }): this { + this.requiredMessage = messages.required; + return this; + } + + // Mirrors async-validator's callback form. Always resolves (never rejects); + // callers (validators/shared.ts) inspect the `errors` argument. + async validate( + source: Record, + _options?: ValidateOption, + callback?: ValidateCallback + ): Promise { + const fields: ValidateFieldsError = {}; + const errors: ValidateError[] = []; + + for (const field of Object.keys(this.rules)) { + const ruleDef = this.rules[field]; + if (ruleDef === undefined) continue; + const ruleList = Array.isArray(ruleDef) ? ruleDef : [ruleDef]; + const value = source[field]; + + for (const rule of ruleList) { + const message = await runRule(rule, field, value, this.requiredMessage); + if (message !== undefined) { + const error: ValidateError = { message, field, fieldValue: value }; + (fields[field] ??= []).push(error); + errors.push(error); + } + } + } + + callback?.(errors.length > 0 ? errors : null, fields); + } +} diff --git a/interface/src/validators/security.ts b/interface/src/validators/security.ts index 109aa045e..ff146779c 100644 --- a/interface/src/validators/security.ts +++ b/interface/src/validators/security.ts @@ -1,6 +1,6 @@ -import Schema from 'async-validator'; -import type { InternalRuleItem } from 'async-validator'; import type { UserType } from 'types'; +import Schema from 'validators/schema'; +import type { InternalRuleItem } from 'validators/schema'; const USERNAME_PATTERN = /^[a-zA-Z0-9_\\.]{1,24}$/; const JWT_SECRET_MAX_LENGTH = 64; diff --git a/interface/src/validators/shared.ts b/interface/src/validators/shared.ts index a0da4beb3..973651792 100644 --- a/interface/src/validators/shared.ts +++ b/interface/src/validators/shared.ts @@ -2,8 +2,8 @@ import type { InternalRuleItem, ValidateFieldsError, ValidateOption -} from 'async-validator'; -import type Schema from 'async-validator'; +} from 'validators/schema'; +import type Schema from 'validators/schema'; export class ValidationError extends Error { readonly fieldErrors: ValidateFieldsError; diff --git a/interface/src/vite-env.d.ts b/interface/src/vite-env.d.ts index 11f02fe2a..c5d41d92f 100644 --- a/interface/src/vite-env.d.ts +++ b/interface/src/vite-env.d.ts @@ -1 +1,13 @@ /// + +interface ImportMetaEnv { + // Optional comma-separated allow-list of locales to ship (e.g. "en,de,nl"). + // Unset => all locales are bundled and embedded. `en` is always kept as the + // fallback. Consumed by App.tsx (UI language list) and progmem-generator.js + // (which locale chunks get embedded into firmware flash). + readonly VITE_APP_LOCALES?: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/interface/vite.config.ts b/interface/vite.config.ts index e04cdde27..071ee223f 100644 --- a/interface/vite.config.ts +++ b/interface/vite.config.ts @@ -16,10 +16,18 @@ const CHUNK_SIZE_WARNING_LIMIT = 1024; const ASSETS_INLINE_LIMIT = 4096; // Common resolve aliases +// `react` points at a local shim (preact/compat + a useOptimistic stub) because +// react-router v8 statically imports useOptimistic from "react". See src/preact-react-shim.ts for details. +const REACT_SHIM = path.resolve(import.meta.dirname, 'src/preact-react-shim.ts'); +// `react/jsx-runtime` is listed before `react` so the more specific alias wins +// (a bare `react` alias would otherwise also match `react/jsx-runtime`). +// NOTE: @preact/preset-vite is configured with `reactAliasesEnabled: false` +// so these aliases (not the preset's `react -> preact/compat`) are authoritative. const RESOLVE_ALIASES = { - react: 'preact/compat', + 'react/jsx-runtime': 'preact/jsx-runtime', + 'react-dom/test-utils': 'preact/test-utils', 'react-dom': 'preact/compat', - 'react/jsx-runtime': 'preact/jsx-runtime' + react: REACT_SHIM }; // Common resolve extensions - prioritize TypeScript/React files for Windows compatibility @@ -89,42 +97,30 @@ const bundleSizeReporter = (): Plugin => { }; // Common preact plugin config -const createPreactPlugin = (devToolsEnabled: boolean) => - preact({ +const createPreactPlugin = (devToolsEnabled: boolean): PluginOption[] => { + const plugins = preact({ devToolsEnabled, - prefreshEnabled: false + prefreshEnabled: false, + // Disable the preset's built-in `react -> preact/compat` aliases so our + // RESOLVE_ALIASES (which routes `react` through the useOptimistic shim) win. + reactAliasesEnabled: false }); - -// Patch preact/compat to export stub React 19 APIs (use, useOptimistic) so that -// react-router v7 doesn't trigger IMPORT_IS_UNDEFINED warnings from Rolldown. -// Rolldown tracks the constant strings used in `React[REACT_USE]` / -// `React[USE_OPTIMISTIC]` lookups inside react-router and resolves them -// statically, so simply relying on a runtime guard is not enough — we need -// matching (stub) exports on the aliased preact/compat module. -const preactCompatPatchPlugin = (): Plugin => ({ - name: 'preact-compat-react19-patch', - transform(code, id) { - if (id.includes('preact') && id.includes('compat.module.js')) { - return { - code: - code + - '\nexport var use = undefined;\nexport var useOptimistic = undefined;\n', - map: null - }; - } - return undefined; - } -}); + // The preset always registers its devtools-only plugins (`preact:devtools` + // and `preact:transform-hook-names`); the flag only gates their work, not + // their presence. When disabled they're per-module no-ops that still run a + // hook on every module and dominate [PLUGIN_TIMINGS], so drop them entirely. + const devToolsOnly = new Set(['preact:devtools', 'preact:transform-hook-names']); + return devToolsEnabled + ? plugins + : plugins.filter((p) => !p || !devToolsOnly.has(p.name)); +}; // Common base plugins const createBasePlugins = ( devToolsEnabled: boolean, includeBundleReporter = true ): PluginOption[] => { - const plugins: PluginOption[] = [ - createPreactPlugin(devToolsEnabled), - preactCompatPatchPlugin() - ]; + const plugins: PluginOption[] = [...createPreactPlugin(devToolsEnabled)]; if (includeBundleReporter) { plugins.push(bundleSizeReporter()); } diff --git a/mock-api/package.json b/mock-api/package.json index 4cc30636b..411d57f12 100644 --- a/mock-api/package.json +++ b/mock-api/package.json @@ -15,5 +15,5 @@ "itty-router": "^5.0.24", "prettier": "^3.8.4" }, - "packageManager": "pnpm@11.7.0+sha512.19cc852c120c7125760f2443ee6be0ca5b40f9f50598de1a09a1f177503e010e57c23c77646e01e761de59bf874fb22a3398c33ab9691fc13eb946b6f0f4d620" + "packageManager": "pnpm@11.8.0+sha512.c1f5e7c4cb241c8f174b743851d82f42b802324afc8b0f116b96adb15aa06664948dde36960a3ba1079ba5b4b29dd0140135b94b5b5f5263592249d68e555f26" } diff --git a/src/emsesp_version.h b/src/emsesp_version.h index 7d790a329..dd02903f5 100644 --- a/src/emsesp_version.h +++ b/src/emsesp_version.h @@ -1 +1 @@ -#define EMSESP_APP_VERSION "3.9.0-dev.13" +#define EMSESP_APP_VERSION "3.9.0-dev.14"