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"