diff --git a/.gitignore b/.gitignore index 591593c..054d011 100644 --- a/.gitignore +++ b/.gitignore @@ -122,3 +122,8 @@ vite.config.ts.timestamp-* /email-templates/* admin-update-guide.js + +# wrangler files +.wrangler +.dev.vars* +!.dev.vars.example diff --git a/package-lock.json b/package-lock.json index 8ba11e2..525e371 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "react-router-dom": "^7.6.3" }, "devDependencies": { + "@cloudflare/vite-plugin": "^1.37.1", "@eslint/js": "^9.29.0", "@tailwindcss/postcss": "^4.1.11", "@types/react": "^19.1.8", @@ -32,7 +33,8 @@ "postcss": "^8.5.6", "tailwindcss": "^4.1.11", "typescript": "^5.9.3", - "vite": "^7.0.0" + "vite": "^7.0.0", + "wrangler": "^4.92.0" } }, "node_modules/@alloc/quick-lru": { @@ -345,6 +347,174 @@ "node": ">=6.9.0" } }, + "node_modules/@cloudflare/kv-asset-handler": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.5.0.tgz", + "integrity": "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==", + "dev": true, + "license": "MIT OR Apache-2.0", + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@cloudflare/unenv-preset": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.16.1.tgz", + "integrity": "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==", + "dev": true, + "license": "MIT OR Apache-2.0", + "peerDependencies": { + "unenv": "2.0.0-rc.24", + "workerd": ">1.20260305.0 <2.0.0-0" + }, + "peerDependenciesMeta": { + "workerd": { + "optional": true + } + } + }, + "node_modules/@cloudflare/vite-plugin": { + "version": "1.54.11", + "resolved": "https://registry.npmjs.org/@cloudflare/vite-plugin/-/vite-plugin-1.54.11.tgz", + "integrity": "sha512-TJoTX1MvKXijrLVciUc66xR0Ss199au/9uiTP4H0QDNrUYpTHUU5V+X3opt0IU9f41MXStuy0oPkrZqGM0LRww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cloudflare/unenv-preset": "2.16.1", + "miniflare": "5.20260916.0-alpha", + "unenv": "2.0.0-rc.24", + "workerd": "1.20260916.1", + "wrangler": "4.133.0", + "ws": "8.21.0" + }, + "bin": { + "cf-vite": "bin/cf-vite" + }, + "peerDependencies": { + "vite": "^6.1.0 || ^7.0.0 || ^8.0.0", + "wrangler": "^4.133.0" + } + }, + "node_modules/@cloudflare/workerd-darwin-64": { + "version": "1.20260916.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260916.1.tgz", + "integrity": "sha512-h/xA7foQncooOpO3HF4IaCR/8NAy1qN5SjL0+nMTT8FgJyYE/SAGiLgSCusXc15yh7upibaYFrjHK5928nFIEQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-darwin-arm64": { + "version": "1.20260916.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260916.1.tgz", + "integrity": "sha512-triQBeZCJkCAbugMK/2/K3sqY1neUPXMDbWtoGqpKmzWXi4C2VW2Z+VSobQwJCHluP9wu0qP3O8G0+YNmsJCEg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-64": { + "version": "1.20260916.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260916.1.tgz", + "integrity": "sha512-a4gRHOI2Iv9wRbFkSGqcDFyfgo4pthTKeBUkdKhL7fxyNUghPNs9rHw+d2Q9aZyeOW2TDa2FI7ELg09eDJOPxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-arm64": { + "version": "1.20260916.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260916.1.tgz", + "integrity": "sha512-qWIZZtd1ATaRI53hKBTxpLKGQJcwOwpPsqfIUaRv8ouq2yJkzJxlG9hReacOjpSh1br3edPxysNa5KBOkqpn9Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-windows-64": { + "version": "1.20260916.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260916.1.tgz", + "integrity": "sha512-Fa14o+wq2QI4/Lsez609sNmoS9dRliSZwgmDdLLG0wZzmf3s7n0Xs2ZDAvaO2iZgH8WEk+hBfHuOeWM0P7LF0Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", @@ -954,70 +1124,597 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@humanfs/core": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", - "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.6", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz", + "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.3.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", + "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz", + "integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.3" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz", + "integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.3" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz", + "integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.4" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz", + "integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz", + "integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz", + "integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz", + "integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz", + "integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz", + "integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz", + "integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz", + "integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz", + "integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz", + "integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz", + "integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz", + "integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz", + "integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz", + "integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz", + "integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz", + "integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.3.3" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz", + "integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.3.3" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz", + "integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.3.3" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz", + "integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==", "dev": true, - "license": "Apache-2.0", + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.3" + }, "engines": { - "node": ">=18.18.0" + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@humanfs/node": { - "version": "0.16.6", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz", - "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==", + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz", + "integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==", + "cpu": [ + "wasm32" + ], "dev": true, "license": "Apache-2.0", + "optional": true, "dependencies": { - "@humanfs/core": "^0.19.1", - "@humanwhocodes/retry": "^0.3.0" + "@img/sharp-wasm32": "0.35.4" }, "engines": { - "node": ">=18.18.0" + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", - "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", + "node_modules/@img/sharp-win32-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz", + "integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "Apache-2.0", + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">=18.18" + "node": ">=20.9.0" }, "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" + "url": "https://opencollective.com/libvips" } }, - "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "node_modules/@img/sharp-win32-ia32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz", + "integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==", + "cpu": [ + "ia32" + ], "dev": true, - "license": "Apache-2.0", + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">=12.22" + "node": "^20.9.0" }, "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" + "url": "https://opencollective.com/libvips" } }, - "node_modules/@humanwhocodes/retry": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", - "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "node_modules/@img/sharp-win32-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz", + "integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==", + "cpu": [ + "x64" + ], "dev": true, - "license": "Apache-2.0", + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">=18.18" + "node": ">=20.9.0" }, "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" + "url": "https://opencollective.com/libvips" } }, "node_modules/@isaacs/fs-minipass": { @@ -1083,6 +1780,48 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@poppinss/colors": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz", + "integrity": "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^4.1.5" + } + }, + "node_modules/@poppinss/dumper": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/@poppinss/dumper/-/dumper-0.6.5.tgz", + "integrity": "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@sindresorhus/is": "^7.0.2", + "supports-color": "^10.0.0" + } + }, + "node_modules/@poppinss/dumper/node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/@poppinss/exception": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@poppinss/exception/-/exception-1.2.3.tgz", + "integrity": "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==", + "dev": true, + "license": "MIT" + }, "node_modules/@redocly/ajv": { "version": "8.11.2", "resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz", @@ -1539,6 +2278,26 @@ "win32" ] }, + "node_modules/@sindresorhus/is": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz", + "integrity": "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@speed-highlight/core": { + "version": "1.2.24", + "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.24.tgz", + "integrity": "sha512-qeW2e1l78afw8VhRPfPQ1Gjj+KU5XFQ/OFV5ti6eTa9bruO7mJyZtA4vw0ofqmA3tKCkROE9xLk3VZoeRc98nw==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/@stomp/stompjs": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/@stomp/stompjs/-/stompjs-7.3.0.tgz", @@ -1773,6 +2532,66 @@ "node": ">=14.0.0" } }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.4.3", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.0.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.4.3", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.0.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "0.2.11", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.3", + "@emnapi/runtime": "^1.4.3", + "@tybys/wasm-util": "^0.9.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.9.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.0", + "dev": true, + "inBundle": true, + "license": "0BSD", + "optional": true + }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "version": "4.1.11", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.11.tgz", @@ -2051,6 +2870,13 @@ "dev": true, "license": "MIT" }, + "node_modules/blake3-wasm": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", + "integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==", + "dev": true, + "license": "MIT" + }, "node_modules/brace-expansion": { "version": "1.1.18", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", @@ -2263,9 +3089,9 @@ "license": "MIT" }, "node_modules/detect-libc": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", - "integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "dev": true, "license": "Apache-2.0", "engines": { @@ -2293,6 +3119,16 @@ "node": ">=10.13.0" } }, + "node_modules/error-stack-parser-es": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", + "integrity": "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, "node_modules/esbuild": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", @@ -2916,6 +3752,16 @@ "json-buffer": "3.0.1" } }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -3221,6 +4067,24 @@ "@jridgewell/sourcemap-codec": "^1.5.0" } }, + "node_modules/miniflare": { + "version": "5.20260916.0-alpha", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-5.20260916.0-alpha.tgz", + "integrity": "sha512-rffIUYE5tPwhvUtVYZ4LEGaQ2XkXdBsq/flJOKODumR3gbBgHFyqPkobQEB7Ocqesz5wSUhpV8C2R57JYOsyhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "0.8.1", + "sharp": "0.35.4", + "undici": "7.29.0", + "workerd": "1.20260916.1", + "ws": "8.21.0", + "youch": "4.1.0-beta.10" + }, + "engines": { + "node": ">=22.0.0" + } + }, "node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", @@ -3457,6 +4321,20 @@ "node": ">=8" } }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -3712,6 +4590,69 @@ "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", "license": "MIT" }, + "node_modules/sharp": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz", + "integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.35.4", + "@img/sharp-darwin-x64": "0.35.4", + "@img/sharp-freebsd-wasm32": "0.35.4", + "@img/sharp-libvips-darwin-arm64": "1.3.3", + "@img/sharp-libvips-darwin-x64": "1.3.3", + "@img/sharp-libvips-linux-arm": "1.3.3", + "@img/sharp-libvips-linux-arm64": "1.3.3", + "@img/sharp-libvips-linux-ppc64": "1.3.3", + "@img/sharp-libvips-linux-riscv64": "1.3.3", + "@img/sharp-libvips-linux-s390x": "1.3.3", + "@img/sharp-libvips-linux-x64": "1.3.3", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.3", + "@img/sharp-libvips-linuxmusl-x64": "1.3.3", + "@img/sharp-linux-arm": "0.35.4", + "@img/sharp-linux-arm64": "0.35.4", + "@img/sharp-linux-ppc64": "0.35.4", + "@img/sharp-linux-riscv64": "0.35.4", + "@img/sharp-linux-s390x": "0.35.4", + "@img/sharp-linux-x64": "0.35.4", + "@img/sharp-linuxmusl-arm64": "0.35.4", + "@img/sharp-linuxmusl-x64": "0.35.4", + "@img/sharp-webcontainers-wasm32": "0.35.4", + "@img/sharp-win32-arm64": "0.35.4", + "@img/sharp-win32-ia32": "0.35.4", + "@img/sharp-win32-x64": "0.35.4" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/sharp/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -3832,6 +4773,14 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -3873,6 +4822,27 @@ "node": ">=14.17" } }, + "node_modules/undici": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/unenv": { + "version": "2.0.0-rc.24", + "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz", + "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "pathe": "^2.0.3" + } + }, "node_modules/update-browserslist-db": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", @@ -4023,6 +4993,85 @@ "node": ">=0.10.0" } }, + "node_modules/workerd": { + "version": "1.20260916.1", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260916.1.tgz", + "integrity": "sha512-yGPvK1Tg6l80QNp7uIs3fAlWBmw+ftgn3QK4uqH3bHoS+PeJNARjm6HoWGmkYl1zaqFO0iR31sdwnFKHoHOhUQ==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "bin": { + "workerd": "bin/workerd" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "@cloudflare/workerd-darwin-64": "1.20260916.1", + "@cloudflare/workerd-darwin-arm64": "1.20260916.1", + "@cloudflare/workerd-linux-64": "1.20260916.1", + "@cloudflare/workerd-linux-arm64": "1.20260916.1", + "@cloudflare/workerd-windows-64": "1.20260916.1" + } + }, + "node_modules/wrangler": { + "version": "4.133.0", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.133.0.tgz", + "integrity": "sha512-Iv+feMJ3CzBvWcJM1OjtRL/0ujr1Oqmg6H/356hJrnqf8AEkazhUVqjgcRer5HGhuc9KhrsyxUpKTRMnMeUmeA==", + "dev": true, + "license": "MIT OR Apache-2.0", + "dependencies": { + "@cloudflare/kv-asset-handler": "0.5.0", + "@cloudflare/unenv-preset": "2.16.1", + "blake3-wasm": "2.1.5", + "esbuild": "0.28.1", + "miniflare": "5.20260916.0-alpha", + "path-to-regexp": "6.3.0", + "unenv": "2.0.0-rc.24", + "workerd": "1.20260916.1" + }, + "bin": { + "cf-wrangler": "bin/cf-wrangler.js", + "wrangler": "bin/wrangler.js", + "wrangler2": "bin/wrangler.js" + }, + "engines": { + "node": ">=22.0.0" + }, + "optionalDependencies": { + "fsevents": "2.3.3" + }, + "peerDependencies": { + "@cloudflare/workers-types": "^5.20260916.1" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + } + } + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -4059,6 +5108,31 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/youch": { + "version": "4.1.0-beta.10", + "resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz", + "integrity": "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@poppinss/dumper": "^0.6.4", + "@speed-highlight/core": "^1.2.7", + "cookie": "^1.0.2", + "youch-core": "^0.3.3" + } + }, + "node_modules/youch-core": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/youch-core/-/youch-core-0.3.3.tgz", + "integrity": "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/exception": "^1.2.2", + "error-stack-parser-es": "^1.0.5" + } } } } diff --git a/package.json b/package.json index e22c4cf..f484dd8 100644 --- a/package.json +++ b/package.json @@ -7,10 +7,11 @@ "dev": "vite", "build": "npm run typecheck && vite build", "lint": "eslint .", - "preview": "vite preview", + "preview": "npm run build && wrangler dev", + "deploy": "npm run build && wrangler deploy", "api:generate": "node scripts/generate-api.mjs", "typecheck": "tsc --noEmit", - "test": "node scripts/run-refresh-test.mjs && node --test src/models/board.test.mjs src/utils/auth.test.mjs src/utils/inlineMarkdown.test.mjs", + "test": "node scripts/run-refresh-test.mjs && node --test src/models/board.test.mjs src/utils/auth.test.mjs src/utils/inlineMarkdown.test.mjs src/utils/boardState.test.mjs", "api:check": "node scripts/generate-api.mjs --check" }, "dependencies": { @@ -24,6 +25,7 @@ "react-router-dom": "^7.6.3" }, "devDependencies": { + "@cloudflare/vite-plugin": "^1.37.1", "@eslint/js": "^9.29.0", "@tailwindcss/postcss": "^4.1.11", "@types/react": "^19.1.8", @@ -38,6 +40,7 @@ "postcss": "^8.5.6", "tailwindcss": "^4.1.11", "typescript": "^5.9.3", - "vite": "^7.0.0" + "vite": "^7.0.0", + "wrangler": "^4.92.0" } } diff --git a/src/api/client.ts b/src/api/client.ts index ab2fe76..212a748 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -21,6 +21,9 @@ import { const BASE_URL = import.meta.env.VITE_API_BASE_URL || "http://localhost:8080"; +/** The backend's base URL, as a board must be told it at enrollment. */ +export const API_BASE_URL: string = BASE_URL.replace(/\/+$/, ""); + /** Sent on every request; the ngrok tunnel used for demos returns an interstitial without it. */ const STATIC_HEADERS: Record = { "ngrok-skip-browser-warning": "true", diff --git a/src/api/schema.d.ts b/src/api/schema.d.ts index ea1c932..ed0629c 100644 --- a/src/api/schema.d.ts +++ b/src/api/schema.d.ts @@ -223,6 +223,26 @@ export interface paths { patch?: never; trace?: never; }; + "/api/admin/board/devices/{deviceId}/offline-bundle": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Download a content bundle for a board with no internet + * @description A zip holding one fully assembled payload per day, starting today in the device's timezone, plus every poster image those payloads reference. Pushed to the board by hand with mbpush. Requires board:device:read. + */ + get: operations["downloadOfflineBundle"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/admin/board/devices/{deviceId}/revoke": { parameters: { query?: never; @@ -2657,6 +2677,78 @@ export interface operations { }; }; }; + downloadOfflineBundle: { + parameters: { + query?: { + /** @description Number of days in the bundle, starting today in the device's timezone */ + days?: number; + }; + header?: never; + path: { + deviceId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The bundle */ + 200: { + headers: { + /** @description attachment; filename="musallahboard--.zip" */ + "Content-Disposition"?: string; + [name: string]: unknown; + }; + content: { + "application/zip": string; + }; + }; + /** @description days outside 1-31 */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageResponse"]; + }; + }; + /** @description No device with that id */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageResponse"]; + }; + }; + /** @description Device is revoked */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageResponse"]; + }; + }; + /** @description A poster image could not be fetched; the message names the poster */ + 502: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageResponse"]; + }; + }; + /** @description Request failed; body carries a human-readable message */ + default: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageResponse"]; + }; + }; + }; + }; revokeDevice: { parameters: { query?: never; diff --git a/src/components/devices/BoardIssueChips.jsx b/src/components/devices/BoardIssueChips.jsx new file mode 100644 index 0000000..46939bb --- /dev/null +++ b/src/components/devices/BoardIssueChips.jsx @@ -0,0 +1,27 @@ +import { boardIssues } from '../../utils/boardState'; + +const STYLES = { + danger: 'bg-red-50 text-red-700 border-red-200', + warning: 'bg-amber-50 text-amber-800 border-amber-200', + info: 'bg-indigo-50 text-indigo-700 border-indigo-200' +}; + +/** What needs attention on a board, as small chips. Nothing when all is well. */ +function BoardIssueChips({ board, reportAt = null, empty = null }) { + const issues = boardIssues(board, { reportAt }); + if (!issues.length) return empty; + return ( +
+ {issues.map((issue) => ( + + {issue.label} + + ))} +
+ ); +} + +export default BoardIssueChips; diff --git a/src/components/devices/BoardStatePanel.jsx b/src/components/devices/BoardStatePanel.jsx new file mode 100644 index 0000000..7e18d3d --- /dev/null +++ b/src/components/devices/BoardStatePanel.jsx @@ -0,0 +1,114 @@ +import { AlertTriangle, CheckCircle2, Clock, Download, Film, History, LayoutTemplate, RefreshCcw } from 'lucide-react'; +import { createElement } from 'react'; +import BoardIssueChips from './BoardIssueChips'; +import { + contentDays, + contentSource, + describeAgentUpdate, + describeClock, + describeContent, + describeUpdates, + updateCheckProblem +} from '../../utils/boardState'; +import { formatRelativeTime } from '../../utils/deviceStatus'; + +const TEXT = { + ok: 'text-gray-900', + warning: 'text-amber-800', + danger: 'text-red-700' +}; + +function Row({ icon, label, children, hint, level = 'ok' }) { + return ( +
+ {createElement(icon, { className: `mt-0.5 h-4 w-4 flex-shrink-0 ${level === 'ok' ? 'text-gray-400' : TEXT[level]}` })} +
+
{label}
+
{children}
+ {hint &&
{hint}
} +
+
+ ); +} + +/** + * What the board runs and shows, from the report it sends with every heartbeat + * (agent 0.3.0 and later): the admin's view of a board nobody is standing in + * front of. + */ +function BoardStatePanel({ device }) { + const board = device.board; + if (!board) { + return ( +

+ This board has not reported what it shows yet. Boards report it from agent 0.3.0 on. +

+ ); + } + + const content = board.content; + const days = contentDays(content); + const contentLevel = board.error || !content || days.staleDays > 0 ? 'danger' : days.daysRemaining <= 2 ? 'warning' : 'ok'; + const updates = describeUpdates(board.updates); + const checkProblem = updateCheckProblem(board.updates); + const agentUpdate = describeAgentUpdate(board.lastAgentUpdate); + const clock = describeClock(board.clock); + + return ( +
+ +
+ {board.appVersion || 'Not installed'} + + {describeContent(content)} + + + {updates || checkProblem || 'Nothing: the board app and agent are up to date'} + + {agentUpdate && ( + + {agentUpdate.text} + + )} + {clock && ( + + {clock.text} + + )} + + {board.syncError ? `Failing: ${board.syncError}` : 'Working'} + +
+

+ + Reported by the board {formatRelativeTime(device.boardReportAt)} +

+
+ ); +} + +export default BoardStatePanel; diff --git a/src/components/devices/CommandOutput.jsx b/src/components/devices/CommandOutput.jsx index c731016..d244938 100644 --- a/src/components/devices/CommandOutput.jsx +++ b/src/components/devices/CommandOutput.jsx @@ -51,10 +51,13 @@ function CommandOutput({ command }) { return (
{output.checkError && ( -
Could not check for updates: {output.checkError}
+
+ Could not check for updates: {output.checkError} + {output.results.length === 0 &&
Nothing was installed. Check the board's internet connection and try again.
} +
)} {output.results.length === 0 ? ( -
Nothing to install: the board app and agent are up to date.
+ !output.checkError &&
Nothing to install: the board app and agent are up to date.
) : (
    {output.results.map((result, index) => ( diff --git a/src/components/devices/DeviceBoardConfig.jsx b/src/components/devices/DeviceBoardConfig.jsx index a422812..e658778 100644 --- a/src/components/devices/DeviceBoardConfig.jsx +++ b/src/components/devices/DeviceBoardConfig.jsx @@ -15,6 +15,19 @@ import { PERMISSIONS } from '../../utils/permissions'; * button that always 403s for the role most likely to press it. */ +/** + * Where a save goes: online boards pick it up on their own, offline ones only + * from a new bundle. Said right after saving, when it matters. + */ +function SavedHint({ show }) { + if (!show) return null; + return ( + + Online boards update within a minute; offline boards need a new offline bundle. + + ); +} + function SaveButton({ dirty, saving, saved, disabled, onSave }) { return ( +
+ {command} +

The board shows its address on screen while it waits to be enrolled.

- +
+ + ); +} + +export default OfflineBundleDownload; diff --git a/src/hooks/useDevice.js b/src/hooks/useDevice.js index 5d3bfdc..609b596 100644 --- a/src/hooks/useDevice.js +++ b/src/hooks/useDevice.js @@ -50,7 +50,9 @@ export function useDevice(deviceId) { setDevice((prev) => prev ? { ...prev, lastHeartbeat: event.at, - lastSeenIp: event.telemetry?.ipv4?.[0] || prev.lastSeenIp + lastSeenIp: event.telemetry?.ipv4?.[0] || prev.lastSeenIp, + // The board's report of itself rides on the heartbeat too. + ...(event.telemetry?.board ? { board: event.telemetry.board, boardReportAt: event.at } : {}) } : prev); setTelemetrySamples((prev) => [ ...prev, diff --git a/src/hooks/useDeviceList.js b/src/hooks/useDeviceList.js index 46188d2..6a3e21b 100644 --- a/src/hooks/useDeviceList.js +++ b/src/hooks/useDeviceList.js @@ -67,7 +67,8 @@ export function useDeviceList({ enabled = true } = {}) { ? { ...device, lastHeartbeat: event.at, - lastSeenIp: event.telemetry?.ipv4?.[0] || device.lastSeenIp + lastSeenIp: event.telemetry?.ipv4?.[0] || device.lastSeenIp, + ...(event.telemetry?.board ? { board: event.telemetry.board, boardReportAt: event.at } : {}) } : device ))); diff --git a/src/pages/DeviceDetail.jsx b/src/pages/DeviceDetail.jsx index 4366ce9..2d018b4 100644 --- a/src/pages/DeviceDetail.jsx +++ b/src/pages/DeviceDetail.jsx @@ -1,15 +1,17 @@ import { Link, useParams } from 'react-router-dom'; import { useState } from 'react'; -import { Ban, ChevronLeft, Loader2, Monitor, RefreshCcw, Server } from 'lucide-react'; +import { Ban, Check, ChevronLeft, LayoutDashboard, Loader2, Monitor, Pencil, RefreshCcw, Server, X } from 'lucide-react'; import { useAuth } from '../context/AuthContext'; import { useDevice } from '../hooks/useDevice'; import { upsertCommand, useCommandStream } from '../hooks/useCommandStream'; import DeviceService from '../services/DeviceService'; import DeviceStatusBadge from '../components/devices/DeviceStatusBadge'; import TelemetryPanel from '../components/devices/TelemetryPanel'; +import BoardStatePanel from '../components/devices/BoardStatePanel'; import CommandLauncher from '../components/devices/CommandLauncher'; import CommandRow from '../components/devices/CommandRow'; import DeviceBoardConfig from '../components/devices/DeviceBoardConfig'; +import OfflineBundleDownload from '../components/devices/OfflineBundleDownload'; import Can from '../components/Can'; import { audienceLabel, formatDateTime, formatRelativeTime } from '../utils/deviceStatus'; import { PERMISSIONS } from '../utils/permissions'; @@ -22,6 +24,29 @@ function DeviceDetail() { const [showRevoke, setShowRevoke] = useState(false); const [revoking, setRevoking] = useState(false); const [revokeError, setRevokeError] = useState(''); + const [renaming, setRenaming] = useState(false); + const [nameDraft, setNameDraft] = useState(''); + const [savingName, setSavingName] = useState(false); + const [renameError, setRenameError] = useState(''); + + const saveName = async () => { + const name = nameDraft.trim(); + if (!name || name === device?.displayName) { + setRenaming(false); + return; + } + setSavingName(true); + setRenameError(''); + try { + await DeviceService.renameDevice(deviceId, name); + setRenaming(false); + refetch(); + } catch (err) { + setRenameError(err.message || 'Failed to rename the board'); + } finally { + setSavingName(false); + } + }; const handleIssued = (issued) => { setCommands((prev) => upsertCommand(prev, { @@ -81,10 +106,47 @@ function DeviceDetail() {
-

{device.displayName}

+ {renaming ? ( +
{ event.preventDefault(); saveName(); }} + > + setNameDraft(event.target.value)} + onKeyDown={(event) => { if (event.key === 'Escape') setRenaming(false); }} + className="rounded-lg border border-gray-300 px-3 py-1.5 text-xl font-semibold text-gray-900 focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-100" + aria-label="Board name" + /> + + +
+ ) : ( + <> +

{device.displayName}

+ + + + + )}

{device.id}

+ {renameError &&

{renameError}

}
@@ -117,6 +179,14 @@ function DeviceDetail() {
+
+
+ +

Board

+
+ +
+

Telemetry

@@ -154,6 +224,12 @@ function DeviceDetail() {
+ {!device.revokedAt && ( + + + + )} + diff --git a/src/pages/DeviceList.jsx b/src/pages/DeviceList.jsx index 10ab428..df5d895 100644 --- a/src/pages/DeviceList.jsx +++ b/src/pages/DeviceList.jsx @@ -3,6 +3,7 @@ import { ChevronLeft, Monitor, Plus, RefreshCcw, Thermometer, WifiOff } from 'lu import { useDeviceList } from '../hooks/useDeviceList'; import Can from '../components/Can'; import DeviceStatusBadge from '../components/devices/DeviceStatusBadge'; +import BoardIssueChips from '../components/devices/BoardIssueChips'; import { audienceLabel, formatRelativeTime } from '../utils/deviceStatus'; import { PERMISSIONS } from '../utils/permissions'; @@ -58,6 +59,7 @@ function DeviceList() { Device Audience Status + Board Last heartbeat CPU Agent @@ -65,9 +67,9 @@ function DeviceList() { {loading ? ( - Loading devices... + Loading devices... ) : devices.length === 0 ? ( - No devices enrolled yet. + No devices enrolled yet. ) : devices.map((device) => ( navigate(`/admin/devices/${device.id}`)} className="cursor-pointer hover:bg-gray-50"> @@ -76,6 +78,13 @@ function DeviceList() { {audienceLabel(device.audience)} + + {device.board ? 'All good' : 'Not reported'}} + /> + {formatRelativeTime(device.lastHeartbeat)} diff --git a/src/services/DeviceService.js b/src/services/DeviceService.js index a279be6..0d199f7 100644 --- a/src/services/DeviceService.js +++ b/src/services/DeviceService.js @@ -32,6 +32,17 @@ class DeviceService { ); } + /** Rename a board. The name is what admins see; the board never shows it. */ + static async renameDevice(deviceId, displayName) { + return this.unwrap( + await api.PATCH('/api/admin/board/devices/{deviceId}', { + params: { path: { deviceId } }, + body: { displayName }, + }), + 'Failed to rename device' + ); + } + static async issueEnrollmentToken(request) { return this.unwrap( await api.POST('/api/admin/board/devices/enrollment-tokens', { body: request }), @@ -56,6 +67,32 @@ class DeviceService { ); } + /** + * Content bundle for a board with no internet, fetched as a Blob so the + * request carries the Bearer token (a plain would not). + * Bundling downloads every referenced poster, so this can take several seconds. + * @returns {Promise<{ blob: Blob, filename: string }>} + */ + static async downloadOfflineBundle(deviceId, days) { + const { data, error, response } = await api.GET('/api/admin/board/devices/{deviceId}/offline-bundle', { + params: { path: { deviceId }, query: { days } }, + parseAs: 'blob', + }); + if (error !== undefined || !data) { + const fallback = { + 400: 'Days must be between 1 and 31.', + 404: 'This device no longer exists.', + 409: 'This device has been revoked, so no bundle can be built for it.', + 502: 'A poster image could not be fetched. Try again in a moment.', + }[response.status] || 'Failed to build the offline bundle'; + throw new Error((typeof error === 'object' && error?.message) || fallback); + } + const disposition = response.headers.get('Content-Disposition') || ''; + const match = /filename="?([^";]+)"?/i.exec(disposition); + const filename = match?.[1] || `musallahboard-${String(deviceId).slice(0, 8)}.zip`; + return { blob: data, filename }; + } + static async listCommands(deviceId) { return this.unwrap( await api.GET('/api/admin/board/devices/{deviceId}/commands', { params: { path: { deviceId } } }), diff --git a/src/utils/boardState.js b/src/utils/boardState.js new file mode 100644 index 0000000..924978b --- /dev/null +++ b/src/utils/boardState.js @@ -0,0 +1,164 @@ +/** + * The board's report of itself (`device.board`, sent with every heartbeat by + * agent 0.3.0 and later; see the agent's docs/architecture.md, "Board report"), + * turned into what the console shows: plain sentences and the few issues that + * need someone's attention. + * + * { appVersion, + * content: { firstDay, lastDay, createdAt, source, installedAt, daysRemaining, staleDays }, + * updates: { available: [{ type, version, description }], installTime, installAt, installing }, + * lastAgentUpdate: { from, to, at, status: 'ok' | 'rolled-back' | 'refused', message }, + * clock: { source: 'ntp' | 'rtc' | 'uploader' | 'starting' | 'unverified', trusted }, + * syncError, error } + */ + +const SOURCE_LABELS = { + sync: 'synced from LensBridge', + usb: 'from a USB stick', + upload: 'uploaded from a laptop or phone', + cli: 'installed from the command line' +}; + +const CLOCK_LABELS = { + ntp: 'Set from the internet', + rtc: 'Kept by the hardware clock', + uploader: 'Set from a laptop or phone since the board started', + starting: 'Checking (the board just started)', + unverified: 'Not confirmed since the board started: prayer times may be wrong' +}; + +/** Content ending this many days from now or sooner is worth a warning. */ +const CONTENT_LOW_DAYS = 2; + +/** A report this old says so: an offline board's report stops at its last heartbeat. */ +const REPORT_OLD_MS = 24 * 60 * 60 * 1000; +const DAY_MS = 24 * 60 * 60 * 1000; + +/** "YYYY-MM-DD" today in `timezone` (the board's), or the viewer's own. */ +function todayIn(timezone, now) { + try { + return new Intl.DateTimeFormat('en-CA', { + year: 'numeric', month: '2-digit', day: '2-digit', timeZone: timezone || undefined, + }).format(now); + } catch { + return now.toISOString().slice(0, 10); + } +} + +/** + * Days of content left, or days since it ran out, worked out from lastDay and + * today rather than taken from the report: a board offline for a week last + * reported "6 days left", and that is no longer true. + */ +export function contentDays(content, now = new Date()) { + if (!content?.lastDay) return { daysRemaining: content?.daysRemaining ?? 0, staleDays: content?.staleDays ?? 0 }; + const diff = Math.round( + (Date.parse(`${content.lastDay}T00:00:00Z`) - Date.parse(`${todayIn(content.timezone, now)}T00:00:00Z`)) / DAY_MS + ); + return diff >= 0 ? { daysRemaining: diff, staleDays: 0 } : { daysRemaining: 0, staleDays: -diff }; +} + +/** "Oct 1" for a board-local "2026-10-01". */ +export function formatDay(day) { + if (!day) return ''; + const date = new Date(`${day}T12:00:00Z`); + if (Number.isNaN(date.getTime())) return day; + return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' }); +} + +/** + * "Thu, Sep 25, 11:00 PM" for an RFC 3339 time, read as the board's own wall + * clock (the offset it carries), not the viewer's: "installs at 11:00 PM" means + * the board's 11 PM wherever the admin is. + */ +export function formatBoardTime(rfc3339) { + const m = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/.exec(rfc3339 || ''); + if (!m) return rfc3339 || ''; + const wall = new Date(Date.UTC(+m[1], +m[2] - 1, +m[3], +m[4], +m[5])); + return wall.toLocaleString('en-US', { + weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', timeZone: 'UTC' + }); +} + +/** One line about the installed content. */ +export function describeContent(content, now = new Date()) { + if (!content) return 'No content installed'; + const range = `${formatDay(content.firstDay)} to ${formatDay(content.lastDay)}`; + const { daysRemaining: left, staleDays } = contentDays(content, now); + if (staleDays > 0) { + return `${range}: ran out ${staleDays} day${staleDays === 1 ? '' : 's'} ago`; + } + return `${range} (${left} more day${left === 1 ? '' : 's'})`; +} + +export function contentSource(content) { + return SOURCE_LABELS[content?.source] || ''; +} + +/** One line about software waiting for the install window, or null. */ +export function describeUpdates(updates) { + const available = updates?.available ?? []; + if (!available.length) return null; + const what = available.map((u) => u.description).join(' and '); + if (updates.installing) return `Installing ${what} now`; + return updates.installAt ? `${what}, installs ${formatBoardTime(updates.installAt)} (board time)` : what; +} + +/** The last agent self-update, in a sentence, with how it went. */ +export function describeAgentUpdate(u) { + if (!u) return null; + switch (u.status) { + case 'ok': + return { level: 'ok', text: `Updated from ${u.from} to ${u.to}` }; + case 'rolled-back': + return { level: 'danger', text: `Agent ${u.to} failed to start, so the board went back to ${u.from}. It will not be retried.` }; + case 'refused': + return { level: 'warning', text: `Agent ${u.to || 'update'} could not be installed on this board` }; + default: + return { level: 'warning', text: u.message || u.status }; + } +} + +export function describeClock(clock) { + if (!clock) return null; + return { level: clock.trusted === false ? 'danger' : 'ok', text: CLOCK_LABELS[clock.source] || clock.source }; +} + +/** + * What needs someone's attention, most serious first, as short labels for the + * device list and the device page header. Empty when the board is fine or has + * not reported. + */ +/** + * What the portal can say about software updates: waiting ones, a failing + * check, or updates being off. Null when there is nothing to say. + */ +export function updateCheckProblem(updates) { + if (!updates) return null; + if (updates.autoUpdate === false) return 'Automatic updates are off on this board'; + if (updates.lastCheckError) return `Update check failing: ${updates.lastCheckError}`; + return null; +} + +export function boardIssues(board, { reportAt = null, now = new Date() } = {}) { + if (!board) return []; + const issues = []; + if (!board.appVersion) issues.push({ level: 'danger', label: 'No board app' }); + if (board.error) issues.push({ level: 'danger', label: 'Content unreadable' }); + const c = board.content; + if (!c && !board.error) issues.push({ level: 'danger', label: 'No content' }); + const days = contentDays(c, now); + if (c && days.staleDays > 0) issues.push({ level: 'danger', label: `Content ran out ${days.staleDays}d ago` }); + else if (c && days.daysRemaining <= CONTENT_LOW_DAYS) issues.push({ level: 'warning', label: `Content ends in ${days.daysRemaining}d` }); + if (board.clock?.trusted === false) issues.push({ level: 'danger', label: 'Clock unconfirmed' }); + if (board.lastAgentUpdate?.status === 'rolled-back') issues.push({ level: 'danger', label: 'Agent update rolled back' }); + if (board.lastAgentUpdate?.status === 'refused') issues.push({ level: 'warning', label: 'Agent update refused' }); + if (board.syncError) issues.push({ level: 'warning', label: 'Sync failing' }); + if (board.updates?.lastCheckError && board.updates?.autoUpdate !== false) { + issues.push({ level: 'warning', label: 'Update check failing' }); + } + const age = reportAt ? now.getTime() - Date.parse(reportAt) : 0; + if (age > REPORT_OLD_MS) issues.push({ level: 'info', label: `Report ${Math.floor(age / DAY_MS)}d old` }); + if (board.updates?.available?.length) issues.push({ level: 'info', label: 'Update waiting' }); + return issues; +} diff --git a/src/utils/boardState.test.mjs b/src/utils/boardState.test.mjs new file mode 100644 index 0000000..b5d78e2 --- /dev/null +++ b/src/utils/boardState.test.mjs @@ -0,0 +1,91 @@ +/** + * Pins how a board's report reads in the console. The failure mode is quiet: + * a board whose content has run out, or whose clock is unconfirmed, looking + * fine in the device list. + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + boardIssues, contentDays, describeContent, describeUpdates, describeAgentUpdate, + formatBoardTime, updateCheckProblem, +} from './boardState.js'; + +// Midday in Toronto, 25 September 2026. +const now = new Date('2026-09-25T16:00:00Z'); +const content = { + firstDay: '2026-09-25', lastDay: '2026-10-01', timezone: 'America/Toronto', + daysRemaining: 6, staleDays: 0, source: 'usb', +}; +const healthy = { appVersion: '2.2.0', content, clock: { source: 'ntp', trusted: true } }; + +test('a healthy board has no issues', () => { + assert.deepEqual(boardIssues(healthy, { now }), []); + assert.deepEqual(boardIssues(null), []); +}); + +test('problems are listed most serious first', () => { + const labels = boardIssues({ + content: { ...content, lastDay: '2026-09-22' }, + clock: { source: 'unverified', trusted: false }, + lastAgentUpdate: { status: 'rolled-back', from: '0.2.1', to: '0.3.0' }, + syncError: 'no route', + updates: { available: [{ description: 'board app 2.3.0' }], autoUpdate: true, lastCheckError: 'dns' }, + }, { now }).map((i) => `${i.level}:${i.label}`); + assert.deepEqual(labels, [ + 'danger:No board app', + 'danger:Content ran out 3d ago', + 'danger:Clock unconfirmed', + 'danger:Agent update rolled back', + 'warning:Sync failing', + 'warning:Update check failing', + 'info:Update waiting', + ]); +}); + +test('days left come from lastDay and today, not the report', () => { + // Reported "6 days left" a week ago; it has since run out. + const later = new Date('2026-10-03T16:00:00Z'); + assert.deepEqual(contentDays(content, later), { daysRemaining: 0, staleDays: 2 }); + assert.deepEqual(contentDays(content, now), { daysRemaining: 6, staleDays: 0 }); + // Just after midnight in Toronto is still the previous day in UTC terms. + assert.deepEqual(contentDays(content, new Date('2026-10-02T03:30:00Z')), { daysRemaining: 0, staleDays: 0 }); +}); + +test('content running low is a warning', () => { + assert.deepEqual( + boardIssues({ ...healthy, content: { ...content, lastDay: '2026-09-26' } }, { now }).map((i) => i.label), + ['Content ends in 1d'] + ); +}); + +test('an old report says so', () => { + const labels = boardIssues(healthy, { reportAt: '2026-09-21T12:00:00Z', now }).map((i) => i.label); + assert.deepEqual(labels, ['Report 4d old']); +}); + +test('content reads as a range with days left', () => { + assert.equal(describeContent(content, now), 'Sep 25 to Oct 1 (6 more days)'); + assert.equal(describeContent(content, new Date('2026-10-02T16:00:00Z')), 'Sep 25 to Oct 1: ran out 1 day ago'); + assert.equal(describeContent(null), 'No content installed'); +}); + +test('update checks: off, failing, or fine', () => { + assert.equal(updateCheckProblem({ autoUpdate: false }), 'Automatic updates are off on this board'); + assert.equal(updateCheckProblem({ autoUpdate: true, lastCheckError: 'dns' }), 'Update check failing: dns'); + assert.equal(updateCheckProblem({ autoUpdate: true, lastCheckAt: '2026-09-25T12:00:00Z' }), null); +}); + +test('install times are the board\'s wall clock, not the viewer\'s', () => { + assert.equal(formatBoardTime('2026-09-25T23:00:00-04:00'), 'Fri, Sep 25, 11:00 PM'); + assert.match( + describeUpdates({ available: [{ description: 'board app 2.3.0' }], installAt: '2026-09-25T23:00:00-04:00' }), + /^board app 2\.3\.0, installs Fri, Sep 25, 11:00 PM \(board time\)$/ + ); + assert.equal(describeUpdates({ available: [] }), null); +}); + +test('a rolled back agent update says so plainly', () => { + const r = describeAgentUpdate({ status: 'rolled-back', from: '0.2.1', to: '0.3.0' }); + assert.equal(r.level, 'danger'); + assert.match(r.text, /0\.3\.0 failed to start, so the board went back to 0\.2\.1/); +}); diff --git a/vite.config.js b/vite.config.js index 93979b7..bbca6d7 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,9 +1,11 @@ import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' +import { cloudflare } from "@cloudflare/vite-plugin"; + // https://vite.dev/config/ export default defineConfig({ - plugins: [react()], + plugins: [react(), cloudflare()], server: { port: 5173, host: true @@ -12,4 +14,4 @@ export default defineConfig({ outDir: 'dist', sourcemap: true } -}) +}) \ No newline at end of file diff --git a/wrangler.jsonc b/wrangler.jsonc index aa9ad3f..797d1a1 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -1,8 +1,15 @@ { + "$schema": "node_modules/wrangler/config-schema.json", "name": "lensbridgefrontend", "compatibility_date": "2026-08-06", + "observability": { + "enabled": true + }, "assets": { "directory": "./dist", "not_found_handling": "single-page-application" - } + }, + "compatibility_flags": [ + "nodejs_compat" + ] }