diff --git a/.gitignore b/.gitignore index 2c35b56..292e133 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,7 @@ src/secrets.h # macOS .DS_Store + +node_modules/ +.vite/ +dist/ \ No newline at end of file diff --git a/data/assets/app.css.gz b/data/assets/app.css.gz deleted file mode 100644 index 74f10cc..0000000 Binary files a/data/assets/app.css.gz and /dev/null differ diff --git a/data/assets/app.js.gz b/data/assets/app.js.gz deleted file mode 100644 index b9b44cf..0000000 Binary files a/data/assets/app.js.gz and /dev/null differ diff --git a/data/index.html.gz b/data/index.html.gz deleted file mode 100644 index a04c3b1..0000000 Binary files a/data/index.html.gz and /dev/null differ diff --git a/src/network/server.cpp b/src/network/server.cpp index 1ef7f99..2ba617a 100644 --- a/src/network/server.cpp +++ b/src/network/server.cpp @@ -53,6 +53,13 @@ static String contentTypeFromPath(const String& path) { return "application/octet-stream"; } +static String cacheControl(const String& path) { + if (path.endsWith(".css")) return "private, max-age=604800, immutable"; + if (path.endsWith(".js")) return "private, max-age=604800, immutable"; + + return "no-cache, max-age=0"; +} + static void appendColorArray(JsonArray& arr, const CRGB& color) { arr.add(color.r); arr.add(color.g); @@ -397,6 +404,7 @@ void setupServer() { } if (LittleFS.exists(path)) { + request->addHeader("Cache-Control", cacheControl(path)); request->send(LittleFS, path, contentTypeFromPath(path)); return; } diff --git a/data/index.html b/web/index.html similarity index 99% rename from data/index.html rename to web/index.html index acdf564..a10f0e6 100644 --- a/data/index.html +++ b/web/index.html @@ -5,7 +5,7 @@ LUME - +
@@ -493,6 +493,6 @@

