diff --git a/web/.env.example b/web/.env.example index eac2d21..533c83b 100644 --- a/web/.env.example +++ b/web/.env.example @@ -1,3 +1,6 @@ CALORIE_AI_WEB_USER=admin CALORIE_AI_WEB_PASSWORD=change-this-password CALORIE_AI_SESSION_SECRET=change-this-session-secret +LITELLM_API_BASE=http://litellm:4000/v1 +LITELLM_API_KEY=change-this-litellm-key +LITELLM_MODEL=openrouter-claude-haiku-4.5 diff --git a/web/Dockerfile b/web/Dockerfile index 8ee5078..785f200 100644 --- a/web/Dockerfile +++ b/web/Dockerfile @@ -1,8 +1,12 @@ FROM node:22-alpine WORKDIR /app -COPY package.json server.js ./ +COPY package*.json ./ +RUN npm ci +COPY index.html vite.config.js server.js ./ +COPY src ./src COPY public ./public +RUN npm run build && npm prune --omit=dev ENV PORT=8080 EXPOSE 8080 diff --git a/web/docker-compose.yml b/web/docker-compose.yml index a04d880..6fe9ed0 100644 --- a/web/docker-compose.yml +++ b/web/docker-compose.yml @@ -3,7 +3,19 @@ services: build: . container_name: calorie-ai-web restart: unless-stopped + env_file: + - /home/danvics/docker/quiz/backend/.env + environment: + LITELLM_API_BASE: http://litellm:4000/v1 + LITELLM_MODEL: openrouter-claude-haiku-4.5 ports: - "127.0.0.1:8095:8080" volumes: - ./data:/app/data + networks: + - default + - litellm_default + +networks: + litellm_default: + external: true diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..a31b6a2 --- /dev/null +++ b/web/index.html @@ -0,0 +1,13 @@ + + + + + + Calorie AI + + + +
+ + + diff --git a/web/package-lock.json b/web/package-lock.json index bd06c21..0eb16f4 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -7,10 +7,120 @@ "": { "name": "calorie-ai-web", "version": "0.1.0", + "dependencies": { + "@sveltejs/vite-plugin-svelte": "^7.1.2", + "@tailwindcss/vite": "^4.3.0", + "svelte": "^5.55.8", + "tailwindcss": "^4.3.0", + "vite": "^8.0.13" + }, "devDependencies": { "@playwright/test": "^1.56.1" } }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "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==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@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==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "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==", + "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==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.130.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.130.0.tgz", + "integrity": "sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, "node_modules/@playwright/test": { "version": "1.60.0", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz", @@ -27,6 +137,677 @@ "node": ">=18" } }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.1.tgz", + "integrity": "sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.1.tgz", + "integrity": "sha512-cKnAhWEsV7TPcA/5EAteDp6KcJZBQ2G+BqE7zayMMi7kMvwRsbv7WT9aOnn0WNl4SKEIf43vjS31iUPu80nzXg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.1.tgz", + "integrity": "sha512-YKrVwQjIRBPo+5G/u03wGjbdy4q7pyzCe93DK9VJ7zkVmeg8LJ7GbgsiHWdR4xSoe4CAXRD7Bcjgbtr64bkXNg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.1.tgz", + "integrity": "sha512-z/oBsREo46SsFqBwYtFe0kpJeBijAT48O/WXLI4suiCLBkr03RTtTJMCzSdDd2znlh8VJizL09XVkQgk8IZonw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.1.tgz", + "integrity": "sha512-ik8q7GM11zxvYxFc2PeDcT6TBvhCQMaUxfph/M5l9sKuTs/Sjg3L+Byw0F7w0ZVLBZmx30P+gG0ECzzN+MFcmQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.1.tgz", + "integrity": "sha512-QoSx2EkyrrdZ6kcyE8stqZ62t0Yra8Fs5ia9lOxJrh6TMQJK7gQKmscdTHf7pOXKREKrVwOtJcQG3qVSfc866A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.1.tgz", + "integrity": "sha512-uwNwFpwKeNiZawfAWBgg0VIztPTV3ihhh1vV334h9ivnNLorxnQMU6Fz8wG1Zb4Qh9LC1/MkcyT3YlDXG3Rsgg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.1.tgz", + "integrity": "sha512-zY1bul7OWr7DFBiJ++wofXvnr8B45ce3QsQUhKrIhXsygAh7bTkwyeM1bi1a2g5C/yC/N8TZyGDEoMfm/l9mpg==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.1.tgz", + "integrity": "sha512-0frlsT/f4Ft6I7SMESTKnF3cZsdicQn1dCMkF/jT9wDLE+gGoiQfv1nmT9e+s7s/fekvvy6tZM2jHvI2tkbJDQ==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.1.tgz", + "integrity": "sha512-XABVmGp9Tg0WspTVvwduTc4fpqy6JnAUrSQe6OuyqD/03nI7r0O9OWUkMIwFrjKAIqolvqoA4ZrJppgwE0Gxmw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.1.tgz", + "integrity": "sha512-bV4fzswuzVcKD90o/VM6QqKxnxlDq0g2BISDLNVmxrnhpv1DDbyPhCIjYfvzYLV+MvkKKnQt2Q6AO86SEBULUQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.1.tgz", + "integrity": "sha512-/Mh0Zhq3OP7fVs0kcQHZP6lZEthMGTaSf8UBQYSFEZDWGXXlEC+nJ6EqenaK2t4LBXMe3A+K/G2BVXXdtOr4PQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.1.tgz", + "integrity": "sha512-+1xc9X45l8ufsBAm6Gjvx2qDRIY9lTVt0cgWNcJ+1gdhXvkbxePA60yRTwSTuXL09CMhyJmjpV7E3NoyxbqFQQ==", + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.1.tgz", + "integrity": "sha512-1D+UqZdfnuR+Jy1GgMJwi85bD40H21uNmOPRWQhw4oRSuolZ/B5rixZ45DK2KXOTCvmVCecauWgEhbw8bI7tOw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.1.tgz", + "integrity": "sha512-INAycaWuhlOK3wk4mRHGsdgwYWmd9cChdPdE9bwWmy6rn9VqVNYNFGhOdXrofXUxwHIncSiPNb8tNm8knDVIeQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "license": "MIT" + }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.9.tgz", + "integrity": "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA==", + "license": "MIT", + "peerDependencies": { + "acorn": "^8.9.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-7.1.2.tgz", + "integrity": "sha512-DrUBA2UXRfDmUX/ZTiEopd3X40yavsJF1FX2RygcuIScHL7o5YX1fMvoYnDhjeJQC4weCOklirpNWlcb2NiSeA==", + "license": "MIT", + "dependencies": { + "deepmerge": "^4.3.1", + "magic-string": "^0.30.21", + "obug": "^2.1.0", + "vitefu": "^1.1.2" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24" + }, + "peerDependencies": { + "svelte": "^5.46.4", + "vite": "^8.0.0-beta.7 || ^8.0.0" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.0.tgz", + "integrity": "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.21.0", + "jiti": "^2.6.1", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.0" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.0.tgz", + "integrity": "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==", + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.0", + "@tailwindcss/oxide-darwin-arm64": "4.3.0", + "@tailwindcss/oxide-darwin-x64": "4.3.0", + "@tailwindcss/oxide-freebsd-x64": "4.3.0", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.0", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.0", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.0", + "@tailwindcss/oxide-linux-x64-musl": "4.3.0", + "@tailwindcss/oxide-wasm32-wasi": "4.3.0", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.0", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.0" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.0.tgz", + "integrity": "sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.0.tgz", + "integrity": "sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.0.tgz", + "integrity": "sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.0.tgz", + "integrity": "sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.0.tgz", + "integrity": "sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.0.tgz", + "integrity": "sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.0.tgz", + "integrity": "sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.0.tgz", + "integrity": "sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.0.tgz", + "integrity": "sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.0.tgz", + "integrity": "sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.10.0", + "@emnapi/runtime": "^1.10.0", + "@emnapi/wasi-threads": "^1.2.1", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.0.tgz", + "integrity": "sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.0.tgz", + "integrity": "sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.0.tgz", + "integrity": "sha512-t6J3OrB5Fc0ExuhohouH0fWUGMYL6PTLhW+E7zIk/pdbnJARZDCwjBznFnkh5ynRnIRSI4YjtTH0t6USjJISrw==", + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.0", + "@tailwindcss/oxide": "4.3.0", + "tailwindcss": "4.3.0" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "license": "MIT" + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/aria-query": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", + "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/devalue": { + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz", + "integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==", + "license": "MIT" + }, + "node_modules/enhanced-resolve": { + "version": "5.21.5", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.5.tgz", + "integrity": "sha512-mLCNbrQli11K1ySUmuNt4ZUB3OpGIDq4q2vTBTf5cL2lpsRjI9QKqSD0ndjW8FyvcW/Jj46gMe9syyHAsvMa/A==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "license": "MIT" + }, + "node_modules/esrap": { + "version": "2.2.9", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.9.tgz", + "integrity": "sha512-4KijP+NxCWthMCUC3qHbE6n4vCjqgJS1uAYKhuT/GWfFTf1Qyive2TgOjep+gzbSzRfnNyaN/UU9YmdOt8Eg0A==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + }, + "peerDependencies": { + "@typescript-eslint/types": "^8.2.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/types": { + "optional": true + } + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, "node_modules/fsevents": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", @@ -42,6 +823,340 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "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/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/playwright": { "version": "1.60.0", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", @@ -73,6 +1188,261 @@ "engines": { "node": ">=18" } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "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.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rolldown": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.1.tgz", + "integrity": "sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ==", + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.130.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.1", + "@rolldown/binding-darwin-arm64": "1.0.1", + "@rolldown/binding-darwin-x64": "1.0.1", + "@rolldown/binding-freebsd-x64": "1.0.1", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.1", + "@rolldown/binding-linux-arm64-gnu": "1.0.1", + "@rolldown/binding-linux-arm64-musl": "1.0.1", + "@rolldown/binding-linux-ppc64-gnu": "1.0.1", + "@rolldown/binding-linux-s390x-gnu": "1.0.1", + "@rolldown/binding-linux-x64-gnu": "1.0.1", + "@rolldown/binding-linux-x64-musl": "1.0.1", + "@rolldown/binding-openharmony-arm64": "1.0.1", + "@rolldown/binding-wasm32-wasi": "1.0.1", + "@rolldown/binding-win32-arm64-msvc": "1.0.1", + "@rolldown/binding-win32-x64-msvc": "1.0.1" + } + }, + "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==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/svelte": { + "version": "5.55.8", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.8.tgz", + "integrity": "sha512-4D6lyrMHmDaZalQOEBMCWCCidyZjSnec14/oPn0k627G6goxcck9xqMwz1tFLlQz+ZFvtTTHfFOlUayuAz0z6Q==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@sveltejs/acorn-typescript": "^1.0.5", + "@types/estree": "^1.0.5", + "@types/trusted-types": "^2.0.7", + "acorn": "^8.12.1", + "aria-query": "5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "devalue": "^5.8.1", + "esm-env": "^1.2.1", + "esrap": "^2.2.4", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz", + "integrity": "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==", + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "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==", + "license": "0BSD", + "optional": true + }, + "node_modules/vite": { + "version": "8.0.13", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.13.tgz", + "integrity": "sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw==", + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.14", + "rolldown": "1.0.1", + "tinyglobby": "^0.2.16" + }, + "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", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.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 + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/vitefu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", + "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==", + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/zimmerframe": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", + "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", + "license": "MIT" } } } diff --git a/web/package.json b/web/package.json index 9651369..f44e2da 100644 --- a/web/package.json +++ b/web/package.json @@ -3,10 +3,18 @@ "version": "0.1.0", "private": true, "scripts": { + "dev": "vite", + "build": "vite build", "start": "node server.js", "test": "playwright test" }, "devDependencies": { - "@playwright/test": "^1.56.1" - } + "@sveltejs/vite-plugin-svelte": "^7.1.2", + "@tailwindcss/vite": "^4.3.0", + "@playwright/test": "^1.56.1", + "svelte": "^5.55.8", + "tailwindcss": "^4.3.0", + "vite": "^8.0.13" + }, + "dependencies": {} } diff --git a/web/playwright.config.js b/web/playwright.config.js index 2891810..a0aa199 100644 --- a/web/playwright.config.js +++ b/web/playwright.config.js @@ -4,7 +4,7 @@ module.exports = defineConfig({ testDir: './tests', timeout: 30000, webServer: { - command: 'node server.js', + command: 'npm run build && node server.js', url: 'http://127.0.0.1:8096', reuseExistingServer: !process.env.CI, env: { diff --git a/web/public/app.js b/web/public/app.js deleted file mode 100644 index 2c39d7e..0000000 --- a/web/public/app.js +++ /dev/null @@ -1,338 +0,0 @@ -const $ = (id) => document.getElementById(id); -const settingsKey = 'calorie-ai-web-settings'; -const entriesKey = 'calorie-ai-web-entries'; - -let selectedImageDataUrl = ''; -let selectedImageName = ''; - -function setText(id, value) { - const element = $(id); - if (element) element.textContent = value; -} - -function todayKey() { - return dateKey(new Date()); -} - -function dateKey(date) { - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, '0'); - const day = String(date.getDate()).padStart(2, '0'); - return `${year}-${month}-${day}`; -} - -function readJson(key, fallback) { - try { return JSON.parse(localStorage.getItem(key)) || fallback; } catch { return fallback; } -} - -function saveJson(key, value) { - localStorage.setItem(key, JSON.stringify(value)); -} - -function settings() { - return readJson(settingsKey, { baseUrl: '', apiKey: '', visionModel: 'gpt-4o-mini', taskModel: 'gpt-4o-mini' }); -} - -function entries() { - return readJson(entriesKey, []); -} - -function setStatus(message, error = false) { - $('status').textContent = message; - $('status').className = `status ${error ? 'error' : 'ok'}`; -} - -function fileToDataUrl(file) { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = () => resolve(reader.result); - reader.onerror = () => reject(reader.error); - reader.readAsDataURL(file); - }); -} - -async function chatCompletion(body) { - const cfg = settings(); - const response = await fetch('/api/chat', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ baseUrl: cfg.baseUrl, apiKey: cfg.apiKey, body }), - }); - const text = await response.text(); - if (!response.ok) throw new Error(text); - const json = JSON.parse(text); - return json.choices[0].message.content.trim(); -} - -async function requestVisionEstimate(description, measure, imageDataUrl) { - const cfg = settings(); - if (!cfg.visionModel) return ''; - return chatCompletion({ - model: cfg.visionModel, - temperature: 0.15, - messages: [{ - role: 'user', - content: [ - { type: 'text', text: `Analyze this meal photo for nutrition tracking. Estimate visible foods, portions, calories, protein grams, carbs grams, fat grams, fruit servings, and vegetable servings. One produce serving is about 80g or one cup raw leafy vegetables. Return concise JSON if possible. User description: ${description}; user portion note: ${measure}` }, - { type: 'image_url', image_url: { url: imageDataUrl } }, - ], - }], - }); -} - -async function requestTaskEstimate(description, measure, visionEstimate) { - const cfg = settings(); - return chatCompletion({ - model: cfg.taskModel, - temperature: 0.1, - messages: [ - { role: 'system', content: 'Return strict JSON only. Do not wrap the response in markdown.' }, - { role: 'user', content: `You are a nutrition logging assistant. Produce a final nutrition estimate for one meal. If a vision estimate is provided, use it as primary evidence for visible ingredients and portion sizes. Return only JSON with keys mealName, calories, proteinGrams, carbsGrams, fatGrams, fruitServings, vegetableServings, foodGroups, notes. Use integer grams and calories. fruitServings and vegetableServings can be decimal numbers. foodGroups should be a short comma-separated string. Mention uncertainty in notes.\n\nUser description: ${description}\nUser measure: ${measure}\nVision nutrition estimate: ${visionEstimate}` }, - ], - }); -} - -function parseEstimate(content) { - let cleaned = content.trim().replace(/^```json/i, '').replace(/^```/, '').replace(/```$/, '').trim(); - const start = cleaned.indexOf('{'); - const end = cleaned.lastIndexOf('}'); - if (start >= 0 && end > start) cleaned = cleaned.slice(start, end + 1); - const parsed = JSON.parse(cleaned); - return { - mealName: parsed.mealName || 'Meal', - calories: Math.max(0, Number.parseInt(parsed.calories || 0, 10)), - proteinGrams: Math.max(0, Number.parseInt(parsed.proteinGrams || 0, 10)), - carbsGrams: Math.max(0, Number.parseInt(parsed.carbsGrams || 0, 10)), - fatGrams: Math.max(0, Number.parseInt(parsed.fatGrams || 0, 10)), - fruitServings: Math.max(0, Number(parsed.fruitServings ?? parsed.fruitsServings ?? 0)), - vegetableServings: Math.max(0, Number(parsed.vegetableServings ?? parsed.vegetablesServings ?? 0)), - foodGroups: Array.isArray(parsed.foodGroups) ? parsed.foodGroups.join(', ') : String(parsed.foodGroups || ''), - notes: parsed.notes || '', - raw: content, - }; -} - -function totalsFor(date) { - return entries().filter(e => e.date === date).reduce((sum, e) => ({ - meals: sum.meals + 1, - calories: sum.calories + (e.calories || 0), - protein: sum.protein + (e.proteinGrams || 0), - carbs: sum.carbs + (e.carbsGrams || 0), - fat: sum.fat + (e.fatGrams || 0), - fruit: sum.fruit + (e.fruitServings || 0), - vegetables: sum.vegetables + (e.vegetableServings || 0), - }), { meals: 0, calories: 0, protein: 0, carbs: 0, fat: 0, fruit: 0, vegetables: 0 }); -} - -function last7Days() { - const now = new Date(); - return Array.from({ length: 7 }, (_, index) => { - const d = new Date(now); - d.setDate(now.getDate() - (6 - index)); - const date = dateKey(d); - return { date, label: d.toLocaleDateString(undefined, { weekday: 'short' }), ...totalsFor(date) }; - }); -} - -function drawMacroChart(total) { - const canvas = $('macroChart'); - const ctx = canvas.getContext('2d'); - ctx.clearRect(0, 0, canvas.width, canvas.height); - const values = [total.protein, total.carbs, total.fat]; - const colors = ['#2f7d59', '#da9648', '#6177c4']; - const labels = [`Protein ${total.protein}g`, `Carbs ${total.carbs}g`, `Fat ${total.fat}g`]; - const sum = values.reduce((a, b) => a + b, 0) || 1; - let start = -Math.PI / 2; - values.forEach((value, index) => { - const arc = (value / sum) * Math.PI * 2; - ctx.beginPath(); - ctx.lineWidth = 34; - ctx.strokeStyle = colors[index]; - ctx.arc(120, 130, 70, start, start + arc); - ctx.stroke(); - start += arc; - }); - ctx.fillStyle = '#17251d'; - ctx.font = '700 20px sans-serif'; - ctx.fillText('Today', 240, 88); - ctx.font = '15px sans-serif'; - labels.forEach((label, index) => { - ctx.fillStyle = colors[index]; - ctx.fillRect(240, 112 + index * 34, 14, 14); - ctx.fillStyle = '#17251d'; - ctx.fillText(label, 264, 125 + index * 34); - }); -} - -function drawBars(canvasId, days, mode) { - const canvas = $(canvasId); - const ctx = canvas.getContext('2d'); - ctx.clearRect(0, 0, canvas.width, canvas.height); - const values = days.map(d => mode === 'calories' ? d.calories : d.fruit + d.vegetables); - const max = Math.max(1, ...values); - const left = 26; - const bottom = canvas.height - 44; - const height = canvas.height - 76; - const gap = 14; - const bar = (canvas.width - left * 2 - gap * 6) / 7; - days.forEach((day, index) => { - const x = left + index * (bar + gap); - const h = values[index] / max * height; - ctx.fillStyle = '#eef3ee'; - roundRect(ctx, x, bottom - height, bar, height, 12); - ctx.fillStyle = mode === 'calories' ? '#2f7d59' : '#da9648'; - roundRect(ctx, x, bottom - h, bar, h, 12); - if (mode === 'produce') { - const vegH = day.vegetables / max * height; - ctx.fillStyle = '#2f7d59'; - roundRect(ctx, x + bar * .52, bottom - vegH, bar * .48, vegH, 10); - } - ctx.fillStyle = '#5b635c'; - ctx.font = '13px sans-serif'; - ctx.fillText(day.label, x, bottom + 24); - }); -} - -function roundRect(ctx, x, y, w, h, r) { - ctx.beginPath(); - ctx.roundRect(x, y, w, Math.max(1, h), r); - ctx.fill(); -} - -function renderEntries() { - const container = $('entries'); - const list = [...entries()].sort((a, b) => `${b.date} ${b.time}`.localeCompare(`${a.date} ${a.time}`)); - if (!list.length) { - container.innerHTML = '

No meals logged yet.

'; - return; - } - container.innerHTML = list.map(e => ` -
- ${escapeHtml(e.date)} ${escapeHtml(e.time)} - ${escapeHtml(e.mealName)} - ${e.calories} kcal | P ${e.proteinGrams}g | C ${e.carbsGrams}g | F ${e.fatGrams}g - Fruit ${Number(e.fruitServings || 0).toFixed(1)} | Vegetables ${Number(e.vegetableServings || 0).toFixed(1)}${e.imageIncluded ? ' | image analyzed' : ''} - ${escapeHtml(e.description || '')} - ${e.foodGroups ? `Groups: ${escapeHtml(e.foodGroups)}` : ''} - ${e.notes ? `${escapeHtml(e.notes)}` : ''} -
`).join(''); -} - -function escapeHtml(value) { - return String(value).replace(/[&<>"]/g, ch => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[ch])); -} - -function render() { - const today = totalsFor(todayKey()); - const week = last7Days(); - const weekAverage = Math.round(week.reduce((sum, day) => sum + day.calories, 0) / week.length); - const produce = today.fruit + today.vegetables; - - setText('todayCalories', `${today.calories} kcal`); - setText('todayMacroText', `P ${today.protein}g | C ${today.carbs}g | F ${today.fat}g`); - setText('todayProduceText', `Fruit ${today.fruit.toFixed(1)} | Veg ${today.vegetables.toFixed(1)}`); - setText('mealCountText', String(today.meals)); - setText('weekAverageText', `${weekAverage} kcal`); - setText('produceGoalText', `${produce.toFixed(1)} / 5`); - - drawMacroChart(today); - drawBars('calorieChart', week, 'calories'); - drawBars('produceChart', week, 'produce'); - renderEntries(); -} - -function showSection(sectionName) { - document.querySelectorAll('.section-page').forEach(section => { - section.hidden = section.dataset.section !== sectionName; - }); - document.querySelectorAll('[data-nav]').forEach(button => { - button.classList.toggle('active', button.dataset.nav === sectionName); - }); - document.body.classList.remove('menu-open'); -} - -function loadSettings() { - const cfg = settings(); - $('baseUrl').value = cfg.baseUrl; - $('apiKey').value = cfg.apiKey; - $('visionModel').value = cfg.visionModel; - $('taskModel').value = cfg.taskModel; -} - -$('saveSettings').addEventListener('click', () => { - saveJson(settingsKey, { - baseUrl: $('baseUrl').value.trim(), - apiKey: $('apiKey').value.trim(), - visionModel: $('visionModel').value.trim(), - taskModel: $('taskModel').value.trim(), - }); -}); - -document.querySelectorAll('[data-nav]').forEach(button => { - button.addEventListener('click', () => showSection(button.dataset.nav)); -}); - -$('menuToggle').addEventListener('click', () => { - document.body.classList.toggle('menu-open'); -}); - -$('logout').addEventListener('click', async () => { - await fetch('/api/logout', { method: 'POST' }); - window.location.href = '/login.html'; -}); - -$('image').addEventListener('change', async (event) => { - const file = event.target.files[0]; - selectedImageDataUrl = file ? await fileToDataUrl(file) : ''; - selectedImageName = file ? file.name : ''; - $('preview').hidden = !selectedImageDataUrl; - $('preview').src = selectedImageDataUrl || ''; -}); - -$('mealForm').addEventListener('submit', async (event) => { - event.preventDefault(); - const cfg = settings(); - const description = $('description').value.trim(); - const measure = $('measure').value.trim(); - if (!description && !selectedImageDataUrl) return setStatus('Describe the meal or upload an image.', true); - if (!cfg.baseUrl || !cfg.taskModel) return setStatus('Save API base URL and tasking model first.', true); - - const button = event.submitter; - button.disabled = true; - try { - setStatus(selectedImageDataUrl ? 'Uploading image to vision model...' : 'Analyzing meal text...'); - const vision = selectedImageDataUrl ? await requestVisionEstimate(description, measure, selectedImageDataUrl) : ''; - setStatus('Normalizing calories and macros...'); - const estimate = parseEstimate(await requestTaskEstimate(description, measure, vision)); - const now = new Date(); - const next = [...entries(), { - ...estimate, - date: todayKey(), - time: now.toTimeString().slice(0, 5), - description, - measure, - imageIncluded: Boolean(selectedImageDataUrl), - imageName: selectedImageName, - visionEstimate: vision, - }]; - saveJson(entriesKey, next); - $('mealForm').reset(); - selectedImageDataUrl = ''; - selectedImageName = ''; - $('preview').hidden = true; - setStatus('Saved.'); - render(); - } catch (error) { - setStatus(error.message, true); - } finally { - button.disabled = false; - } -}); - -$('clearData').addEventListener('click', () => { - if (!confirm('Clear the local diary on this browser?')) return; - saveJson(entriesKey, []); - render(); -}); - -loadSettings(); -render(); diff --git a/web/public/index.html b/web/public/index.html deleted file mode 100644 index 09f9a95..0000000 --- a/web/public/index.html +++ /dev/null @@ -1,162 +0,0 @@ - - - - - - Calorie AI - - - - -
- - -
-
- -
-

AI nutrition cockpit

-

Private food tracking with image-backed nutrition estimates

-
- -
- -
-
-
-
-

Today

- 0 kcal - P 0g | C 0g | F 0g - Fruit 0.0 | Veg 0.0 -
-
- Meals logged - 0 - Entries saved locally -
-
- 7-day average - 0 kcal - Calories per day -
-
- Produce today - 0.0 / 5 - Fruit + vegetable servings -
-
- -
-
-

Quick log

-

Capture the meal while it is still in front of you.

-

Upload a plate photo, add a portion note, and let the vision model hand context to the tasking model for clean nutrition JSON.

- -
- -
-
- - - - - - - - -
-
-
- - - diff --git a/web/public/login.html b/web/public/login.html deleted file mode 100644 index e422b7f..0000000 --- a/web/public/login.html +++ /dev/null @@ -1,29 +0,0 @@ - - - - - - Sign in - Calorie AI - - - - -
-
-

Private nutrition console

-

Calorie AI

-

Protected meal logging for calories, macros, fruit, vegetables, and AI image estimates.

-
-
- CA -

Sign in to Calorie AI

-

Use the local web credentials configured on this host.

- - - -

-
-
- - - diff --git a/web/public/login.js b/web/public/login.js deleted file mode 100644 index 70f2950..0000000 --- a/web/public/login.js +++ /dev/null @@ -1,31 +0,0 @@ -const loginForm = document.getElementById('loginForm'); -const loginStatus = document.getElementById('loginStatus'); - -loginForm.addEventListener('submit', async (event) => { - event.preventDefault(); - const button = loginForm.querySelector('button'); - button.disabled = true; - loginStatus.textContent = 'Checking credentials...'; - loginStatus.className = 'status ok'; - - try { - const response = await fetch('/api/login', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ - username: document.getElementById('username').value.trim(), - password: document.getElementById('password').value, - }), - }); - if (!response.ok) { - const error = await response.json().catch(() => ({ error: 'Sign in failed' })); - throw new Error(error.error || 'Sign in failed'); - } - window.location.href = '/'; - } catch (error) { - loginStatus.textContent = error.message; - loginStatus.className = 'status error'; - } finally { - button.disabled = false; - } -}); diff --git a/web/public/styles.css b/web/public/styles.css deleted file mode 100644 index 0966abf..0000000 --- a/web/public/styles.css +++ /dev/null @@ -1,246 +0,0 @@ -:root { - color-scheme: light; - --bg: #f3efe3; - --panel: rgba(255, 253, 247, .94); - --panel-strong: #fffaf0; - --ink: #17251d; - --muted: #667268; - --line: #e3d8c4; - --green: #236b4b; - --green-2: #39a36d; - --orange: #d5873a; - --blue: #5168bc; - --nav: #112018; - --nav-soft: #1c3427; - --danger: #a43434; - --shadow: 0 28px 80px rgba(41, 31, 17, .12); -} - -* { box-sizing: border-box; } - -body { - margin: 0; - min-height: 100vh; - font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; - background: - radial-gradient(circle at 15% 10%, rgba(255, 226, 154, .65), transparent 28rem), - radial-gradient(circle at 80% 0%, rgba(79, 138, 104, .22), transparent 30rem), - var(--bg); - color: var(--ink); -} - -button, input, textarea { font: inherit; } - -button { - border: 0; - border-radius: 16px; - background: linear-gradient(135deg, var(--green), var(--green-2)); - color: white; - cursor: pointer; - font-weight: 850; - padding: 13px 17px; - transition: transform .18s ease, box-shadow .18s ease, opacity .18s ease; -} - -button:hover { transform: translateY(-1px); box-shadow: 0 12px 28px rgba(35, 107, 75, .22); } -button:disabled { cursor: wait; opacity: .55; transform: none; box-shadow: none; } -button.secondary { background: #ebe1d0; color: var(--ink); } -button.ghost { width: 100%; background: rgba(255,255,255,.09); color: #f4ead6; box-shadow: none; } - -input, textarea { - width: 100%; - border: 1px solid #d9ceb8; - border-radius: 16px; - background: #fffaf2; - color: var(--ink); - outline: none; - padding: 13px 14px; -} - -input:focus, textarea:focus { border-color: var(--green-2); box-shadow: 0 0 0 4px rgba(57, 163, 109, .14); } -textarea { resize: vertical; } -label { display: grid; gap: 8px; color: var(--muted); font-weight: 800; } -h1, h2, p { margin-top: 0; } -h1 { max-width: 820px; margin-bottom: 0; font-size: clamp(2rem, 4vw, 4.2rem); line-height: .94; letter-spacing: -.06em; } -h2 { margin-bottom: 12px; font-size: clamp(1.35rem, 2vw, 2rem); letter-spacing: -.035em; } -p { color: var(--muted); line-height: 1.6; } -canvas { max-width: 100%; } - -.app-frame { display: grid; grid-template-columns: 280px 1fr; min-height: 100vh; } -.sidebar { - position: sticky; - top: 0; - height: 100vh; - padding: 22px; - display: grid; - grid-template-rows: auto 1fr auto; - gap: 28px; - background: linear-gradient(180deg, var(--nav), #0b140f); - color: #f8ecd7; -} - -.brand-block { display: flex; align-items: center; gap: 12px; } -.brand-block strong { display: block; font-size: 1.05rem; } -.brand-block small, .sidebar-footer small { color: rgba(248, 236, 215, .62); } -.brand-mark { - width: 48px; - height: 48px; - display: inline-grid; - place-items: center; - border-radius: 17px; - background: linear-gradient(135deg, #e7a24e, #3fb473); - color: #102017; - font-weight: 950; - letter-spacing: -.04em; -} - -.main-nav { display: grid; align-content: start; gap: 9px; } -.nav-item { - width: 100%; - padding: 13px 14px; - border-radius: 15px; - background: transparent; - color: rgba(248, 236, 215, .72); - text-align: left; - box-shadow: none; -} -.nav-item:hover, .nav-item.active { background: var(--nav-soft); color: #fff4df; box-shadow: none; } -.sidebar-footer { display: grid; gap: 12px; } - -.content-shell { min-width: 0; } -.topbar { - position: sticky; - top: 0; - z-index: 10; - display: flex; - align-items: center; - justify-content: space-between; - gap: 18px; - padding: 26px clamp(18px, 4vw, 48px); - border-bottom: 1px solid rgba(227, 216, 196, .7); - background: rgba(243, 239, 227, .82); - backdrop-filter: blur(20px); -} - -.menu-toggle { display: none; background: #e9dfcc; color: var(--ink); box-shadow: none; } -.primary-action { white-space: nowrap; } -.content { width: min(1180px, calc(100% - 36px)); margin: 0 auto; padding: 28px 0 44px; } -.section-page { display: grid; gap: 22px; } - -.eyebrow { - margin: 0 0 8px; - color: var(--green); - font-size: .76rem; - font-weight: 950; - letter-spacing: .14em; - text-transform: uppercase; -} - -.hero-grid { display: grid; grid-template-columns: minmax(300px, 1.4fr) repeat(3, minmax(150px, .62fr)); gap: 18px; } -.hero-card, .metric-card, .panel, .login-card, .login-brand { - border: 1px solid rgba(227, 216, 196, .9); - border-radius: 28px; - background: var(--panel); - box-shadow: var(--shadow); -} -.hero-card { - min-height: 220px; - padding: 28px; - display: grid; - align-content: end; - background: - linear-gradient(135deg, rgba(22, 42, 30, .9), rgba(35, 107, 75, .84)), - radial-gradient(circle at top right, rgba(235, 173, 79, .6), transparent 20rem); - color: white; -} -.hero-card .eyebrow, .hero-card span { color: rgba(255,255,255,.78); } -.hero-card strong { display: block; margin-bottom: 8px; font-size: clamp(3rem, 8vw, 5.6rem); line-height: .9; letter-spacing: -.07em; } -.hero-card span { display: block; margin-top: 6px; } -.metric-card { padding: 22px; display: grid; align-content: end; gap: 8px; background: linear-gradient(180deg, #fffdf8, #f7efde); } -.metric-card small { color: var(--muted); font-weight: 850; text-transform: uppercase; letter-spacing: .08em; } -.metric-card strong { font-size: 2.2rem; line-height: 1; letter-spacing: -.05em; } -.metric-card span { color: var(--muted); font-size: .92rem; } -.metric-card.warm strong { color: var(--orange); } -.metric-card.cool strong { color: var(--blue); } - -.panel { padding: clamp(20px, 3vw, 32px); } -.split-panel { display: grid; grid-template-columns: minmax(280px, .85fr) minmax(320px, 1fr); gap: 24px; align-items: center; } -.panel-heading { max-width: 760px; margin-bottom: 20px; } -.panel-heading.compact { margin-bottom: 8px; } -.grid { display: grid; gap: 20px; } -.grid.two { grid-template-columns: repeat(2, minmax(0, 1fr)); } -.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; } -.span-2 { grid-column: span 2; } -.form-actions { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; margin-top: 18px; } -.meal-panel { background: linear-gradient(135deg, rgba(255,253,247,.96), rgba(244, 233, 210, .94)); } -.preview { width: 100%; max-height: 360px; object-fit: cover; border: 1px solid var(--line); border-radius: 24px; margin-top: 18px; } -.status { min-height: 1.5em; margin: 0; font-weight: 800; } -.status.error { color: var(--danger); } -.status.ok { color: var(--green); } -.muted { color: var(--muted); font-size: .93rem; } -.chart-panel canvas, .split-panel canvas { width: 100%; height: auto; } -.row-title { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 18px; } -.row-title h2 { margin-bottom: 0; } -.entries { display: grid; gap: 12px; } -.entry { - display: grid; - gap: 7px; - border: 1px solid var(--line); - border-radius: 20px; - padding: 16px; - background: var(--panel-strong); -} -.entry strong { font-size: 1.02rem; } -.entry small { color: var(--muted); } - -.login-body { display: grid; place-items: center; padding: 24px; } -.login-shell { width: min(980px, 100%); display: grid; grid-template-columns: 1.1fr .9fr; gap: 22px; align-items: stretch; } -.login-brand { - min-height: 560px; - padding: clamp(28px, 5vw, 54px); - display: grid; - align-content: end; - background: - linear-gradient(135deg, rgba(15, 29, 21, .94), rgba(29, 88, 61, .86)), - radial-gradient(circle at top right, rgba(218, 150, 72, .82), transparent 22rem); - color: #fff4df; -} -.login-brand h1 { color: white; } -.login-brand p, .login-brand .eyebrow { color: rgba(255,244,223,.78); } -.login-card { padding: clamp(24px, 4vw, 40px); align-self: center; display: grid; gap: 16px; } -.login-card h2 { margin-bottom: 0; } - -@media (max-width: 1050px) { - .app-frame { grid-template-columns: 1fr; } - .sidebar { - position: fixed; - left: 14px; - top: 14px; - bottom: 14px; - z-index: 30; - width: min(320px, calc(100vw - 28px)); - height: auto; - border-radius: 26px; - transform: translateX(calc(-100% - 18px)); - transition: transform .2s ease; - box-shadow: 0 30px 90px rgba(0,0,0,.34); - } - body.menu-open .sidebar { transform: translateX(0); } - .menu-toggle { display: inline-flex; } - .hero-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } - .hero-card { grid-column: span 2; } - .split-panel, .grid.two, .login-shell { grid-template-columns: 1fr; } - .login-brand { min-height: 360px; } -} - -@media (max-width: 700px) { - .topbar { align-items: flex-start; padding: 18px; } - .primary-action { display: none; } - .content { width: min(100% - 22px, 1180px); padding-top: 18px; } - .hero-grid, .form-grid { grid-template-columns: 1fr; } - .hero-card, .span-2 { grid-column: auto; } - .row-title, .form-actions { align-items: stretch; flex-direction: column; } - .row-title button, .form-actions button { width: 100%; } - .login-body { padding: 12px; } - .login-brand { min-height: 260px; } -} diff --git a/web/server.js b/web/server.js index 160b79c..7becc95 100644 --- a/web/server.js +++ b/web/server.js @@ -4,13 +4,18 @@ const fs = require('fs'); const path = require('path'); const port = Number(process.env.PORT || 8080); -const publicDir = path.join(__dirname, 'public'); +const publicDir = fs.existsSync(path.join(__dirname, 'dist')) ? path.join(__dirname, 'dist') : path.join(__dirname, 'public'); const publicRoot = publicDir + path.sep; const dataDir = process.env.CALORIE_AI_DATA_DIR || path.join(__dirname, 'data'); const authFile = path.join(dataDir, 'auth.json'); const sessionCookieName = 'calorie_ai_session'; const sessionSeconds = 60 * 60 * 24 * 7; const auth = loadAuthConfig(); +const aiConfig = { + baseUrl: (process.env.CALORIE_AI_API_BASE_URL || process.env.LITELLM_API_BASE || 'https://llm.danvics.com/v1').replace(/\/+$/, ''), + apiKey: process.env.CALORIE_AI_API_KEY || process.env.LITELLM_API_KEY || process.env.OPENAI_API_KEY || '', + defaultModel: process.env.CALORIE_AI_DEFAULT_MODEL || process.env.LITELLM_MODEL || 'gpt-4o-mini', +}; const types = { '.html': 'text/html; charset=utf-8', @@ -18,6 +23,7 @@ const types = { '.js': 'application/javascript; charset=utf-8', '.json': 'application/json; charset=utf-8', '.svg': 'image/svg+xml', + '.woff2': 'font/woff2', }; function readBody(req) { @@ -124,7 +130,14 @@ function cookieOptions(req, maxAge = sessionSeconds) { function publicRequest(req) { const url = req.url.split('?')[0]; return (req.method === 'POST' && url === '/api/login') - || ((req.method === 'GET' || req.method === 'HEAD') && ['/login.html', '/login.js', '/styles.css', '/favicon.svg'].includes(url)); + || ((req.method === 'GET' || req.method === 'HEAD') && (url === '/login' || url === '/favicon.svg' || url.startsWith('/assets/'))); +} + +function sendFile(req, res, target) { + fs.readFile(target, (err, data) => { + if (err) return send(res, 404, 'Not found', 'text/plain; charset=utf-8'); + send(res, 200, data, types[path.extname(target)] || 'application/octet-stream', req.method === 'HEAD'); + }); } function serveStatic(req, res) { @@ -132,24 +145,23 @@ function serveStatic(req, res) { const file = cleanUrl === '/' ? '/index.html' : cleanUrl; const target = path.normalize(path.join(publicDir, file)); if (!target.startsWith(publicRoot)) return send(res, 403, 'Forbidden', 'text/plain; charset=utf-8'); - fs.readFile(target, (err, data) => { - if (err) return send(res, 404, 'Not found', 'text/plain; charset=utf-8'); - send(res, 200, data, types[path.extname(target)] || 'application/octet-stream', req.method === 'HEAD'); + if (!err) return send(res, 200, data, types[path.extname(target)] || 'application/octet-stream', req.method === 'HEAD'); + if (req.method === 'GET' || req.method === 'HEAD') return sendFile(req, res, path.join(publicDir, 'index.html')); + send(res, 404, 'Not found', 'text/plain; charset=utf-8'); }); } async function proxyChat(req, res) { try { const payload = JSON.parse(await readBody(req)); - const baseUrl = String(payload.baseUrl || '').replace(/\/+$/, ''); - if (!/^https?:\/\//.test(baseUrl)) throw new Error('A valid API base URL is required'); - if (!payload.body || typeof payload.body !== 'object') throw new Error('Missing chat completion body'); + if (!/^https?:\/\//.test(aiConfig.baseUrl)) throw new Error('AI endpoint is not configured'); + if (!payload.body || typeof payload.body !== 'object') throw new Error('Missing request body'); const headers = { 'content-type': 'application/json' }; - if (payload.apiKey) headers.authorization = `Bearer ${payload.apiKey}`; + if (aiConfig.apiKey) headers.authorization = `Bearer ${aiConfig.apiKey}`; - const upstream = await fetch(`${baseUrl}/chat/completions`, { + const upstream = await fetch(`${aiConfig.baseUrl}/chat/completions`, { method: 'POST', headers, body: JSON.stringify(payload.body), @@ -161,6 +173,40 @@ async function proxyChat(req, res) { } } +async function proxyModels(req, res) { + try { + if (!/^https?:\/\//.test(aiConfig.baseUrl)) throw new Error('AI endpoint is not configured'); + + const headers = {}; + if (aiConfig.apiKey) headers.authorization = `Bearer ${aiConfig.apiKey}`; + const infoBase = aiConfig.baseUrl.replace(/\/v1$/, ''); + + let models = []; + try { + const info = await fetch(`${infoBase}/model/info`, { headers }); + if (info.ok) { + const data = await info.json(); + models = (data.data || []) + .filter(item => item.model_name && (!item.model_info || !item.model_info.mode || item.model_info.mode === 'chat')) + .map(item => ({ id: item.model_name, name: item.model_name })); + } + } catch {} + + if (!models.length) { + const upstream = await fetch(`${aiConfig.baseUrl}/models`, { headers }); + const data = await upstream.json(); + models = (data.data || []).map(item => ({ id: item.id, name: item.id })); + } + + const deduped = Array.from(new Map(models.filter(m => m.id).map(m => [m.id, m])).values()) + .sort((a, b) => a.name.localeCompare(b.name)); + if (!deduped.some(model => model.id === aiConfig.defaultModel)) deduped.unshift({ id: aiConfig.defaultModel, name: aiConfig.defaultModel }); + send(res, 200, JSON.stringify({ models: deduped, defaultModel: aiConfig.defaultModel })); + } catch (error) { + send(res, 400, JSON.stringify({ error: error.message })); + } +} + async function login(req, res) { try { const payload = JSON.parse(await readBody(req)); @@ -188,13 +234,14 @@ http.createServer((req, res) => { if (!authenticated && !publicRequest(req)) { if (url.startsWith('/api/')) return send(res, 401, JSON.stringify({ error: 'Authentication required' })); - return redirect(res, '/login.html'); + return redirect(res, '/login'); } - if (authenticated && (req.method === 'GET' || req.method === 'HEAD') && url === '/login.html') return redirect(res, '/'); + if (authenticated && (req.method === 'GET' || req.method === 'HEAD') && url === '/login') return redirect(res, '/'); if (req.method === 'POST' && url === '/api/login') return login(req, res); if (req.method === 'POST' && url === '/api/logout') return logout(req, res); if (req.method === 'GET' && url === '/api/session') return send(res, 200, JSON.stringify({ user: auth.user })); + if (req.method === 'POST' && url === '/api/models') return proxyModels(req, res); if (req.method === 'POST' && req.url === '/api/chat') return proxyChat(req, res); if (req.method === 'GET' || req.method === 'HEAD') return serveStatic(req, res); send(res, 405, 'Method not allowed', 'text/plain; charset=utf-8'); diff --git a/web/src/App.svelte b/web/src/App.svelte new file mode 100644 index 0000000..c64b434 --- /dev/null +++ b/web/src/App.svelte @@ -0,0 +1,537 @@ + + +{#if loginMode} +
+
+
+

Food diary

+

Calorie AI

+

Track meals, calories, macros, fruit, and vegetables across any day.

+
+
+
CA
+

Sign in

+

Use the local account configured on this server.

+ + + + {#if loginStatus}

{loginStatus}

{/if} +
+
+
+{:else} +
+
+
+
+ +
CA
+
+

Calorie AI

+

Food diary

+
+
+
+ + +
+
+
+ +
+ + {#if sidebarCollapsed}{/if} + {#if menuOpen}{/if} + +
+ {#if page === 'dashboard'} +
+

Dashboard

Today’s totals and recent trends.

+
+
Today
{today.calories} kcal
P {today.protein}g | C {today.carbs}g | F {today.fat}g
Fruit {today.fruit.toFixed(1)} | Veg {today.vegetables.toFixed(1)}
+ + + +
+
+

Quick actions

+

Macros today

+
+
+ {:else if page === 'log'} +

Log meal

Backfill meals for any date and time.

+
+

Meal details

+
+ + + + + + +
+ {#if selectedImageDataUrl}Selected meal preview{/if} +
{#if status}

{status}

{/if}
+
+
+ {:else if page === 'analytics'} +

Analytics

Seven-day calorie and produce trends.

Calories, last 7 days

Fruit and vegetables

+ {:else if page === 'diary'} +

Diary

Search and filter local meal entries.

{#if diaryRows.length}{#each diaryRows as entry}
{entry.mealName}
{entry.date} {entry.time} · {entry.mealType}
{entry.calories} kcal
P {entry.proteinGrams}g · C {entry.carbsGrams}g · F {entry.fatGrams}g · Fruit {Number(entry.fruitServings || 0).toFixed(1)} · Vegetables {Number(entry.vegetableServings || 0).toFixed(1)}
{#if entry.description}
{entry.description}
{/if}{#if entry.foodGroups}
Groups: {entry.foodGroups}
{/if}{#if entry.notes}
{entry.notes}
{/if}
{/each}{:else}
No meals match the current filters.
{/if}
+ {:else if page === 'models'} +

Models

Choose from the models configured on the server. Model IDs are not typed manually.

Selected models

{#if modelStatus}

{modelStatus}

{/if}

Available models

{#each filteredModels as model}{/each}
+ {:else if page === 'settings'} +

Settings

Connection settings are managed on the server.

AI connection

API base URL and API key are read from environment variables.

Use the Models page to refresh and select available models.

+ {/if} +
+
+
+{/if} + + diff --git a/web/src/Field.svelte b/web/src/Field.svelte new file mode 100644 index 0000000..2f74a4f --- /dev/null +++ b/web/src/Field.svelte @@ -0,0 +1,9 @@ + + + diff --git a/web/src/Stat.svelte b/web/src/Stat.svelte new file mode 100644 index 0000000..2cb8fe1 --- /dev/null +++ b/web/src/Stat.svelte @@ -0,0 +1,11 @@ + + +
+
{label}
+
{value}
+
{note}
+
diff --git a/web/src/app.css b/web/src/app.css new file mode 100644 index 0000000..f51c733 --- /dev/null +++ b/web/src/app.css @@ -0,0 +1,10 @@ +@import "tailwindcss"; + +@theme { + --font-sans: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} + +html { background: #f8fafc; } +body { margin: 0; min-height: 100vh; font-family: var(--font-sans); color: #0f172a; } +button, input, select, textarea { font: inherit; } +canvas { max-width: 100%; } diff --git a/web/src/main.js b/web/src/main.js new file mode 100644 index 0000000..b61a387 --- /dev/null +++ b/web/src/main.js @@ -0,0 +1,7 @@ +import './app.css'; +import { mount } from 'svelte'; +import App from './App.svelte'; + +const app = mount(App, { target: document.getElementById('app') }); + +export default app; diff --git a/web/tests/app.spec.js b/web/tests/app.spec.js index 98b2a1c..e60ce80 100644 --- a/web/tests/app.spec.js +++ b/web/tests/app.spec.js @@ -5,19 +5,37 @@ const png = Buffer.from( 'base64', ); +async function mockModels(page) { + await page.route('**/api/models', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + defaultModel: 'claude-haiku-4.5', + models: [ + { id: 'claude-haiku-4.5', name: 'Claude Haiku 4.5' }, + { id: 'claude-sonnet-4.6', name: 'Claude Sonnet 4.6' }, + { id: 'gemini-2.5-flash', name: 'Gemini 2.5 Flash' }, + ], + }), + }); + }); +} + async function login(page) { + await mockModels(page); await page.goto('/'); - await expect(page).toHaveURL(/login\.html/); - await expect(page.getByRole('heading', { name: 'Sign in to Calorie AI' })).toBeVisible(); + await expect(page).toHaveURL(/\/login$/); + await expect(page.getByRole('heading', { name: 'Sign in' })).toBeVisible(); await page.locator('#password').fill('test-password'); await page.getByRole('button', { name: 'Sign in' }).click(); - await expect(page.getByRole('heading', { name: /Private food tracking/ })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible(); } test('requires authentication before loading the app', async ({ page, request }) => { await page.goto('/'); - await expect(page).toHaveURL(/login\.html/); - await expect(page.getByRole('heading', { name: 'Sign in to Calorie AI' })).toBeVisible(); + await expect(page).toHaveURL(/\/login$/); + await expect(page.getByRole('heading', { name: 'Sign in' })).toBeVisible(); const response = await request.post('/api/chat', { data: {} }); expect(response.status()).toBe(401); @@ -28,23 +46,27 @@ test.describe('authenticated app', () => { await login(page); await page.evaluate(() => localStorage.clear()); await page.reload(); + await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible(); }); - test('loads the dashboard, menu, and validates missing meal input', async ({ page }) => { + test('uses searchable page navigation and validates missing meal input', async ({ page }) => { await expect(page.getByRole('navigation', { name: 'Main menu' })).toBeVisible(); await expect(page.locator('#todayCalories')).toHaveText('0 kcal'); + + await page.locator('#menu-search').fill('log'); + await expect(page.getByRole('button', { name: 'Log meal' })).toBeVisible(); await page.getByRole('button', { name: 'Log meal' }).click(); await page.getByRole('button', { name: 'Analyze and save' }).click(); await expect(page.locator('#status')).toContainText('Describe the meal or upload an image.'); }); - test('saves settings, uploads an image, logs nutrition, updates charts, and clears diary', async ({ page }) => { + test('selects models from dropdowns, backfills a meal, updates charts, and clears diary', async ({ page }) => { let calls = 0; await page.route('**/api/chat', async (route) => { calls += 1; const content = calls === 1 ? '{"foods":["salmon","rice","broccoli","berries"],"calories":620,"proteinGrams":42,"carbsGrams":58,"fatGrams":22,"fruitServings":0.5,"vegetableServings":1.5}' - : '{"mealName":"Salmon rice bowl","calories":640,"proteinGrams":44,"carbsGrams":60,"fatGrams":23,"fruitServings":0.5,"vegetableServings":1.5,"foodGroups":"fish, grains, fruit, vegetables","notes":"Estimated from uploaded image and portion note."}'; + : '{"mealName":"Salmon rice bowl","calories":640,"proteinGrams":44,"carbsGrams":60,"fatGrams":23,"fruitServings":0.5,"vegetableServings":1.5,"foodGroups":"fish, grains, fruit, vegetables","notes":"Estimated from image and portion note."}'; await route.fulfill({ status: 200, @@ -53,13 +75,16 @@ test.describe('authenticated app', () => { }); }); - await page.getByRole('button', { name: 'AI settings' }).click(); - await page.locator('#baseUrl').fill('https://api.example.test/v1'); - await page.locator('#visionModel').fill('vision-test-model'); - await page.locator('#taskModel').fill('task-test-model'); - await page.getByRole('button', { name: 'Save settings' }).click(); + await page.getByRole('button', { name: 'Models', exact: true }).click(); + await page.locator('#visionModel').selectOption('gemini-2.5-flash'); + await page.locator('#taskModel').selectOption('claude-sonnet-4.6'); + await page.getByRole('button', { name: 'Save selected models' }).click(); + await expect(page.locator('#modelStatus')).toContainText('Models saved.'); await page.getByRole('button', { name: 'Log meal' }).click(); + await page.locator('#mealDate').fill('2026-05-18'); + await page.locator('#mealTime').fill('08:15'); + await page.locator('#mealType').selectOption('Breakfast'); await page.locator('#description').fill('salmon rice bowl with broccoli and berries'); await page.locator('#measure').fill('one dinner bowl'); await page.locator('#image').setInputFiles({ name: 'meal.png', mimeType: 'image/png', buffer: png }); @@ -67,20 +92,18 @@ test.describe('authenticated app', () => { await page.getByRole('button', { name: 'Analyze and save' }).click(); await expect(page.locator('#status')).toContainText('Saved.'); - await expect(page.locator('#todayCalories')).toHaveText('640 kcal'); - await expect(page.locator('#todayMacroText')).toHaveText('P 44g | C 60g | F 23g'); - await expect(page.locator('#todayProduceText')).toHaveText('Fruit 0.5 | Veg 1.5'); + + await page.getByRole('button', { name: 'Diary', exact: true }).click(); + await expect(page.locator('.entry')).toContainText('Salmon rice bowl'); + await expect(page.locator('.entry')).toContainText('2026-05-18 08:15 · Breakfast'); await page.getByRole('button', { name: 'Analytics' }).click(); await expect(page.locator('#calorieChart')).toBeVisible(); await expect(page.locator('#produceChart')).toBeVisible(); await page.getByRole('button', { name: 'Diary', exact: true }).click(); - await expect(page.locator('.entry')).toContainText('Salmon rice bowl'); - - page.once('dialog', dialog => dialog.accept()); - await page.getByRole('button', { name: 'Clear local diary' }).click(); - await expect(page.locator('#todayCalories')).toHaveText('0 kcal'); - await expect(page.locator('#entries')).toContainText('No meals logged yet.'); + await page.getByRole('button', { name: 'Clear diary' }).click(); + await page.getByRole('button', { name: 'Click again' }).click(); + await expect(page.locator('#entries')).toContainText('No meals match the current filters.'); }); }); diff --git a/web/vite.config.js b/web/vite.config.js new file mode 100644 index 0000000..42acc96 --- /dev/null +++ b/web/vite.config.js @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite'; +import { svelte } from '@sveltejs/vite-plugin-svelte'; +import tailwindcss from '@tailwindcss/vite'; + +export default defineConfig({ + plugins: [svelte(), tailwindcss()], +});