🤖 AI Assi
- + diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..9f67330 --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,1180 @@ +{ + "name": "lume", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "lume", + "version": "1.0.0", + "license": "ISC", + "devDependencies": { + "terser": "^5.44.1", + "vite": "^7.3.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", + "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", + "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", + "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", + "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", + "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", + "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", + "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", + "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", + "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", + "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", + "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", + "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", + "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", + "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", + "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", + "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", + "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", + "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", + "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", + "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", + "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", + "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", + "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", + "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", + "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", + "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.54.0.tgz", + "integrity": "sha512-OywsdRHrFvCdvsewAInDKCNyR3laPA2mc9bRYJ6LBp5IyvF3fvXbbNR0bSzHlZVFtn6E0xw2oZlyjg4rKCVcng==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.54.0.tgz", + "integrity": "sha512-Skx39Uv+u7H224Af+bDgNinitlmHyQX1K/atIA32JP3JQw6hVODX5tkbi2zof/E69M1qH2UoN3Xdxgs90mmNYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.54.0.tgz", + "integrity": "sha512-k43D4qta/+6Fq+nCDhhv9yP2HdeKeP56QrUUTW7E6PhZP1US6NDqpJj4MY0jBHlJivVJD5P8NxrjuobZBJTCRw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.54.0.tgz", + "integrity": "sha512-cOo7biqwkpawslEfox5Vs8/qj83M/aZCSSNIWpVzfU2CYHa2G3P1UN5WF01RdTHSgCkri7XOlTdtk17BezlV3A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.54.0.tgz", + "integrity": "sha512-miSvuFkmvFbgJ1BevMa4CPCFt5MPGw094knM64W9I0giUIMMmRYcGW/JWZDriaw/k1kOBtsWh1z6nIFV1vPNtA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.54.0.tgz", + "integrity": "sha512-KGXIs55+b/ZfZsq9aR026tmr/+7tq6VG6MsnrvF4H8VhwflTIuYh+LFUlIsRdQSgrgmtM3fVATzEAj4hBQlaqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.54.0.tgz", + "integrity": "sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.54.0.tgz", + "integrity": "sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.54.0.tgz", + "integrity": "sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.54.0.tgz", + "integrity": "sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.54.0.tgz", + "integrity": "sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.54.0.tgz", + "integrity": "sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.54.0.tgz", + "integrity": "sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.54.0.tgz", + "integrity": "sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.54.0.tgz", + "integrity": "sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.54.0.tgz", + "integrity": "sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.54.0.tgz", + "integrity": "sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.54.0.tgz", + "integrity": "sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.54.0.tgz", + "integrity": "sha512-c2V0W1bsKIKfbLMBu/WGBz6Yci8nJ/ZJdheE0EwB73N3MvHYKiKGs3mVilX4Gs70eGeDaMqEob25Tw2Gb9Nqyw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.54.0.tgz", + "integrity": "sha512-woEHgqQqDCkAzrDhvDipnSirm5vxUXtSKDYTVpZG3nUdW/VVB5VdCYA2iReSj/u3yCZzXID4kuKG7OynPnB3WQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.54.0.tgz", + "integrity": "sha512-dzAc53LOuFvHwbCEOS0rPbXp6SIhAf2txMP5p6mGyOXXw5mWY8NGGbPMPrs4P1WItkfApDathBj/NzMLUZ9rtQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.54.0.tgz", + "integrity": "sha512-hYT5d3YNdSh3mbCU1gwQyPgQd3T2ne0A3KG8KSBdav5TiBg6eInVmV+TeR5uHufiIgSFg0XsOWGW5/RhNcSvPg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", + "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.2", + "@esbuild/android-arm": "0.27.2", + "@esbuild/android-arm64": "0.27.2", + "@esbuild/android-x64": "0.27.2", + "@esbuild/darwin-arm64": "0.27.2", + "@esbuild/darwin-x64": "0.27.2", + "@esbuild/freebsd-arm64": "0.27.2", + "@esbuild/freebsd-x64": "0.27.2", + "@esbuild/linux-arm": "0.27.2", + "@esbuild/linux-arm64": "0.27.2", + "@esbuild/linux-ia32": "0.27.2", + "@esbuild/linux-loong64": "0.27.2", + "@esbuild/linux-mips64el": "0.27.2", + "@esbuild/linux-ppc64": "0.27.2", + "@esbuild/linux-riscv64": "0.27.2", + "@esbuild/linux-s390x": "0.27.2", + "@esbuild/linux-x64": "0.27.2", + "@esbuild/netbsd-arm64": "0.27.2", + "@esbuild/netbsd-x64": "0.27.2", + "@esbuild/openbsd-arm64": "0.27.2", + "@esbuild/openbsd-x64": "0.27.2", + "@esbuild/openharmony-arm64": "0.27.2", + "@esbuild/sunos-x64": "0.27.2", + "@esbuild/win32-arm64": "0.27.2", + "@esbuild/win32-ia32": "0.27.2", + "@esbuild/win32-x64": "0.27.2" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.54.0.tgz", + "integrity": "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.54.0", + "@rollup/rollup-android-arm64": "4.54.0", + "@rollup/rollup-darwin-arm64": "4.54.0", + "@rollup/rollup-darwin-x64": "4.54.0", + "@rollup/rollup-freebsd-arm64": "4.54.0", + "@rollup/rollup-freebsd-x64": "4.54.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.54.0", + "@rollup/rollup-linux-arm-musleabihf": "4.54.0", + "@rollup/rollup-linux-arm64-gnu": "4.54.0", + "@rollup/rollup-linux-arm64-musl": "4.54.0", + "@rollup/rollup-linux-loong64-gnu": "4.54.0", + "@rollup/rollup-linux-ppc64-gnu": "4.54.0", + "@rollup/rollup-linux-riscv64-gnu": "4.54.0", + "@rollup/rollup-linux-riscv64-musl": "4.54.0", + "@rollup/rollup-linux-s390x-gnu": "4.54.0", + "@rollup/rollup-linux-x64-gnu": "4.54.0", + "@rollup/rollup-linux-x64-musl": "4.54.0", + "@rollup/rollup-openharmony-arm64": "4.54.0", + "@rollup/rollup-win32-arm64-msvc": "4.54.0", + "@rollup/rollup-win32-ia32-msvc": "4.54.0", + "@rollup/rollup-win32-x64-gnu": "4.54.0", + "@rollup/rollup-win32-x64-msvc": "4.54.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/terser": { + "version": "5.44.1", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.44.1.tgz", + "integrity": "sha512-t/R3R/n0MSwnnazuPpPNVO60LX0SKL45pyl9YlvxIdkH0Of7D5qM2EVe+yASRIlY5pZ73nclYJfNANGWPwFDZw==", + "dev": true, + "license": "BSD-2-Clause", + "peer": true, + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.15.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/vite": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz", + "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..b175b2f --- /dev/null +++ b/web/package.json @@ -0,0 +1,19 @@ +{ + "name": "lume", + "private": true, + "version": "1.0.0", + "description": "", + "license": "ISC", + "author": "", + "type": "module", + "main": "app.js", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "devDependencies": { + "terser": "^5.44.1", + "vite": "^7.3.0" + } +} diff --git a/data/assets/app.css b/web/src/app.css similarity index 100% rename from data/assets/app.css rename to web/src/app.css diff --git a/web/src/app.js b/web/src/app.js new file mode 100644 index 0000000..24ccdd4 --- /dev/null +++ b/web/src/app.js @@ -0,0 +1,1058 @@ +// Modal management +function openConfigModal() { + document.getElementById("configModal").classList.add("show"); + loadSegmentsConfig(); // Load segments when modal opens +} + +function closeConfigModal() { + document.getElementById("configModal").classList.remove("show"); +} + +// Close modal when clicking outside +document.addEventListener("click", function (e) { + const modal = document.getElementById("configModal"); + if (e.target === modal) { + closeConfigModal(); + } +}); + +// Theme management +function toggleTheme() { + const html = document.documentElement; + const currentTheme = html.getAttribute("data-theme") || "dark"; + const newTheme = currentTheme === "dark" ? "light" : "dark"; + + html.setAttribute("data-theme", newTheme); + localStorage.setItem("theme", newTheme); + + // Update icon + const icon = document.getElementById("themeIcon"); + icon.textContent = newTheme === "dark" ? "🌙" : "☀️"; +} + +// Load theme from localStorage on page load +function loadTheme() { + const savedTheme = localStorage.getItem("theme") || "dark"; + document.documentElement.setAttribute("data-theme", savedTheme); + const icon = document.getElementById("themeIcon"); + icon.textContent = savedTheme === "dark" ? "🌙" : "☀️"; +} + +// Load theme immediately +loadTheme(); + +// Load palette preset (v2 cannot read preset back reliably) +(function loadPalettePreset() { + const saved = localStorage.getItem("palettePreset"); + if (saved) { + const pal = document.getElementById("palette"); + if (pal) pal.value = saved; + selectTileByValue("paletteTiles", saved); + } +})(); + +// State +let sliderBindings = {}; +let effectMetadata = {}; // Map of effect ID -> metadata (usesPalette, usesSpeed, etc.) +let activeSegmentId = 0; // Currently selected segment + +// Segment name helpers (stored in localStorage) +function getSegmentName(segmentId) { + const names = JSON.parse(localStorage.getItem("segmentNames") || "{}"); + return names[segmentId] || null; +} + +function setSegmentName(segmentId, name) { + const names = JSON.parse(localStorage.getItem("segmentNames") || "{}"); + if (name && name.trim()) { + names[segmentId] = name.trim(); + } else { + delete names[segmentId]; + } + localStorage.setItem("segmentNames", JSON.stringify(names)); +} + +// Toast notification +function showToast(message, type = "info") { + const toast = document.getElementById("toast"); + toast.textContent = message; + toast.className = "toast show " + type; + setTimeout(() => toast.classList.remove("show"), 3000); +} + +// API helpers +async function api(endpoint, method = "GET", body = null) { + const options = { + method, + headers: { "Content-Type": "application/json" }, + }; + if (body) options.body = JSON.stringify(body); + + const response = await fetch("/api" + endpoint, options); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + return response.json(); +} + +// v2 API helpers (segments/controller) +const PALETTE_PRESETS = { + rainbow: 0, + lava: 1, + ocean: 2, + party: 3, + forest: 4, + cloud: 5, + heat: 6, +}; + +async function apiV2(path, method = "GET", body = null) { + const options = { + method, + headers: { "Content-Type": "application/json" }, + }; + if (body) options.body = JSON.stringify(body); + + const response = await fetch("/api/v2" + path, options); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + return response.json(); +} + +// WebSocket (optional): server should expose /ws and send {type:'state', controller:{...}, segments:[...]} +let ws = null; +function connectWebSocket() { + try { + const proto = location.protocol === "https:" ? "wss" : "ws"; + ws = new WebSocket(`${proto}://${location.host}/ws`); + ws.onopen = () => console.log("WS connected"); + ws.onclose = () => { + console.log("WS disconnected, retrying..."); + setTimeout(connectWebSocket, 2000); + }; + ws.onmessage = (evt) => { + try { + const msg = JSON.parse(evt.data); + if (msg.type === "state") { + if (msg.controller) applyControllerToUI(msg.controller); + if (msg.segments) { + // Update the currently active segment, not always segment 0 + const activeSeg = msg.segments.find( + (s) => s.id === activeSegmentId + ); + if (activeSeg) applySegmentToUI(activeSeg); + } + } + } catch (e) { + console.warn("WS message parse error", e); + } + }; + } catch (e) { + console.warn("WS init failed", e); + } +} + +function selectActiveSegment(segments) { + // Current UI is single-segment oriented; default to segment 0 if present, else first segment. + if (!Array.isArray(segments) || segments.length === 0) return null; + const s0 = segments.find((s) => s.id === 0); + return s0 || segments[0]; +} + +function applyControllerToUI(controller) { + if (!controller) return; + document.getElementById("powerToggle").checked = controller.power !== false; + // Brightness input is user-controlled only, never updated from server +} + +function applySegmentToUI(seg) { + if (!seg) return; + document.getElementById("effect").value = seg.effect || "rainbow"; + // Speed input is user-controlled only, never updated from server + + // Palette cannot be read back reliably in v2 (see docs); keep last selected in localStorage. + const savedPalette = localStorage.getItem("palettePreset") || "rainbow"; + document.getElementById("palette").value = savedPalette; + + selectTileByValue("effectTiles", seg.effect || "rainbow"); + selectTileByValue("paletteTiles", savedPalette); + + if (seg.primaryColor) { + const [r, g, b] = seg.primaryColor; + document.getElementById("primaryColor").value = rgbToHex(r, g, b); + } + if (seg.secondaryColor) { + const [r, g, b] = seg.secondaryColor; + document.getElementById("secondaryColor").value = rgbToHex(r, g, b); + } +} + +// Load segments into dropdown selector +async function loadSegments() { + try { + const data = await apiV2("/segments"); + const selector = document.getElementById("segmentSelector"); + + if (data.segments && data.segments.length > 0) { + selector.innerHTML = data.segments + .map((seg) => { + const customName = getSegmentName(seg.id); + const label = customName + ? `${customName} (LEDs ${seg.start}-${seg.stop})` + : `Segment ${seg.id} (LEDs ${seg.start}-${seg.stop})`; + return ``; + }) + .join(""); + + // Load the first segment's state + activeSegmentId = data.segments[0].id; + selector.value = activeSegmentId; + await loadSegmentState(activeSegmentId); + } else { + selector.innerHTML = + ''; + } + } catch (e) { + console.error("Failed to load segments:", e); + } +} + +// Switch to a different segment +async function switchSegment(segmentId) { + activeSegmentId = segmentId; + await loadSegmentState(segmentId); +} + +// Load a specific segment's state into UI controls +async function loadSegmentState(segmentId) { + try { + const seg = await apiV2(`/segments/${segmentId}`); + + document.getElementById("effect").value = seg.effect || "rainbow"; + document.getElementById("speed").value = seg.speed || 100; + document.getElementById("speedValue").textContent = seg.speed || 100; + document.getElementById("intensity").value = seg.intensity ?? 128; + document.getElementById("intensityValue").textContent = + seg.intensity ?? 128; + selectTileByValue("effectTiles", seg.effect || "rainbow"); + + const savedPalette = localStorage.getItem("palettePreset") || "rainbow"; + document.getElementById("palette").value = savedPalette; + selectTileByValue("paletteTiles", savedPalette); + + if (seg.primaryColor) { + const [r, g, b] = seg.primaryColor; + document.getElementById("primaryColor").value = rgbToHex(r, g, b); + } + if (seg.secondaryColor) { + const [r, g, b] = seg.secondaryColor; + document.getElementById("secondaryColor").value = rgbToHex(r, g, b); + } + + // Update control visibility based on effect + updateEffectControls(seg.effect); + } catch (e) { + console.error(`Failed to load segment ${segmentId} state:`, e); + } +} + +// v2: Load LED state (controller + segments) +async function loadLedState() { + try { + const state = await apiV2("/segments"); + // Update controller state + document.getElementById("powerToggle").checked = state.power !== false; + document.getElementById("brightness").value = state.brightness ?? 128; + document.getElementById("brightnessValue").textContent = + state.brightness ?? 128; + + // Load segments into dropdown + await loadSegments(); + } catch (e) { + console.error("Failed to load LED state (v2):", e); + } +} + +// Load effect metadata from API +async function loadEffectMetadata() { + try { + const data = await apiV2("/effects"); + if (data.effects) { + data.effects.forEach((effect) => { + effectMetadata[effect.id] = { + usesPalette: effect.usesPalette, + usesPrimaryColor: effect.usesPrimaryColor, + usesSecondaryColor: effect.usesSecondaryColor, + usesSpeed: effect.usesSpeed, + usesIntensity: effect.usesIntensity, + }; + }); + } + } catch (e) { + console.error("Failed to load effect metadata:", e); + } +} + +// Update control visibility based on selected effect +function updateEffectControls(effectId) { + const metadata = effectMetadata[effectId]; + if (!metadata) return; // Metadata not loaded yet or effect not found + + // Palette controls + const paletteSection = document.querySelector(".palette-section"); + if (paletteSection) { + paletteSection.style.display = metadata.usesPalette ? "" : "none"; + } + + // Speed controls + const speedControl = document.querySelector(".speed-control"); + if (speedControl) { + speedControl.style.display = metadata.usesSpeed ? "" : "none"; + } + + // Intensity controls + const intensityControl = document.querySelector(".intensity-control"); + if (intensityControl) { + intensityControl.style.display = metadata.usesIntensity ? "" : "none"; + } + + // Primary color + const primaryColorControl = document.querySelector(".primary-color"); + if (primaryColorControl) { + primaryColorControl.style.display = metadata.usesPrimaryColor ? "" : "none"; + } + + // Secondary color + const secondaryColorControl = document.querySelector(".secondary-color"); + if (secondaryColorControl) { + secondaryColorControl.style.display = metadata.usesSecondaryColor + ? "" + : "none"; + } + + // Hide entire color section if neither color is used + // EXCEPT when custom palette is selected (needs color input) + const colorControls = document.querySelector(".color-controls"); + if (colorControls) { + const selectedPalette = document.getElementById("palette")?.value; + const isCustomPalette = selectedPalette === "custom"; + const usesAnyColor = + metadata.usesPrimaryColor || + metadata.usesSecondaryColor || + (metadata.usesPalette && isCustomPalette); + colorControls.style.display = usesAnyColor ? "" : "none"; + + // Show both colors for custom palette + if (isCustomPalette && metadata.usesPalette) { + if (primaryColorControl) primaryColorControl.style.display = ""; + if (secondaryColorControl) secondaryColorControl.style.display = ""; + } + } +} + +// v2: Apply LED state - updates controller + segment 0 +async function applyLedState() { + const controller = { + power: document.getElementById("powerToggle").checked, + brightness: parseInt(document.getElementById("brightness").value), + }; + + const paletteName = document.getElementById("palette").value; + localStorage.setItem("palettePreset", paletteName); + + const segment = { + effect: document.getElementById("effect").value, + speed: parseInt(document.getElementById("speed").value), + intensity: parseInt(document.getElementById("intensity").value), + primaryColor: hexToRgb(document.getElementById("primaryColor").value), + secondaryColor: hexToRgb(document.getElementById("secondaryColor").value), + palette: PALETTE_PRESETS[paletteName] ?? 0, + }; + + try { + // Update controller + await apiV2("/controller", "PUT", controller); + + // Update active segment + if (activeSegmentId >= 0) { + await apiV2(`/segments/${activeSegmentId}`, "PUT", segment); + showToast("Settings applied!", "success"); + } else { + showToast("No segment selected", "error"); + } + } catch (e) { + showToast("Failed to apply settings (v2)", "error"); + console.error(e); + } +} + +// Load status +async function loadStatus() { + try { + const status = await api("/status"); + + document.getElementById("wifiDot").className = "status-dot"; + document.getElementById("wifiStatus").textContent = + status.wifi || "Connected"; + document.getElementById("ipAddress").textContent = status.ip || "--"; + + const uptime = status.uptime || 0; + const hours = Math.floor(uptime / 3600); + const mins = Math.floor((uptime % 3600) / 60); + document.getElementById("uptime").textContent = `${hours}h ${mins}m`; + + // Update sACN status + const sacn = status.sacn || {}; + const sacnDot = document.getElementById("sacnDot"); + const sacnText = document.getElementById("sacnStatusText"); + if (!sacn.enabled) { + sacnDot.className = "status-dot offline"; + sacnText.textContent = "Not enabled"; + } else if (sacn.receiving) { + sacnDot.className = "status-dot"; + sacnText.textContent = `Receiving (${sacn.packets} pkts, uni ${sacn.universe})`; + } else { + sacnDot.className = "status-dot loading"; + sacnText.textContent = `Waiting for data (uni ${sacn.universe})`; + } + + // Update MQTT status + const mqtt = status.mqtt || {}; + const mqttDot = document.getElementById("mqttDot"); + const mqttText = document.getElementById("mqttStatusText"); + if (!mqtt.enabled) { + mqttDot.className = "status-dot offline"; + mqttText.textContent = "Not enabled"; + } else if (mqtt.connected) { + mqttDot.className = "status-dot"; + mqttText.textContent = `Connected to ${mqtt.broker}`; + } else { + mqttDot.className = "status-dot loading"; + mqttText.textContent = "Connecting..."; + } + } catch (e) { + document.getElementById("wifiDot").className = "status-dot offline"; + document.getElementById("wifiStatus").textContent = "Offline"; + } +} + +// Load LED state (v2) defined above + +// Simple slider handlers - update display on input, apply on release +function setupSlider(inputId, valueId) { + const input = document.getElementById(inputId); + const display = document.getElementById(valueId); + if (!input || !display) return; + + let isDragging = false; + + // Update display while dragging + input.addEventListener("input", () => { + display.textContent = input.value; + }); + + // Mark as dragging + ["pointerdown", "mousedown", "touchstart"].forEach((evt) => { + input.addEventListener(evt, () => { + isDragging = true; + }); + }); + + // Send update when released + ["pointerup", "mouseup", "touchend"].forEach((evt) => { + input.addEventListener(evt, () => { + if (isDragging) { + isDragging = false; + applyLedState(); + } + }); + }); +} + +setupSlider("brightness", "brightnessValue"); +setupSlider("speed", "speedValue"); +setupSlider("intensity", "intensityValue"); + +// Tile selector functions - auto-apply on selection +function selectEffect(tile) { + const container = document.getElementById("effectTiles"); + container + .querySelectorAll(".tile") + .forEach((t) => t.classList.remove("selected")); + tile.classList.add("selected"); + const effectId = tile.dataset.value; + document.getElementById("effect").value = effectId; + updateEffectControls(effectId); + applyLedState(); +} + +function selectPalette(tile) { + localStorage.setItem("palettePreset", tile.dataset.value); + const container = document.getElementById("paletteTiles"); + container + .querySelectorAll(".tile") + .forEach((t) => t.classList.remove("selected")); + tile.classList.add("selected"); + document.getElementById("palette").value = tile.dataset.value; + + // Update control visibility (custom palette needs color pickers) + const currentEffect = document.getElementById("effect").value; + updateEffectControls(currentEffect); + + applyLedState(); +} + +function selectTileByValue(containerId, value) { + const container = document.getElementById(containerId); + if (!container) return; + container.querySelectorAll(".tile").forEach((t) => { + if (t.dataset.value === value) { + t.classList.add("selected"); + } else { + t.classList.remove("selected"); + } + }); +} + +// Apply LED state (v2) defined above + +// Nightlight functions +let nightlightPollInterval = null; + +async function loadNightlightStatus() { + try { + const status = await api("/nightlight"); + updateNightlightUI(status); + } catch (e) { + console.error("Failed to load nightlight status:", e); + } +} + +// Flag to prevent toggle change handler from firing during programmatic updates +let updatingNightlightUI = false; + +function updateNightlightUI(status) { + const isActive = status.active; + const controls = document.getElementById("nightlightControls"); + const toggle = document.getElementById("nightlightToggle"); + + // Prevent change handler from running when we set checked programmatically + updatingNightlightUI = true; + toggle.checked = isActive; + updatingNightlightUI = false; + + document.getElementById("startNightlightBtn").style.display = isActive + ? "none" + : ""; + document.getElementById("stopNightlightBtn").style.display = isActive + ? "" + : "none"; + document.getElementById("nightlightProgress").style.display = isActive + ? "" + : "none"; + + // Expand/collapse controls based on active state + if (isActive) { + controls.classList.add("expanded"); + } else { + controls.classList.remove("expanded"); + } + + if (isActive) { + const progress = Math.round((status.progress || 0) * 100); + document.getElementById("nightlightProgressBar").style.width = + progress + "%"; + document.getElementById("nightlightProgressText").textContent = + progress + "% complete"; + + // Start polling for progress updates + if (!nightlightPollInterval) { + nightlightPollInterval = setInterval(loadNightlightStatus, 2000); + } + } else { + // Stop polling + if (nightlightPollInterval) { + clearInterval(nightlightPollInterval); + nightlightPollInterval = null; + } + } +} + +function toggleNightlightControls(show) { + const controls = document.getElementById("nightlightControls"); + if (show) { + controls.classList.add("expanded"); + } else { + controls.classList.remove("expanded"); + } +} + +async function startNightlight() { + const durationMinutes = parseInt( + document.getElementById("nightlightDuration").value + ); + const targetBrightness = parseInt( + document.getElementById("nightlightTarget").value + ); + + try { + const result = await api("/nightlight", "POST", { + duration: durationMinutes * 60, // Convert to seconds + targetBrightness: targetBrightness, + }); + showToast("Nightlight started!", "success"); + // Wait a moment for server to process, then start polling + setTimeout(() => { + loadNightlightStatus(); + }, 200); + } catch (e) { + showToast("Failed to start nightlight", "error"); + // Reset toggle on error + const toggle = document.getElementById("nightlightToggle"); + updatingNightlightUI = true; + toggle.checked = false; + updatingNightlightUI = false; + } +} + +async function stopNightlight() { + try { + await api("/nightlight/stop", "POST", {}); + showToast("Nightlight stopped", "success"); + // Stop polling immediately + if (nightlightPollInterval) { + clearInterval(nightlightPollInterval); + nightlightPollInterval = null; + } + // Update UI immediately without waiting for server + const toggle = document.getElementById("nightlightToggle"); + const controls = document.getElementById("nightlightControls"); + updatingNightlightUI = true; + toggle.checked = false; + updatingNightlightUI = false; + controls.classList.remove("expanded"); + document.getElementById("startNightlightBtn").style.display = ""; + document.getElementById("stopNightlightBtn").style.display = "none"; + document.getElementById("nightlightProgress").style.display = "none"; + } catch (e) { + showToast("Failed to stop nightlight", "error"); + } +} + +// Load config +async function loadConfig() { + try { + const config = await api("/config"); + + document.getElementById("wifiSSID").value = config.wifiSSID || ""; + document.getElementById("ledCount").value = config.ledCount || 160; + + // AI settings + document.getElementById("aiApiKey").value = + config.aiApiKey && config.aiApiKey !== "****" ? "" : ""; + document.getElementById("aiModel").value = + config.aiModel || "claude-3-5-sonnet-20241022"; + + // sACN settings + const sacnEnabled = config.sacnEnabled || false; + document.getElementById("sacnEnabled").checked = sacnEnabled; + document.getElementById("sacnUniverse").value = config.sacnUniverse || 1; + document.getElementById("sacnStartChannel").value = + config.sacnStartChannel || 1; + toggleSettings("sacnSettings", sacnEnabled); + + // MQTT settings + const mqttEnabled = config.mqttEnabled || false; + document.getElementById("mqttEnabled").checked = mqttEnabled; + document.getElementById("mqttBroker").value = config.mqttBroker || ""; + document.getElementById("mqttPort").value = config.mqttPort || 1883; + document.getElementById("mqttUsername").value = + config.mqttUsername && config.mqttUsername !== "****" + ? config.mqttUsername + : ""; + document.getElementById("mqttPassword").value = + config.mqttPassword && config.mqttPassword !== "****" ? "" : ""; + document.getElementById("mqttTopicPrefix").value = + config.mqttTopicPrefix || "lume"; + toggleSettings("mqttSettings", mqttEnabled); + } catch (e) { + console.error("Failed to load config:", e); + } +} + +// Load segments for config modal +async function loadSegmentsConfig() { + try { + const data = await apiV2("/segments"); + const container = document.getElementById("segmentsList"); + + if (!data.segments || data.segments.length === 0) { + container.innerHTML = + '

No segments configured

'; + return; + } + + container.innerHTML = data.segments + .map((seg) => { + const customName = getSegmentName(seg.id); + const displayName = customName || `Segment ${seg.id}`; + return ` +
+
+
${displayName}
+
+ LEDs ${seg.start}-${seg.stop} (${seg.length + } LEDs) + ${seg.effect ? `• ${seg.effect}` : ""} +
+
+ + ${seg.length > 1 + ? `` + : "" + } + +
+ `; + }) + .join(""); + } catch (e) { + console.error("Failed to load segments:", e); + } +} + +async function editSegmentName(id) { + const currentName = getSegmentName(id) || ""; + const newName = prompt(`Enter name for segment ${id}:`, currentName); + + if (newName !== null) { + setSegmentName(id, newName); + await loadSegmentsConfig(); // Refresh config list + await loadSegments(); // Refresh dropdown + } +} + +async function splitSegment(id, start, length) { + const splitAt = prompt( + `Split segment ${id} at LED position? (${start + 1} to ${start + length - 1 + })` + ); + if (!splitAt) return; + + const splitPos = parseInt(splitAt); + if (isNaN(splitPos) || splitPos <= start || splitPos >= start + length) { + showToast("Invalid split position", "error"); + return; + } + + try { + // Calculate new segment lengths + const firstLength = splitPos - start; + const secondLength = length - firstLength; + + // Delete original segment + await fetch("/api/v2/segments/" + id, { method: "DELETE" }); + + // Create first segment + await apiV2("/segments", "POST", { + start: start, + length: firstLength, + }); + + // Create second segment + await apiV2("/segments", "POST", { + start: splitPos, + length: secondLength, + }); + + showToast("Segment split successfully!", "success"); + loadSegmentsConfig(); + } catch (e) { + showToast("Failed to split segment", "error"); + console.error(e); + } +} + +async function addSegment() { + const start = parseInt(document.getElementById("newSegmentStart").value); + const length = parseInt(document.getElementById("newSegmentLength").value); + + if (isNaN(start) || isNaN(length) || start < 0 || length < 1) { + showToast("Invalid segment range", "error"); + return; + } + + try { + await apiV2("/segments", "POST", { + start: start, + length: length, + }); + showToast("Segment created!", "success"); + loadSegmentsConfig(); + + // Update start field for next segment + document.getElementById("newSegmentStart").value = start + length; + } catch (e) { + showToast("Failed to create segment", "error"); + console.error(e); + } +} + +async function deleteSegmentConfig(id) { + if (!confirm(`Delete segment ${id}?`)) { + return; + } + + try { + await fetch("/api/v2/segments/" + id, { method: "DELETE" }); + showToast("Segment deleted", "success"); + loadSegmentsConfig(); + } catch (e) { + showToast("Failed to delete segment", "error"); + console.error(e); + } +} + +// Save config +async function saveConfig() { + const config = { + wifiSSID: document.getElementById("wifiSSID").value, + wifiPassword: document.getElementById("wifiPassword").value, + ledCount: parseInt(document.getElementById("ledCount").value), + aiApiKey: document.getElementById("aiApiKey").value, + aiModel: document.getElementById("aiModel").value, + sacnEnabled: document.getElementById("sacnEnabled").checked, + sacnUniverse: parseInt(document.getElementById("sacnUniverse").value), + sacnStartChannel: parseInt( + document.getElementById("sacnStartChannel").value + ), + mqttEnabled: document.getElementById("mqttEnabled").checked, + mqttBroker: document.getElementById("mqttBroker").value, + mqttPort: parseInt(document.getElementById("mqttPort").value), + mqttUsername: document.getElementById("mqttUsername").value, + mqttPassword: document.getElementById("mqttPassword").value, + mqttTopicPrefix: document.getElementById("mqttTopicPrefix").value, + }; + + // Don't send masked password/key + if (config.wifiPassword === "") delete config.wifiPassword; + if (config.mqttPassword === "") delete config.mqttPassword; + if (config.aiApiKey === "") delete config.aiApiKey; + + try { + await api("/config", "POST", config); + showToast("Configuration saved!", "success"); + loadConfig(); + } catch (e) { + showToast("Failed to save configuration", "error"); + } +} + +// Scene management +async function loadScenes() { + try { + const scenes = await api("/scenes"); + const container = document.getElementById("scenesList"); + + if (!scenes || scenes.length === 0) { + container.innerHTML = + '

No saved scenes yet

'; + return; + } + + container.innerHTML = scenes + .map( + (scene) => ` +
+ ${escapeHtml( + scene.name + )} +
+ + +
+
+ ` + ) + .join(""); + } catch (e) { + console.error("Failed to load scenes:", e); + } +} + +function escapeHtml(text) { + const div = document.createElement("div"); + div.textContent = text; + return div.innerHTML; +} + +async function applyScene(id) { + try { + const response = await fetch("/api/scenes/" + id + "/apply", { + method: "POST", + }); + + if (!response.ok) { + const result = await response.json(); + showToast("Failed: " + (result.error || response.status), "error"); + return; + } + + showToast("Scene applied!", "success"); + loadLedState(); + } catch (e) { + showToast("Failed to apply scene: " + e.message, "error"); + } +} + +async function deleteScene(id) { + if (!confirm("Delete this scene?")) { + return; + } + + try { + await fetch("/api/scenes/" + id, { method: "DELETE" }); + showToast("Scene deleted", "success"); + loadScenes(); + } catch (e) { + showToast("Failed to delete scene", "error"); + } +} + +// Color helpers +function hexToRgb(hex) { + const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); + return result + ? [ + parseInt(result[1], 16), + parseInt(result[2], 16), + parseInt(result[3], 16), + ] + : [0, 0, 255]; +} + +function rgbToHex(r, g, b) { + return "#" + [r, g, b].map((x) => x.toString(16).padStart(2, "0")).join(""); +} + +// Toggle settings visibility +function toggleSettings(settingsId, show) { + const settings = document.getElementById(settingsId); + if (!settings) return; + settings.style.maxHeight = show ? "500px" : "0"; +} + +// Event listeners - sliders handled by SliderBinding helpers + +// Color pickers auto-apply on change (when picker closes) +document.getElementById("primaryColor").addEventListener("change", function () { + applyLedState(); +}); + +document + .getElementById("secondaryColor") + .addEventListener("change", function () { + applyLedState(); + }); + +document.getElementById("powerToggle").addEventListener("change", function () { + applyLedState(); +}); + +// Nightlight slider listeners +document + .getElementById("nightlightDuration") + .addEventListener("input", function () { + document.getElementById("nightlightDurationValue").textContent = + this.value + " min"; + }); + +document + .getElementById("nightlightTarget") + .addEventListener("input", function () { + const val = parseInt(this.value); + document.getElementById("nightlightTargetValue").textContent = + val === 0 ? "0 (off)" : val; + }); + +document + .getElementById("nightlightToggle") + .addEventListener("change", async function () { + // Ignore programmatic changes + if (updatingNightlightUI) return; + + if (this.checked) { + // Toggle ON - expand controls to show settings + toggleNightlightControls(true); + } else { + // Toggle OFF - stop if running, otherwise just hide + await stopNightlight(); + } + }); + +// AI Prompt functions +async function sendAIPrompt() { + const prompt = document.getElementById("aiPrompt").value.trim(); + if (!prompt) { + showToast("Please enter a prompt", "error"); + return; + } + + const statusDiv = document.getElementById("aiStatus"); + const statusText = document.getElementById("aiStatusText"); + + statusDiv.style.display = "block"; + statusText.textContent = "Processing your request..."; + + try { + const result = await api("/prompt", "POST", { prompt: prompt }); + + if (result.success) { + showToast("✨ Lights updated!", "success"); + statusText.textContent = result.message || "Applied successfully!"; + setTimeout(() => { + statusDiv.style.display = "none"; + }, 3000); + + // Reload LED state to show changes + await loadLedState(); + } else { + showToast(result.error || "Failed to process prompt", "error"); + statusDiv.style.display = "none"; + } + } catch (e) { + showToast("Error: " + (e.message || "Network error"), "error"); + statusDiv.style.display = "none"; + console.error("AI prompt error:", e); + } +} + +// sACN and MQTT toggle handlers +document.getElementById("sacnEnabled").addEventListener("change", function () { + toggleSettings("sacnSettings", this.checked); +}); + +document.getElementById("mqttEnabled").addEventListener("change", function () { + toggleSettings("mqttSettings", this.checked); +}); + +// Initialize +async function initialize() { + loadStatus(); + await loadEffectMetadata(); // Load effect metadata FIRST + await loadLedState(); // Then load LED state (which needs metadata) + loadConfig(); + loadScenes(); + loadNightlightStatus(); + setInterval(loadStatus, 10000); +} + +initialize(); + +// Start WebSocket (optional) +connectWebSocket(); diff --git a/web/vite.config.js b/web/vite.config.js new file mode 100644 index 0000000..5fadbca --- /dev/null +++ b/web/vite.config.js @@ -0,0 +1,27 @@ +import { defineConfig } from 'vite'; +import { resolve } from 'path'; + +export default defineConfig({ + server: { + port: 5173, + open: true, + // Proxy API requests to the ESP32 device during development + proxy: { + '/api': { + target: 'http://lume.local', + changeOrigin: true, + }, + '/ws': { + target: 'ws://lume.local', + ws: true, + }, + }, + }, + + // Resolve assets from the assets directory + resolve: { + alias: { + '@': resolve(__dirname), + }, + }, +});