commit a708152fc17360ef0d4c54e92a8c22657b031365 Author: admin Date: Thu May 14 22:15:51 2026 +0200 init: HejYou Language Learning App (React + Vite) React SPA with Vite, Directus backend, canvas-confetti. Includes Dockerfile (multi-stage Node → nginx) for Coolify deployment. Co-Authored-By: Claude Sonnet 4.6 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..31e9d4b --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +dist/ +.env +.env.local +.DS_Store +.claude/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e3139e1 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,18 @@ +FROM node:20-alpine AS build +WORKDIR /app + +COPY package*.json ./ +RUN npm ci + +COPY . . + +ARG VITE_DIRECTUS_URL +ENV VITE_DIRECTUS_URL=$VITE_DIRECTUS_URL + +RUN npm run build + +FROM nginx:alpine +COPY --from=build /app/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 +CMD ["nginx", "-g", "daemon off;"] diff --git a/index.html b/index.html new file mode 100644 index 0000000..bdcd71c --- /dev/null +++ b/index.html @@ -0,0 +1,12 @@ + + + + + + Language App + + +
+ + + diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..956537b --- /dev/null +++ b/nginx.conf @@ -0,0 +1,20 @@ +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + # SPA fallback + location / { + try_files $uri $uri/ /index.html; + } + + # Cache static assets + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + + gzip on; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..c4e65ed --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1816 @@ +{ + "name": "language-app", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "language-app", + "version": "1.0.0", + "dependencies": { + "canvas-confetti": "^1.9.4", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.3.4", + "vite": "^6.3.1" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "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/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "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==", + "dev": true, + "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==", + "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/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", + "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", + "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", + "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", + "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", + "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", + "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", + "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", + "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", + "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", + "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", + "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", + "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", + "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", + "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", + "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", + "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", + "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", + "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", + "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", + "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", + "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", + "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", + "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", + "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", + "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "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/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.19", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.19.tgz", + "integrity": "sha512-qCkNLi2sfBOn8XhZQ0FXsT1Ki/Yo5P90hrkRamVFRS7/KV9hpfA4HkoWNU152+8w0zPjnxo5psx5NL3PSGgv5g==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001788", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001788.tgz", + "integrity": "sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/canvas-confetti": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/canvas-confetti/-/canvas-confetti-1.9.4.tgz", + "integrity": "sha512-yxQbJkAVrFXWNbTUjPqjF7G+g6pDotOUHGbkZq2NELZUMDpiJ85rIEazVb8GTaAptNW2miJAXbs1BtioA251Pw==", + "license": "ISC", + "funding": { + "type": "donate", + "url": "https://www.paypal.me/kirilvatev" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.336", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.336.tgz", + "integrity": "sha512-AbH9q9J455r/nLmdNZes0G0ZKcRX73FicwowalLs6ijwOmCJSRRrLX63lcAlzy9ux3dWK1w1+1nsBJEWN11hcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "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/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "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/node-releases": { + "version": "2.0.37", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", + "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.9", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz", + "integrity": "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==", + "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/react": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", + "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", + "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.5" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", + "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", + "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.60.1", + "@rollup/rollup-android-arm64": "4.60.1", + "@rollup/rollup-darwin-arm64": "4.60.1", + "@rollup/rollup-darwin-x64": "4.60.1", + "@rollup/rollup-freebsd-arm64": "4.60.1", + "@rollup/rollup-freebsd-x64": "4.60.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", + "@rollup/rollup-linux-arm-musleabihf": "4.60.1", + "@rollup/rollup-linux-arm64-gnu": "4.60.1", + "@rollup/rollup-linux-arm64-musl": "4.60.1", + "@rollup/rollup-linux-loong64-gnu": "4.60.1", + "@rollup/rollup-linux-loong64-musl": "4.60.1", + "@rollup/rollup-linux-ppc64-gnu": "4.60.1", + "@rollup/rollup-linux-ppc64-musl": "4.60.1", + "@rollup/rollup-linux-riscv64-gnu": "4.60.1", + "@rollup/rollup-linux-riscv64-musl": "4.60.1", + "@rollup/rollup-linux-s390x-gnu": "4.60.1", + "@rollup/rollup-linux-x64-gnu": "4.60.1", + "@rollup/rollup-linux-x64-musl": "4.60.1", + "@rollup/rollup-openbsd-x64": "4.60.1", + "@rollup/rollup-openharmony-arm64": "4.60.1", + "@rollup/rollup-win32-arm64-msvc": "4.60.1", + "@rollup/rollup-win32-ia32-msvc": "4.60.1", + "@rollup/rollup-win32-x64-gnu": "4.60.1", + "@rollup/rollup-win32-x64-msvc": "4.60.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "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/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "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/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", + "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "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 + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..b60e97b --- /dev/null +++ b/package.json @@ -0,0 +1 @@ +{"name":"language-app","version":"1.0.0","type":"module","scripts":{"dev":"vite","build":"vite build","preview":"vite preview"},"dependencies":{"canvas-confetti":"^1.9.4","react":"^19.0.0","react-dom":"^19.0.0"},"devDependencies":{"@types/react":"^19.0.0","@types/react-dom":"^19.0.0","@vitejs/plugin-react":"^4.3.4","vite":"^6.3.1"}} \ No newline at end of file diff --git a/public/admin/kachel/index.html b/public/admin/kachel/index.html new file mode 100644 index 0000000..8fd675f --- /dev/null +++ b/public/admin/kachel/index.html @@ -0,0 +1,685 @@ + + + + + + Kachel-Übersicht — Admin + + + + + +

Kachel-Übersicht

+

Admin · alle verfügbaren Kacheltypen mit Beispiel

+ + +
+
new_word_text
+
Neues Wort — Text
+

+ Der Nutzer sieht das Wort mit Bild und tippt die Übersetzung in der Zielsprache ein. + Sofortiges Feedback: richtig / falsch. Bei Erfolg Punkte-Anzeige. +

+ +
+
+ Svenska + ★ +1 Punkt +
bord
+ +
+
+
+ bordet + der Tisch +
+
+ +
+ + +
+
+
+
+ +
+ + +
+
new_word_voice
+
Neues Wort — Sprache
+

+ Der Nutzer sieht das Wort und spricht es laut aus. Die Web Speech API erkennt die Aussprache + und gibt Feedback. Bei Erfolg erscheint die Punkte-Leiste. +

+ +
+
+ Svenska + ★ +1 Punkt +
bord
+ +
+
+
+ bordet + der Tisch +
+
+ +
+
+ + + + + + +
+ Bra! Aussprache erkannt. +
+
+ ★ +1 Punkt erhalten + Gesamt: 47 Punkte +
+
+
+
+ + +
+ + +
+
letter_order
+
Buchstaben-Reihenfolge
+

+ Der Nutzer sieht ein Bild und tippt die durcheinander gewürfelten Buchstaben in der richtigen Reihenfolge. + Grüner Rahmen + Erfolgsmeldung bei vollständig richtiger Eingabe. +

+ +
+
+ Svenska + ★ +1 Punkt +
+
+ +
+
+
+ Tippe die Buchstaben in der richtigen Reihenfolge + das Sofa +
+ +
+ s + o + f + f + a +
+
+

Perfekt! Bra jobbat!

+
+ ★ +1 Punkt erhalten + Gesamt: 47 Punkte +
+
+
+ + +

ZUSTAND: Wartend (gemischt)

+
+
+ Svenska + ★ +1 Punkt +
+
+ +
+
+
+ Tippe die Buchstaben in der richtigen Reihenfolge + das Sofa +
+
+ s + o +
+
+
+ + + +
+ +
+
+
+ +
+ + +
+
image_pick
+
Bild auswählen — Ja / Nein
+

+ Der Nutzer sieht ein Wort und blättert durch Bilder. Mit „Ja, das ist es" wählt er das passende Bild aus. + Punkte gibt es nur beim richtigen Bild. +

+ +
+
+ Svenska + ★ +1 Punkt +
+
+ +
+ + + + +
+
+
+
+ soffa + das Sofa +
+
+ +
+ + +
+
+
+
+ +
+ + +
+
audio_quiz
+
Audio-Quiz — Mehrfachwahl
+

+ Der Nutzer hört einen gesprochenen Begriff und wählt die richtige deutsche Übersetzung aus vier Optionen. + Dunkles Karten-Design mit Lautsprecher-Animation. +

+ +
+
+ Svenska + ★ +1 Punkt +
+
+
+
+ +
+
+
+ + + + + + +
+

Tippe zum Abspielen

+
+
+

Was hörst du?

+
+
Adie Küche
+
Bdas Wohnzimmer
+
Cdas Schlafzimmer
+
Ddas Badezimmer
+
+
+
+ ★ +1 Punkt erhalten + Gesamt: 47 Punkte +
+
+
+
+ +
+ + +
+
image_quiz
+
Bild-Quiz — Mehrfachauswahl
+

+ Der Nutzer sieht ein Bild einer Szene und wählt alle passenden schwedischen Wörter aus. + Mehrere Antworten sind möglich. Bei vollständig richtiger Auswahl gibt es Punkte. +

+ +
+ +
+ Svenska + ★ +1 Punkt +
+
+ vardagsrum + +
+
+
+ Was siehst du im Bild? + Mehrere möglich +
+
+ + + + + + + + +
+
+ +
+
+ + +

ZUSTAND: Richtig

+
+
+ Svenska + ★ +1 Punkt +
+
+ vardagsrum + +
+
+
+ Was siehst du im Bild? + Mehrere möglich +
+
+ + + + + + + + +
+
+
+ ★ +1 Punkt erhalten + Gesamt: 47 Punkte +
+
+
+
+ + + diff --git a/src/App.jsx b/src/App.jsx new file mode 100644 index 0000000..1159d82 --- /dev/null +++ b/src/App.jsx @@ -0,0 +1,45 @@ +import { useState } from 'react' +import { AuthProvider, useAuth } from './context/AuthContext' +import AuthScreen from './components/auth/AuthScreen' +import BottomNav from './BottomNav' +import Feed from './pages/Feed' +import Game from './pages/Game' +import Pro from './pages/Pro' +import Profil from './pages/Profil' + +const PAGES = { feed: Feed, game: Game, pro: Pro, profil: Profil } + +function AppContent() { + const { user, loading } = useAuth() + const [page, setPage] = useState('feed') + + if (loading) { + return ( +
+
+ +
+ ) + } + + if (!user || !user.username || !user.language_native || !user.language_target) { + return + } + + const PageComponent = PAGES[page] || Feed + + return ( +
+ + +
+ ) +} + +export default function App() { + return ( + + + + ) +} diff --git a/src/BottomNav.css b/src/BottomNav.css new file mode 100644 index 0000000..f46db1b --- /dev/null +++ b/src/BottomNav.css @@ -0,0 +1,44 @@ +.bottom-nav { + display: flex; + background: #C4A882; + border-top: 1px solid #D4B896; + padding-bottom: env(safe-area-inset-bottom); +} + +.nav-item { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 10px 0; + border: none; + background: none; + cursor: pointer; + color: rgba(74, 55, 40, 0.45); + transition: color 0.2s; + gap: 3px; +} + +.nav-item.active { + color: #7A5C3A; +} + +.nav-icon { + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; +} + +.nav-icon svg { + width: 24px; + height: 24px; +} + +.nav-label { + font-family: 'Nunito', sans-serif; + font-size: 11px; + font-weight: 700; +} diff --git a/src/BottomNav.jsx b/src/BottomNav.jsx new file mode 100644 index 0000000..c9209e2 --- /dev/null +++ b/src/BottomNav.jsx @@ -0,0 +1,53 @@ +import './BottomNav.css' + +const IconHouse = () => ( + + + + +) + +const IconPlay = () => ( + + + + +) + +const IconMountain = () => ( + + + +) + +const IconGoal = () => ( + + + + + +) + +const tabs = [ + { id: 'feed', label: 'Feed', Icon: IconHouse }, + { id: 'game', label: 'Game', Icon: IconPlay }, + { id: 'pro', label: 'Pro', Icon: IconMountain }, + { id: 'profil', label: 'Profil', Icon: IconGoal }, +] + +export default function BottomNav({ active, onNavigate }) { + return ( + + ) +} diff --git a/src/api/directus.js b/src/api/directus.js new file mode 100644 index 0000000..32678c4 --- /dev/null +++ b/src/api/directus.js @@ -0,0 +1,270 @@ +const BASE = import.meta.env.VITE_DIRECTUS_URL + +const json = { 'Content-Type': 'application/json' } +const auth = (token) => ({ 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }) + +export async function login(email, password) { + const res = await fetch(`${BASE}/auth/login`, { + method: 'POST', + headers: json, + body: JSON.stringify({ email, password }), + }) + const data = await res.json() + if (!res.ok) throw new Error(data.errors?.[0]?.message || 'Login fehlgeschlagen.') + return data.data +} + +export async function getMe(userToken) { + const res = await fetch(`${BASE}/users/me?fields=id,username,language_native,language_target`, { + headers: auth(userToken), + }) + const data = await res.json() + if (!res.ok) throw new Error('Profil konnte nicht geladen werden.') + return data.data +} + +// Nutzt den öffentlichen Registrierungsendpunkt — kein Admin-Token nötig +export async function registerUser(email, password) { + const res = await fetch(`${BASE}/users/register`, { + method: 'POST', + headers: json, + body: JSON.stringify({ email, password }), + }) + if (res.status === 204) return null + const data = await res.json() + if (!res.ok) throw new Error(data.errors?.[0]?.message || 'Registrierung fehlgeschlagen.') + return data.data +} + +export async function checkUsername(username, userToken) { + const clean = username.toLowerCase().replace(/[^a-z0-9_]/g, '') + const res = await fetch( + `${BASE}/items/users_language?filter[username_lowercases][_eq]=${encodeURIComponent(clean)}&fields=id&limit=1`, + { headers: auth(userToken) } + ) + const data = await res.json() + return Array.isArray(data.data) && data.data.length === 0 +} + +export async function createProfile({ userId, username, nativeLang, targetLang, userToken }) { + const clean = username.toLowerCase().replace(/[^a-z0-9_]/g, '') + const h = auth(userToken) + + const profileRes = await fetch(`${BASE}/items/users_language`, { + method: 'POST', + headers: h, + body: JSON.stringify({ username_public: username, username_lowercases: clean }), + }) + const profileData = await profileRes.json() + if (!profileRes.ok) throw new Error(profileData.errors?.[0]?.message || 'Profil konnte nicht erstellt werden.') + const profileId = profileData.data.id + + await fetch(`${BASE}/users/${userId}`, { + method: 'PATCH', + headers: h, + body: JSON.stringify({ username: profileId, language_native: nativeLang, language_target: targetLang }), + }) + + await fetch(`${BASE}/items/users_language/${profileId}`, { + method: 'PATCH', + headers: h, + body: JSON.stringify({ user: userId }), + }) + + await fetch(`${BASE}/items/learning_pairs`, { + method: 'POST', + headers: h, + body: JSON.stringify({ user: profileId, language_from: nativeLang, language_to: targetLang, active: true, current_level: 1, points: 0 }), + }) + + return profileId +} + +const LANG_META = { + de: { flag: '🇩🇪', speech: 'de-DE' }, + en: { flag: '🇬🇧', speech: 'en-US' }, + se: { flag: '🇸🇪', speech: 'sv-SE' }, +} + +// Lädt Sprachen aus Directus (public, kein Token nötig) +export async function getLanguageOptions() { + const res = await fetch( + `${BASE}/items/language_options?filter[status][_eq]=published&fields=id,title_de,short&sort=title_en` + ) + const data = await res.json() + return (data.data || []).map(l => ({ + id: l.id, + label: l.title_de, + suffix: l.short, + ...(LANG_META[l.short] || { flag: '🌐', speech: l.short }), + })) +} + +export function langById(id, options) { + return (options || []).find(l => l.id === id) || null +} + +export async function getActiveLearningPair(profileId, userToken) { + const res = await fetch( + `${BASE}/items/learning_pairs?filter[user][_eq]=${profileId}&filter[active][_eq]=true` + + `&fields=id,language_from,language_to,current_level,points&limit=1`, + { headers: auth(userToken) } + ) + const data = await res.json() + return data.data?.[0] || null +} + +export async function getWords(userToken, limit = 100) { + const res = await fetch( + `${BASE}/items/words?filter[status][_eq]=published` + + `&fields=id,title_de,title_en,title_se,level&limit=${limit}`, + { headers: auth(userToken) } + ) + const data = await res.json() + return data.data || [] +} + +export async function getQuestions(userToken, limit = 100) { + const res = await fetch( + `${BASE}/items/questions?filter[status][_eq]=published` + + `&fields=id,question_de,question_en,question_se,answer_de,answer_en,answer_se,level,related_words.words_id` + + `&limit=${limit}`, + { headers: auth(userToken) } + ) + const data = await res.json() + return data.data || [] +} + +// Holt Progress für einen Nutzer, optional gefiltert auf eine Zielsprache +export async function getUserProgress(profileId, userToken, toLangId = null) { + let url = `${BASE}/items/user_progress?filter[user][_eq]=${profileId}` + + `&fields=id,word,question,card_type,result,language_from,language_to&limit=-1` + if (toLangId) url += `&filter[language_to][_eq]=${toLangId}` + const res = await fetch(url, { headers: auth(userToken) }) + const data = await res.json() + return data.data || [] +} + +export async function saveProgress(payload, userToken) { + const res = await fetch(`${BASE}/items/user_progress`, { + method: 'POST', + headers: auth(userToken), + body: JSON.stringify(payload), + }) + const data = await res.json() + if (!res.ok) return null + return data.data +} + +export async function addPointsToPair(pairId, newPoints, userToken) { + const res = await fetch(`${BASE}/items/learning_pairs/${pairId}`, { + method: 'PATCH', + headers: auth(userToken), + body: JSON.stringify({ points: newPoints }), + }) + return res.ok +} + +export async function addPointsToUser(userId, newTotal, userToken) { + const res = await fetch(`${BASE}/users/${userId}`, { + method: 'PATCH', + headers: auth(userToken), + body: JSON.stringify({ points_total: newTotal }), + }) + return res.ok +} + +// Asset-URL inkl. User-Token, da Directus Hetzner-Storage standardmäßig nicht öffentlich ist +export function assetUrl(fileId, userToken) { + if (!fileId) return null + const base = `${BASE}/assets/${fileId}` + return userToken ? `${base}?access_token=${encodeURIComponent(userToken)}` : base +} + +// Lädt qa_pairs (db_pairs) auf einem bestimmten Level und liefert pro Pair +// das zugehörige Bild + den fertig aufgelösten Statement-Satz zurück. +export async function getQAPairsAtLevel(level, userToken, langSuffix = 'de') { + const fields = [ + 'id', + 'picture.picture', + 'selections', + 'word_main.db_words_id.id', + 'word_main.db_words_id.titel_de', + 'word_main.db_words_id.titel_en', + 'word_main.db_words_id.titel_se', + 'pairs.db_pairs_id.id', + 'pairs.db_pairs_id.level', + 'pairs.db_pairs_id.status', + 'pairs.db_pairs_id.statement.db_statement_id.statement_de', + 'pairs.db_pairs_id.statement.db_statement_id.statement_en', + 'pairs.db_pairs_id.statement.db_statement_id.statement_se', + ].join(',') + + const filter = encodeURIComponent( + JSON.stringify({ pairs: { db_pairs_id: { level: { _eq: level } } } }) + ) + + const res = await fetch( + `${BASE}/items/db_objects?fields=${fields}&filter=${filter}&limit=-1`, + { headers: auth(userToken) } + ) + const data = await res.json() + if (!res.ok) return [] + + const statementKey = `statement_${langSuffix}` + const titelKey = `titel_${langSuffix}` + const result = [] + + for (const obj of data.data || []) { + const wordsById = {} + let primaryWord = '' + for (const w of obj.word_main || []) { + const word = w.db_words_id + if (!word) continue + const text = word[titelKey] || word.titel_de || '' + if (text) wordsById[word.id] = text + if (!primaryWord && text) primaryWord = text + } + + for (const p of obj.pairs || []) { + const pair = p.db_pairs_id + if (!pair) continue + if (pair.level !== level) continue + if (pair.status === 'archived') continue + + const stmtRow = pair.statement?.[0]?.db_statement_id + if (!stmtRow) continue + const raw = stmtRow[statementKey] || stmtRow.statement_de || '' + if (!raw) continue + + const statement = raw.replace(/\{([^}]+)\}/g, (_m, ref) => { + const parts = ref.split('.') + const wid = parts[parts.length - 1] + return wordsById[wid] || primaryWord + }) + + result.push({ + pairId: pair.id, + level: pair.level, + statement, + pictureFileId: obj.picture?.picture || null, + objectId: obj.id, + primaryWord, + selections: obj.selections || null, + }) + } + } + + return result +} + +export async function getProfilData(userToken) { + const res = await fetch( + `${BASE}/users/me?fields=id,username.id,username.username_public,` + + `language_native,language_target,points_total,streak_days`, + { headers: auth(userToken) } + ) + const data = await res.json() + if (!res.ok) throw new Error('Profil konnte nicht geladen werden.') + return data.data +} diff --git a/src/components/AudioQuizCard.css b/src/components/AudioQuizCard.css new file mode 100644 index 0000000..9785ddd --- /dev/null +++ b/src/components/AudioQuizCard.css @@ -0,0 +1,191 @@ +/* Dark card */ +.aq-card { background: #F5EFE6; } + +.aq-header { + background: #2C1A0E; +} + +.aq-points-pill { + background: #2C1A0E !important; + border-color: #4A3020 !important; + color: #C4A882 !important; +} + +/* Dark image area */ +.aq-image { + background: #2C1A0E; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 14px; + padding: 32px 20px 20px; + min-height: 220px; + cursor: pointer; +} + +/* Speaker ring */ +.aq-speaker-ring { + width: 100px; + height: 100px; + border-radius: 50%; + border: 2px solid rgba(196, 168, 130, 0.25); + display: flex; + align-items: center; + justify-content: center; + transition: border-color 0.3s; +} + +.aq-speaker-ring.aq-playing { + border-color: rgba(196, 168, 130, 0.6); + animation: aq-pulse 1.2s ease-in-out infinite; +} + +@keyframes aq-pulse { + 0%, 100% { box-shadow: 0 0 0 0 rgba(196, 168, 130, 0.15); } + 50% { box-shadow: 0 0 0 14px rgba(196, 168, 130, 0.05); } +} + +.aq-speaker-icon { + width: 68px; + height: 68px; + border-radius: 14px; + background: rgba(196, 168, 130, 0.08); + border: 1.5px solid rgba(196, 168, 130, 0.2); + display: flex; + align-items: center; + justify-content: center; +} + +/* Dots */ +.aq-dots { + display: flex; + gap: 5px; +} + +.aq-dot { + width: 5px; + height: 5px; + border-radius: 50%; + background: rgba(196, 168, 130, 0.3); +} + +/* Hint */ +.aq-tap-hint { + font-family: 'Nunito', sans-serif; + font-size: 12px; + color: rgba(196, 168, 130, 0.5); + letter-spacing: 0.02em; +} + +/* Question */ +.aq-question { + font-family: 'Nunito', sans-serif; + font-size: 15px; + font-weight: 700; + color: #4A3728; + margin-bottom: 14px; +} + +/* Options */ +.aq-options { + display: flex; + flex-direction: column; + gap: 8px; + margin-bottom: 16px; +} + +.aq-option { + display: flex; + align-items: center; + gap: 14px; + padding: 14px 16px; + border-radius: 14px; + background: #EDE0CE; + border: 1px solid transparent; + cursor: pointer; + text-align: left; + transition: background 0.15s, border-color 0.15s; +} + +.aq-option:hover { background: #D4C4AE; } + +.aq-option-label { + font-family: 'Nunito', sans-serif; + font-size: 12px; + font-weight: 700; + color: #8C7A65; + width: 16px; + flex-shrink: 0; +} + +.aq-option-text { + font-family: 'Nunito', sans-serif; + font-size: 14px; + font-weight: 600; + color: #4A3728; +} + +.aq-option-selected { + background: rgba(122, 92, 58, 0.12); + border-color: #7A5C3A; +} + +.aq-option-correct { + background: rgba(90, 122, 58, 0.12); + border-color: #5a7a3a; +} +.aq-option-correct .aq-option-label, +.aq-option-correct .aq-option-text { color: #3a5a1e; } + +.aq-option-wrong { + background: rgba(160, 90, 58, 0.12); + border-color: #a05a3a; +} +.aq-option-wrong .aq-option-label, +.aq-option-wrong .aq-option-text { color: #a05a3a; } + +.aq-option-reveal { + background: rgba(90, 122, 58, 0.08); + border-color: rgba(90, 122, 58, 0.4); +} + +/* Success / retry */ +.aq-success-bar { + display: flex; + justify-content: space-between; + align-items: center; + background: #EDE0CE; + border: 0.5px solid #D4B896; + border-radius: 12px; + padding: 12px 16px; +} + +.aq-success-left { + font-family: 'Nunito', sans-serif; + font-size: 13px; + font-weight: 700; + color: #4A3728; +} + +.aq-success-right { + font-family: 'Nunito', sans-serif; + font-size: 12px; + color: #8C7A65; +} + +.aq-retry-btn { + width: 100%; + padding: 13px; + border: none; + border-radius: 14px; + background: #7A5C3A; + color: #F5EFE6; + font-family: 'Nunito', sans-serif; + font-size: 14px; + font-weight: 700; + cursor: pointer; + transition: background 0.15s; +} + +.aq-retry-btn:hover { background: #4A3728; } diff --git a/src/components/AudioQuizCard.jsx b/src/components/AudioQuizCard.jsx new file mode 100644 index 0000000..3bf1b73 --- /dev/null +++ b/src/components/AudioQuizCard.jsx @@ -0,0 +1,106 @@ +import { useState, useRef } from 'react' +import './CardShared.css' +import './AudioQuizCard.css' +import { triggerConfetti } from '../utils/confetti' + +export default function AudioQuizCard({ card }) { + const [playing, setPlaying] = useState(false) + const [selected, setSelected] = useState(null) + const [status, setStatus] = useState('idle') // idle | correct | wrong + const timerRef = useRef(null) + + const playAudio = () => { + if (playing) return + setPlaying(true) + if (card.audioSrc) { + const audio = new Audio(card.audioSrc) + audio.play() + audio.onended = () => setPlaying(false) + } else { + // Simulate playback + timerRef.current = setTimeout(() => setPlaying(false), 1800) + } + } + + const pick = (id) => { + if (status === 'correct') return + setSelected(id) + const correct = id === card.correct + setStatus(correct ? 'correct' : 'wrong') + if (correct) triggerConfetti() + } + + const reset = () => { setSelected(null); setStatus('idle') } + + const optionClass = (id) => { + let cls = 'aq-option' + if (status === 'idle' && selected === id) cls += ' aq-option-selected' + if (status === 'correct' && id === card.correct) cls += ' aq-option-correct' + if (status === 'wrong' && id === selected) cls += ' aq-option-wrong' + if (status === 'wrong' && id === card.correct) cls += ' aq-option-reveal' + return cls + } + + return ( +
+
+ {card.language} + ★ +{card.points} Punkt +
+ +
+
+
+ +
+
+ +
+ {card.options.map((_, i) => ( + + ))} +
+ +

Tippe zum Abspielen

+
+ +
+

{card.prompt}

+ +
+ {card.options.map(opt => ( + + ))} +
+ +
+ + {status === 'correct' && ( +
+ ★ +{card.points} Punkt erhalten + Gesamt: {card.totalPoints} Punkte +
+ )} + {status === 'wrong' && ( + + )} +
+
+ ) +} diff --git a/src/components/CardShared.css b/src/components/CardShared.css new file mode 100644 index 0000000..eeb93a1 --- /dev/null +++ b/src/components/CardShared.css @@ -0,0 +1,114 @@ +/* Shared styles for all feed cards */ +.nw-card { + width: 100%; + max-width: 360px; + border-radius: 24px; + overflow: hidden; + border: 0.5px solid #D4B896; + box-shadow: 0 4px 24px rgba(74, 55, 40, 0.1); + background: #F5EFE6; +} + +/* Pills row above the image */ +.nw-card-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 14px 10px; + background: #F5EFE6; +} + +.nw-lang-pill { + background: #7A5C3A; + color: #EDE0CE; + font-size: 12px; + font-weight: 700; + padding: 5px 12px; + border-radius: 99px; + font-family: 'Nunito', sans-serif; + letter-spacing: 0.03em; +} + +.nw-points-pill { + background: #F5EFE6; + color: #7A5C3A; + font-size: 12px; + font-weight: 700; + padding: 5px 12px; + border-radius: 99px; + border: 0.5px solid #D4B896; + font-family: 'Nunito', sans-serif; +} + +/* 1:1 square image area */ +.nw-image { + background: #C4A882; + position: relative; + width: 100%; + aspect-ratio: 1 / 1; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; +} + +.nw-bubble { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -70%); + background: rgba(237, 224, 206, 0.55); + color: rgba(74, 55, 40, 0.7); + font-family: 'Lora', Georgia, serif; + font-size: 26px; + font-weight: 700; + width: 100px; + height: 100px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + backdrop-filter: blur(4px); +} + +.nw-content { + padding: 18px 20px 20px; +} + +.nw-word-row { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; +} + +.nw-word { + font-family: 'Lora', Georgia, serif; + font-size: 28px; + font-weight: 700; + color: #4A3728; +} + +.nw-translation { + background: #D4B896; + color: #4A3728; + font-size: 12px; + font-weight: 600; + padding: 5px 12px; + border-radius: 99px; + font-family: 'Nunito', sans-serif; +} + +.nw-divider { + height: 0.5px; + background: #D4B896; + margin-bottom: 14px; +} + +.nw-label { + display: block; + font-size: 12px; + color: #8C7A65; + font-family: 'Nunito', sans-serif; + margin-bottom: 10px; +} diff --git a/src/components/ImagePickCard.css b/src/components/ImagePickCard.css new file mode 100644 index 0000000..44a94f6 --- /dev/null +++ b/src/components/ImagePickCard.css @@ -0,0 +1,95 @@ +.ip-image { + flex-direction: column; + justify-content: flex-end; + gap: 0; + padding-bottom: 12px; +} + +/* Pagination dots */ +.ip-dots { + display: flex; + gap: 5px; + margin-top: 10px; +} + +.ip-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: rgba(74, 55, 40, 0.25); + transition: background 0.2s; +} + +.ip-dot-active { + background: rgba(74, 55, 40, 0.7); +} + +/* Buttons */ +.ip-btn-row { + display: flex; + gap: 10px; +} + +.ip-btn { + flex: 1; + padding: 13px 8px; + border-radius: 14px; + border: 1px solid #D4B896; + background: #F5EFE6; + font-family: 'Nunito', sans-serif; + font-size: 14px; + font-weight: 700; + color: #8C7A65; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + transition: background 0.15s, color 0.15s; +} + +.ip-btn:disabled { + opacity: 0.4; + cursor: default; +} + +.ip-btn-ja { + background: #7A5C3A; + color: #F5EFE6; + border-color: #7A5C3A; +} + +.ip-btn-ja:hover:not(:disabled) { background: #4A3728; border-color: #4A3728; } +.ip-btn-nein:hover:not(:disabled) { background: #EDE0CE; } + +/* Feedback */ +.ip-wrong-text { + font-family: 'Nunito', sans-serif; + font-size: 12px; + color: #a05a3a; + margin-bottom: 10px; +} + +/* Success bar */ +.ip-success-bar { + display: flex; + justify-content: space-between; + align-items: center; + background: #EDE0CE; + border: 0.5px solid #D4B896; + border-radius: 12px; + padding: 12px 16px; +} + +.ip-success-left { + font-family: 'Nunito', sans-serif; + font-size: 13px; + font-weight: 700; + color: #4A3728; +} + +.ip-success-right { + font-family: 'Nunito', sans-serif; + font-size: 12px; + color: #8C7A65; +} diff --git a/src/components/ImagePickCard.jsx b/src/components/ImagePickCard.jsx new file mode 100644 index 0000000..ff4bccd --- /dev/null +++ b/src/components/ImagePickCard.jsx @@ -0,0 +1,125 @@ +import { useState } from 'react' +import './CardShared.css' +import './ImagePickCard.css' +import { triggerConfetti } from '../utils/confetti' + +const ILLUSTRATIONS = { + sofa: ( + + ), + lamp: ( + + ), + table: ( + + ), + chair: ( + + ), +} + +export default function ImagePickCard({ card }) { + const [index, setIndex] = useState(0) + const [status, setStatus] = useState('idle') // idle | correct | wrong + + const currentImage = card.images[index] + const isLast = index === card.images.length - 1 + + const handleJa = () => { + if (currentImage === card.correctImage) { + setStatus('correct') + triggerConfetti() + } else { + setStatus('wrong') + } + } + + const handleNein = () => { + if (status === 'wrong') setStatus('idle') + if (index < card.images.length - 1) { + setIndex(index + 1) + setStatus('idle') + } + } + + const reset = () => { setIndex(0); setStatus('idle') } + + return ( +
+
+ {card.language} + ★ +{card.points} Punkt +
+ +
+ {ILLUSTRATIONS[currentImage]} +
+ {card.images.map((_, i) => ( + + ))} +
+
+ +
+
+ {card.word} + {card.translation} +
+
+

{card.prompt}

+ + {status === 'correct' ? ( +
+ ★ +{card.points} Punkt erhalten + Gesamt: {card.totalPoints} Punkte +
+ ) : ( + <> + {status === 'wrong' && ( +

Das ist nicht richtig — weiter suchen!

+ )} +
+ + +
+ + )} +
+
+ ) +} diff --git a/src/components/ImageQuizCard.css b/src/components/ImageQuizCard.css new file mode 100644 index 0000000..9136852 --- /dev/null +++ b/src/components/ImageQuizCard.css @@ -0,0 +1,140 @@ +.iq-image { + flex-direction: column; + gap: 0; +} + +.iq-scene-label { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + font-family: 'Lora', Georgia, serif; + font-size: 22px; + font-weight: 700; + color: rgba(74, 55, 40, 0.55); + letter-spacing: 0.01em; + z-index: 1; + pointer-events: none; +} + +/* Question row */ +.iq-question-row { + display: flex; + align-items: baseline; + gap: 8px; + margin-bottom: 14px; +} + +.iq-question { + font-family: 'Nunito', sans-serif; + font-size: 15px; + font-weight: 700; + color: #4A3728; +} + +.iq-hint { + font-family: 'Nunito', sans-serif; + font-size: 12px; + color: #8C7A65; +} + +/* Chip grid */ +.iq-chips { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 16px; +} + +.iq-chip { + font-family: 'Nunito', sans-serif; + font-size: 14px; + font-weight: 600; + color: #4A3728; + background: transparent; + border: 1.5px solid #C4A882; + border-radius: 99px; + padding: 7px 16px; + cursor: pointer; + transition: background 0.15s, border-color 0.15s, color 0.15s; +} + +.iq-chip:hover { + background: rgba(196, 168, 130, 0.15); +} + +.iq-chip-selected { + background: rgba(122, 92, 58, 0.12); + border-color: #7A5C3A; + color: #4A3728; +} + +.iq-chip-correct { + background: rgba(90, 122, 58, 0.12); + border-color: #5a7a3a; + color: #3a5a1e; +} + +.iq-chip-wrong { + background: rgba(160, 90, 58, 0.12); + border-color: #a05a3a; + color: #a05a3a; +} + +/* Confirm button */ +.iq-confirm-btn { + width: 100%; + padding: 14px; + border: none; + border-radius: 14px; + background: #7A5C3A; + color: #F5EFE6; + font-family: 'Nunito', sans-serif; + font-size: 15px; + font-weight: 700; + cursor: pointer; + transition: background 0.15s; +} + +.iq-confirm-btn:hover:not(.iq-confirm-disabled) { + background: #4A3728; +} + +.iq-confirm-btn.iq-confirm-disabled { + background: #D4B896; + color: #8C7A65; + cursor: default; +} + +/* Feedback text */ +.iq-feedback { + font-size: 12px; + font-family: 'Nunito', sans-serif; + margin-bottom: 10px; +} + +.iq-wrong-text { color: #a05a3a; } + +/* Success bar */ +.iq-success-bar { + display: flex; + justify-content: space-between; + align-items: center; + background: #EDE0CE; + border: 0.5px solid #D4B896; + border-radius: 12px; + padding: 12px 16px; +} + +.iq-success-left { + font-size: 13px; + font-weight: 700; + color: #4A3728; + font-family: 'Nunito', sans-serif; +} + +.iq-success-right { + font-size: 12px; + color: #8C7A65; + font-family: 'Nunito', sans-serif; +} diff --git a/src/components/ImageQuizCard.jsx b/src/components/ImageQuizCard.jsx new file mode 100644 index 0000000..0f77bbf --- /dev/null +++ b/src/components/ImageQuizCard.jsx @@ -0,0 +1,110 @@ +import { useState } from 'react' +import './CardShared.css' +import './ImageQuizCard.css' +import { triggerConfetti } from '../utils/confetti' + +function LivingRoomIllustration() { + return ( + + ) +} + +export default function ImageQuizCard({ card }) { + const [selected, setSelected] = useState([]) + const [status, setStatus] = useState('idle') // idle | correct | wrong + + const toggle = (word) => { + if (status !== 'idle') return + setSelected(prev => + prev.includes(word) ? prev.filter(w => w !== word) : [...prev, word] + ) + } + + const confirm = () => { + if (selected.length === 0) return + const isCorrect = + selected.length === card.correct.length && + selected.every(w => card.correct.includes(w)) + setStatus(isCorrect ? 'correct' : 'wrong') + if (isCorrect) triggerConfetti() + } + + const reset = () => { setSelected([]); setStatus('idle') } + + return ( +
+
+ {card.language} + ★ +{card.points} Punkt +
+ +
+ {card.scene} + +
+ +
+
+ {card.prompt} + Mehrere möglich +
+ +
+ {card.choices.map(word => { + const isSelected = selected.includes(word) + const isCorrectWord = card.correct.includes(word) + let chipClass = 'iq-chip' + if (status === 'correct' && isCorrectWord) chipClass += ' iq-chip-correct' + else if (status === 'wrong' && isSelected && !isCorrectWord) chipClass += ' iq-chip-wrong' + else if (isSelected) chipClass += ' iq-chip-selected' + return ( + + ) + })} +
+ +
+ + {status === 'correct' ? ( +
+ ★ +{card.points} Punkt erhalten + Gesamt: {card.totalPoints} Punkte +
+ ) : ( + <> + {status === 'wrong' && ( +

Nicht alle richtig — versuch es nochmal.

+ )} + + + )} +
+
+ ) +} diff --git a/src/components/LanguageParentCard.css b/src/components/LanguageParentCard.css new file mode 100644 index 0000000..bf4a076 --- /dev/null +++ b/src/components/LanguageParentCard.css @@ -0,0 +1,145 @@ +.lp-card { background: #F5EFE6; } + +/* 1:1 Bild */ +.lp-image { + width: 100%; + aspect-ratio: 1 / 1; + background: #C4A882; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + position: relative; +} + +.lp-image img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +/* Polygon-Overlay: liegt deckungsgleich über dem Bild */ +.lp-overlay { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + pointer-events: none; +} + +.lp-polygon { + fill: rgba(255, 236, 170, 0.45); + stroke: #FFD86B; + stroke-width: 5; + stroke-linejoin: round; + stroke-dasharray: 18 7; + vector-effect: non-scaling-stroke; + opacity: 0; +} + +.lp-overlay-on .lp-polygon { + animation: + lp-flash 1.9s ease-out forwards, + lp-shimmer 0.45s linear infinite; +} + +@keyframes lp-flash { + 0% { opacity: 0; filter: none; } + 7% { opacity: 1; filter: drop-shadow(0 0 8px rgba(255, 216, 107, 1)) + drop-shadow(0 0 22px rgba(255, 216, 107, 0.9)) + drop-shadow(0 0 44px rgba(255, 195, 30, 0.75)); } + 32% { opacity: 1; filter: drop-shadow(0 0 6px rgba(255, 216, 107, 0.85)) + drop-shadow(0 0 16px rgba(255, 216, 107, 0.55)); } + 78% { opacity: 0.4; filter: drop-shadow(0 0 4px rgba(255, 216, 107, 0.4)); } + 100% { opacity: 0; filter: none; } +} + +@keyframes lp-shimmer { + to { stroke-dashoffset: -25; } +} + +.lp-image-fallback { + font-size: 56px; + color: rgba(74, 55, 40, 0.4); +} + +.lp-statement { + font-family: 'Lora', Georgia, serif; + font-size: 20px; + font-weight: 700; + color: #4A3728; + line-height: 1.35; + margin: 4px 0 14px; +} + +/* Buttons-Reihe: Lautsprecher + Mikro */ +.lp-actions { + display: flex; + gap: 12px; + align-items: stretch; +} + +.lp-btn { + flex: 1; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + height: 52px; + border-radius: 12px; + background: #F5EFE6; + border: 1px solid #D4B896; + color: #7A5C3A; + font-family: 'Nunito', sans-serif; + font-size: 14px; + font-weight: 700; + cursor: pointer; + position: relative; + overflow: visible; + transition: background 0.15s, border-color 0.15s, transform 0.15s; +} + +.lp-btn:hover { background: #EDE0CE; } +.lp-btn:active { transform: scale(0.98); } + +.lp-btn-stt.lp-listening { + border-color: #7A5C3A; + background: #EDE0CE; +} + +.lp-btn-stt.lp-wrong { + border-color: #c0826a; +} + +.lp-pulse { + position: absolute; + inset: -6px; + border-radius: 16px; + border: 2px solid #7A5C3A; + animation: lp-pulse 1.2s ease-out infinite; + pointer-events: none; +} + +@keyframes lp-pulse { + 0% { opacity: 0.7; transform: scale(1); } + 100% { opacity: 0; transform: scale(1.2); } +} + +.lp-hint { + margin-top: 10px; + font-size: 13px; + color: #7A5C3A; + font-family: 'Nunito', sans-serif; + font-weight: 600; +} + +.lp-feedback { + margin-top: 10px; + font-size: 13px; + font-family: 'Nunito', sans-serif; + font-weight: 700; +} + +.lp-wrong-text { color: #a05a3a; } +.lp-correct-text { color: #5a7a3a; } diff --git a/src/components/LanguageParentCard.jsx b/src/components/LanguageParentCard.jsx new file mode 100644 index 0000000..3b4e70a --- /dev/null +++ b/src/components/LanguageParentCard.jsx @@ -0,0 +1,222 @@ +import { useState, useRef } from 'react' +import './CardShared.css' +import './NewWordVoiceCard.css' +import './LanguageParentCard.css' +import { triggerConfetti } from '../utils/confetti' + +function normalize(s) { + return (s || '') + .toLowerCase() + .normalize('NFD') + .replace(/[̀-ͯ]/g, '') + .replace(/[^\p{L}\p{N}\s]/gu, ' ') + .replace(/\s+/g, ' ') + .trim() +} + +function similarity(a, b) { + const A = normalize(a) + const B = normalize(b) + if (!A || !B) return 0 + if (A === B) return 1 + const wa = A.split(' ') + const wb = new Set(B.split(' ')) + let hits = 0 + for (const w of wa) if (wb.has(w)) hits++ + return hits / Math.max(wa.length, wb.size) +} + +export default function LanguageParentCard({ card, onComplete }) { + const [status, setStatus] = useState('idle') // idle | listening | correct | wrong + const [heard, setHeard] = useState('') + const [imgSize, setImgSize] = useState(null) // { w, h } natural image dims + const [highlight, setHighlight] = useState(false) + const recognitionRef = useRef(null) + const reportedRef = useRef(false) + const speakGenRef = useRef(0) + + const polygon = (() => { + const sel = card.selections?.[0] + if (!sel) return null + if (sel.mode === 'polygon' && Array.isArray(sel.polygon)) return sel.polygon + if (Array.isArray(sel.points)) return sel.points + return null + })() + + const report = (result) => { + if (reportedRef.current) return + reportedRef.current = true + onComplete?.(result) + } + + const speak = () => { + // Eigener Generationszähler: nur der jeweils letzte Aufruf darf den Glow am Ende abschalten + const gen = ++speakGenRef.current + const start = performance.now() + const minMs = 1400 + setHighlight(true) + + const finish = () => { + if (speakGenRef.current !== gen) return // ein neuerer Klick läuft schon + const elapsed = performance.now() - start + const wait = Math.max(0, minMs - elapsed) + setTimeout(() => { + if (speakGenRef.current === gen) setHighlight(false) + }, wait) + } + + if (!('speechSynthesis' in window)) { + finish() + return + } + window.speechSynthesis.cancel() + const u = new SpeechSynthesisUtterance(card.statement) + u.lang = card.speechLang || 'de-DE' + u.rate = 0.70 + u.onend = finish + u.onerror = finish + window.speechSynthesis.speak(u) + // Fallback, falls onend nicht feuert (manche Engines) + setTimeout(finish, 6000) + } + + const startListening = () => { + const SR = window.SpeechRecognition || window.webkitSpeechRecognition + if (!SR) { + setStatus('listening') + setTimeout(() => { setStatus('correct'); triggerConfetti(); report('correct') }, 1500) + return + } + const rec = new SR() + rec.lang = card.speechLang || 'de-DE' + rec.interimResults = false + rec.maxAlternatives = 3 + recognitionRef.current = rec + + setStatus('listening') + setHeard('') + + rec.onresult = (e) => { + const alts = Array.from(e.results[0]).map(r => r.transcript.trim()) + let best = 0 + let bestText = alts[0] || '' + for (const a of alts) { + const s = similarity(a, card.statement) + if (s > best) { best = s; bestText = a } + } + setHeard(bestText) + if (best >= 0.7) { + setStatus('correct') + triggerConfetti() + report('correct') + } else { + setStatus('wrong') + } + } + rec.onerror = () => setStatus('wrong') + rec.onend = () => { if (status === 'listening') setStatus(prev => prev === 'listening' ? 'wrong' : prev) } + try { rec.start() } catch { setStatus('idle') } + } + + const reset = () => { + recognitionRef.current?.abort() + setStatus('idle') + setHeard('') + } + + return ( +
+
+ {card.language} + ★ +{card.points} Punkt +
+ +
+ {card.imageUrl ? ( + <> + {card.primaryWord setImgSize({ w: e.currentTarget.naturalWidth, h: e.currentTarget.naturalHeight })} + /> + {polygon && imgSize && ( + + )} + + ) : ( + + )} +
+ +
+

{card.statement}

+ +
+ + {status !== 'correct' ? ( + <> +
+ + + +
+ + {status === 'listening' &&

Ich höre dir zu …

} + {status === 'wrong' && ( +

+ {heard ? <>Verstanden: „{heard}" — versuch's nochmal. : <>Nicht erkannt — versuch's nochmal.} +

+ )} + + ) : ( + <> +

Perfekt! 🎉

+
+ ★ +{card.points} Punkt erhalten + Gesamt: {card.totalPoints} Punkte +
+ + )} +
+
+ ) +} diff --git a/src/components/LetterOrderCard.css b/src/components/LetterOrderCard.css new file mode 100644 index 0000000..afe7554 --- /dev/null +++ b/src/components/LetterOrderCard.css @@ -0,0 +1,129 @@ +.lo-prompt-row { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + margin-bottom: 14px; +} + +.lo-prompt { + font-family: 'Nunito', sans-serif; + font-size: 13px; + color: #8C7A65; + line-height: 1.5; + flex: 1; +} + +/* Answer slot */ +.lo-answer-area { + display: flex; + flex-wrap: wrap; + gap: 6px; + min-height: 52px; + border: 1.5px solid #D4B896; + border-radius: 14px; + padding: 10px 12px; + margin-bottom: 14px; + transition: border-color 0.2s; + background: rgba(255,255,255,0.4); +} + +.lo-answer-correct { border-color: #5a7a3a; background: rgba(90,122,58,0.06); } +.lo-answer-wrong { border-color: #c0826a; background: rgba(160,90,58,0.06); } + +/* Chips */ +.lo-chip { + width: 38px; + height: 38px; + border-radius: 10px; + display: flex; + align-items: center; + justify-content: center; + font-family: 'Lora', Georgia, serif; + font-size: 17px; + font-weight: 700; +} + +.lo-chip-placed { + background: #7A5C3A; + color: #F5EFE6; +} + +.lo-chip-available { + background: #7A5C3A; + color: #F5EFE6; + border: none; + cursor: pointer; + transition: background 0.15s, transform 0.1s; +} + +.lo-chip-available:hover { background: #4A3728; } +.lo-chip-available:active { transform: scale(0.93); } + +/* Available letters row */ +.lo-available { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-bottom: 12px; + min-height: 38px; +} + +/* Back / Reset button */ +.lo-back-btn { + font-family: 'Nunito', sans-serif; + font-size: 13px; + font-weight: 700; + color: #7A5C3A; + background: transparent; + border: none; + cursor: pointer; + padding: 2px 0; + opacity: 1; + transition: opacity 0.15s; +} + +.lo-back-btn.lo-back-disabled { + opacity: 0.35; + cursor: default; +} + +/* Feedback */ +.lo-wrong-text { + font-family: 'Nunito', sans-serif; + font-size: 12px; + color: #a05a3a; + margin-bottom: 8px; +} + +/* Success */ +.lo-success-text { + font-family: 'Nunito', sans-serif; + font-size: 14px; + font-weight: 700; + color: #5a7a3a; + margin-bottom: 12px; +} + +.lo-success-bar { + display: flex; + justify-content: space-between; + align-items: center; + background: #EDE0CE; + border: 0.5px solid #D4B896; + border-radius: 12px; + padding: 12px 16px; +} + +.lo-success-left { + font-family: 'Nunito', sans-serif; + font-size: 13px; + font-weight: 700; + color: #4A3728; +} + +.lo-success-right { + font-family: 'Nunito', sans-serif; + font-size: 12px; + color: #8C7A65; +} diff --git a/src/components/LetterOrderCard.jsx b/src/components/LetterOrderCard.jsx new file mode 100644 index 0000000..e53e6e6 --- /dev/null +++ b/src/components/LetterOrderCard.jsx @@ -0,0 +1,131 @@ +import { useState, useMemo, useRef } from 'react' +import './CardShared.css' +import './LetterOrderCard.css' +import { triggerConfetti } from '../utils/confetti' + +function shuffle(arr) { + const a = [...arr] + for (let i = a.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [a[i], a[j]] = [a[j], a[i]] + } + return a +} + +function SofaIllustration() { + return ( + + ) +} + +export default function LetterOrderCard({ card, onComplete }) { + const scrambled = useMemo(() => shuffle(card.word.split('')), [card.word]) + const [placed, setPlaced] = useState([]) + const [available, setAvailable] = useState(scrambled) + const [status, setStatus] = useState('idle') // idle | correct | wrong + const reportedRef = useRef(false) + + const report = (result) => { + if (reportedRef.current) return + reportedRef.current = true + onComplete?.(result) + } + + const placeLetter = (idx) => { + if (status === 'correct') return + const letter = available[idx] + const newPlaced = [...placed, letter] + const newAvailable = available.filter((_, i) => i !== idx) + + if (newPlaced.length === card.word.length) { + const correct = newPlaced.join('') === card.word + setPlaced(newPlaced) + setAvailable(newAvailable) + setStatus(correct ? 'correct' : 'wrong') + if (correct) { triggerConfetti(); report('correct') } + } else { + setPlaced(newPlaced) + setAvailable(newAvailable) + } + } + + const removeLast = () => { + if (placed.length === 0) return + const letter = placed[placed.length - 1] + setPlaced(placed.slice(0, -1)) + setAvailable([...available, letter]) + setStatus('idle') + } + + const reset = () => { + setPlaced([]) + setAvailable(scrambled) + setStatus('idle') + } + + return ( +
+
+ {card.language} + ★ +{card.points} Punkt +
+
+ +
+ +
+
+ {card.prompt} + {card.translation} +
+ +
+ {placed.map((letter, i) => ( + {letter} + ))} +
+ +
+ + {status === 'correct' ? ( + <> +

Perfekt! Bra jobbat!

+
+ ★ +{card.points} Punkt erhalten + Gesamt: {card.totalPoints} Punkte +
+ + ) : ( + <> +
+ {available.map((letter, i) => ( + + ))} +
+ {status === 'wrong' && ( +

Nicht ganz — versuch es nochmal.

+ )} + + + )} +
+
+ ) +} diff --git a/src/components/NewWordTextCard.css b/src/components/NewWordTextCard.css new file mode 100644 index 0000000..1e34ebb --- /dev/null +++ b/src/components/NewWordTextCard.css @@ -0,0 +1,77 @@ +.nwt-input-row { + display: flex; + align-items: center; + gap: 8px; + background: #fff; + border: 1px solid #D4B896; + border-radius: 12px; + padding: 4px 6px 4px 12px; + transition: border-color 0.2s; +} + +.nwt-input-row.nwt-wrong { + border-color: #c0826a; +} + +.nwt-input { + flex: 1; + border: none; + outline: none; + background: transparent; + font-family: 'Lora', Georgia, serif; + font-size: 16px; + color: #4A3728; + padding: 6px 0; +} + +.nwt-input::placeholder { + color: rgba(74, 55, 40, 0.3); +} + +.nwt-submit-btn { + width: 36px; + height: 36px; + border-radius: 8px; + background: #7A5C3A; + border: none; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + color: #F5EFE6; + flex-shrink: 0; + transition: background 0.15s; +} + +.nwt-submit-btn:hover { background: #4A3728; } + +.nwt-feedback { + font-size: 12px; + font-family: 'Nunito', sans-serif; + margin-top: 8px; +} + +.nwt-wrong-text { color: #a05a3a; } + +.nwt-success-bar { + display: flex; + justify-content: space-between; + align-items: center; + background: #EDE0CE; + border: 0.5px solid #D4B896; + border-radius: 12px; + padding: 12px 16px; +} + +.nwt-success-left { + font-size: 13px; + font-weight: 700; + color: #4A3728; + font-family: 'Nunito', sans-serif; +} + +.nwt-success-right { + font-size: 12px; + color: #8C7A65; + font-family: 'Nunito', sans-serif; +} diff --git a/src/components/NewWordTextCard.jsx b/src/components/NewWordTextCard.jsx new file mode 100644 index 0000000..42e12e2 --- /dev/null +++ b/src/components/NewWordTextCard.jsx @@ -0,0 +1,80 @@ +import { useState, useRef } from 'react' +import './CardShared.css' +import './NewWordTextCard.css' +import TableIllustration from './TableIllustration' +import { triggerConfetti } from '../utils/confetti' + +export default function NewWordTextCard({ card, onComplete }) { + const [input, setInput] = useState('') + const [status, setStatus] = useState('idle') // idle | correct | wrong + const reportedRef = useRef(false) + + const report = (result) => { + if (reportedRef.current) return + reportedRef.current = true + onComplete?.(result) + } + + const check = () => { + if (!input.trim()) return + const correct = input.trim().toLowerCase() === card.word.toLowerCase() + setStatus(correct ? 'correct' : 'wrong') + if (correct) { + triggerConfetti() + report('correct') + } + } + + const reset = () => { setInput(''); setStatus('idle') } + + return ( +
+
+ {card.language} + ★ +{card.points} Punkt +
+
+
{card.baseForm}
+ +
+ +
+
+ {card.word} + {card.translation} +
+
+ + {status !== 'correct' ? ( + <> + +
+ setInput(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && check()} + placeholder={`${card.word} …`} + autoCorrect="off" autoCapitalize="none" spellCheck={false} + /> + +
+ {status === 'wrong' && ( +

Nicht ganz — versuch es nochmal.

+ )} + + ) : ( +
+ ★ +{card.points} Punkt erhalten + Gesamt: {card.totalPoints} Punkte +
+ )} +
+
+ ) +} diff --git a/src/components/NewWordVoiceCard.css b/src/components/NewWordVoiceCard.css new file mode 100644 index 0000000..946ed94 --- /dev/null +++ b/src/components/NewWordVoiceCard.css @@ -0,0 +1,95 @@ +.nwv-mic-row { + display: flex; + align-items: center; + gap: 14px; + min-height: 52px; +} + +.nwv-mic-btn { + width: 52px; + height: 52px; + border-radius: 12px; + background: #F5EFE6; + border: 1px solid #D4B896; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + color: #7A5C3A; + flex-shrink: 0; + position: relative; + transition: background 0.15s, border-color 0.15s; + overflow: visible; +} + +.nwv-mic-btn:hover { background: #EDE0CE; } + +.nwv-mic-btn.nwv-listening { + border-color: #7A5C3A; + background: #EDE0CE; +} + +.nwv-mic-btn.nwv-wrong { + border-color: #c0826a; +} + +.nwv-mic-btn.nwv-done { + border-color: #5a7a3a; + color: #5a7a3a; + cursor: default; +} + +/* Pulse animation when listening */ +.nwv-pulse-ring { + position: absolute; + inset: -6px; + border-radius: 16px; + border: 2px solid #7A5C3A; + animation: mic-pulse 1.2s ease-out infinite; + pointer-events: none; +} + +@keyframes mic-pulse { + 0% { opacity: 0.7; transform: scale(1); } + 100% { opacity: 0; transform: scale(1.25); } +} + +.nwv-hint { + font-size: 13px; + color: #7A5C3A; + font-family: 'Nunito', sans-serif; + font-weight: 600; +} + +.nwv-feedback { + font-size: 13px; + font-family: 'Nunito', sans-serif; + font-weight: 700; +} + +.nwv-wrong-text { color: #a05a3a; } +.nwv-correct-text { color: #5a7a3a; } + +.nwv-success-bar { + display: flex; + justify-content: space-between; + align-items: center; + background: #EDE0CE; + border: 0.5px solid #D4B896; + border-radius: 12px; + padding: 12px 16px; + margin-top: 14px; +} + +.nwv-success-left { + font-size: 13px; + font-weight: 700; + color: #4A3728; + font-family: 'Nunito', sans-serif; +} + +.nwv-success-right { + font-size: 12px; + color: #8C7A65; + font-family: 'Nunito', sans-serif; +} diff --git a/src/components/NewWordVoiceCard.jsx b/src/components/NewWordVoiceCard.jsx new file mode 100644 index 0000000..2deb7c9 --- /dev/null +++ b/src/components/NewWordVoiceCard.jsx @@ -0,0 +1,128 @@ +import { useState, useRef } from 'react' +import './CardShared.css' +import './NewWordVoiceCard.css' +import TableIllustration from './TableIllustration' +import { triggerConfetti } from '../utils/confetti' + +export default function NewWordVoiceCard({ card, onComplete }) { + const [status, setStatus] = useState('idle') // idle | listening | correct | wrong + const recognitionRef = useRef(null) + const reportedRef = useRef(false) + + const report = (result) => { + if (reportedRef.current) return + reportedRef.current = true + onComplete?.(result) + } + + const startListening = () => { + const SR = window.SpeechRecognition || window.webkitSpeechRecognition + if (!SR) { + // Simulate for browsers without speech API + setStatus('listening') + setTimeout(() => { setStatus('correct'); triggerConfetti(); report('correct') }, 2000) + return + } + + const rec = new SR() + rec.lang = card.speechLang || 'sv-SE' + rec.interimResults = false + rec.maxAlternatives = 3 + recognitionRef.current = rec + + setStatus('listening') + + rec.onresult = (e) => { + const heard = Array.from(e.results[0]) + .map((r) => r.transcript.trim().toLowerCase()) + const target = card.word.toLowerCase() + const correct = heard.some((h) => h === target || h.includes(target)) + setStatus(correct ? 'correct' : 'wrong') + if (correct) { triggerConfetti(); report('correct') } + } + + rec.onerror = () => setStatus('wrong') + rec.onend = () => { if (status === 'listening') setStatus('wrong') } + rec.start() + } + + const reset = () => { + recognitionRef.current?.abort() + setStatus('idle') + } + + return ( +
+
+ {card.language} + ★ +{card.points} Punkt +
+
+
{card.baseForm}
+ +
+ +
+
+ {card.word} + {card.translation} +
+
+ + {status !== 'correct' ? ( + <> + +
+ + + {status === 'listening' && ( +

Hören …

+ )} + {status === 'wrong' && ( +

Nicht erkannt — versuch es nochmal.

+ )} + {status === 'correct' && ( +

Bra! Aussprache erkannt.

+ )} +
+ + ) : ( + <> + +
+ +

Bra! Aussprache erkannt.

+
+
+ ★ +{card.points} Punkt erhalten + Gesamt: {card.totalPoints} Punkte +
+ + )} +
+
+ ) +} diff --git a/src/components/SentenceFillCard.jsx b/src/components/SentenceFillCard.jsx new file mode 100644 index 0000000..75734bf --- /dev/null +++ b/src/components/SentenceFillCard.jsx @@ -0,0 +1,97 @@ +import { useState, useRef } from 'react' +import './CardShared.css' +import './NewWordTextCard.css' +import { triggerConfetti } from '../utils/confetti' + +export default function SentenceFillCard({ card, onComplete }) { + const [input, setInput] = useState('') + const [status, setStatus] = useState('idle') // idle | correct | wrong + const [revealed, setRevealed] = useState(false) + const reportedRef = useRef(false) + + const report = (result) => { + if (reportedRef.current) return + reportedRef.current = true + onComplete?.(result) + } + + const check = () => { + if (!input.trim()) return + const correct = input.trim().toLowerCase() === card.word.toLowerCase() + setStatus(correct ? 'correct' : 'wrong') + if (correct) { + triggerConfetti() + report('correct') + } + } + + return ( +
+
+ {card.language} + ★ +{card.points} Punkt +
+ +
+ +
+ {card.translation} +
+ +
+ + {status !== 'correct' ? ( + <> + +
+ setInput(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && check()} + placeholder="Deine Antwort …" + autoCorrect="off" autoCapitalize="none" spellCheck={false} + /> + +
+ {status === 'wrong' && ( + <> +

Nicht ganz — versuch es nochmal.

+ {!revealed ? ( + + ) : ( +

+ Lösung: {card.word} +

+ )} + + )} + + ) : ( +
+ ★ +{card.points} Punkt erhalten + Gesamt: {card.totalPoints} Punkte +
+ )} +
+
+ ) +} diff --git a/src/components/TableIllustration.jsx b/src/components/TableIllustration.jsx new file mode 100644 index 0000000..fa38b86 --- /dev/null +++ b/src/components/TableIllustration.jsx @@ -0,0 +1,11 @@ +export default function TableIllustration() { + return ( + + ) +} diff --git a/src/components/auth/AuthScreen.jsx b/src/components/auth/AuthScreen.jsx new file mode 100644 index 0000000..b381a73 --- /dev/null +++ b/src/components/auth/AuthScreen.jsx @@ -0,0 +1,90 @@ +import { useState } from 'react' +import LoginForm from './LoginForm' +import RegisterStep1 from './RegisterStep1' +import RegisterStep2 from './RegisterStep2' + +const css = ` + :root { + --bg: #F5F0E8; --surface: #FFFCF7; --border: #E2DAD0; + --text: #2C2520; --muted: #9A8F85; --accent: #5C7A5E; + --accent-lt: #EAF0EA; --danger: #C0544A; --danger-lt: #FBF0EF; + --radius: 14px; + } + @import url('https://fonts.googleapis.com/css2?family=Lora:wght@400;500&family=DM+Sans:wght@300;400;500&display=swap'); + @keyframes fadeUp { from { opacity:0; transform:translateY(8px); } to { opacity:1; transform:none; } } +` + +export default function AuthScreen() { + const [mode, setMode] = useState(() => localStorage.getItem('hejyou_last_mode') || 'login') + const [step, setStep] = useState('main') + const [pendingUserId, setPendingUserId] = useState(null) + const [pendingToken, setPendingToken] = useState(null) + const [successName, setSuccessName] = useState('') + + const handleModeChange = (m) => { + setMode(m); localStorage.setItem('hejyou_last_mode', m); setStep('main') + } + + const handleNeedsProfile = (userId, token) => { + setPendingUserId(userId); setPendingToken(token); setStep('profile') + } + + return ( + <> + +
+
+ + {/* Brand */} +
+
+ + + +
+

HejYou

+

Sprachen lernen wie ein Kind

+
+ + {/* Toggle */} + {step === 'main' && ( +
+ {['login', 'register'].map(m => ( + + ))} +
+ )} + + {/* Screens */} + {step === 'main' && mode === 'login' && } + {step === 'main' && mode === 'register' && handleNeedsProfile(id, t)} />} + {step === 'profile' && { setSuccessName(name); setStep('success') }} />} + + {/* Erfolg */} + {step === 'success' && ( +
+
+ + + +
+ + Willkommen, {successName}! + +

Dein Abenteuer beginnt jetzt.

+
+ )} +
+
+ + ) +} diff --git a/src/components/auth/LoginForm.jsx b/src/components/auth/LoginForm.jsx new file mode 100644 index 0000000..b8a4fd4 --- /dev/null +++ b/src/components/auth/LoginForm.jsx @@ -0,0 +1,48 @@ +import { useState } from 'react' +import { login, getMe } from '../../api/directus' +import { useAuth } from '../../context/AuthContext' +import { FormGroup, Input, Button, Alert } from './ui' + +export default function LoginForm({ onNeedsProfile }) { + const { saveToken, setUser } = useAuth() + const [email, setEmail] = useState('') + const [pw, setPw] = useState('') + const [error, setError] = useState('') + const [loading, setLoading] = useState(false) + + const handleSubmit = async (e) => { + e?.preventDefault() + if (!email || !pw) { setError('Bitte E-Mail und Passwort eingeben.'); return } + setError(''); setLoading(true) + try { + const { access_token } = await login(email, pw) + saveToken(access_token) + const me = await getMe(access_token) + setUser(me) + if (!me.username || !me.language_native || !me.language_target) { + onNeedsProfile(me.id, access_token) + } + } catch (err) { + setError(err.message) + } finally { + setLoading(false) + } + } + + return ( +
+ + + setEmail(e.target.value)} autoComplete="email" autoFocus /> + + + setPw(e.target.value)} autoComplete="current-password" /> + + + + ) +} diff --git a/src/components/auth/RegisterStep1.jsx b/src/components/auth/RegisterStep1.jsx new file mode 100644 index 0000000..bd2c02b --- /dev/null +++ b/src/components/auth/RegisterStep1.jsx @@ -0,0 +1,48 @@ +import { useState } from 'react' +import { registerUser, login, getMe } from '../../api/directus' +import { useAuth } from '../../context/AuthContext' +import { FormGroup, Input, Button, Alert, StepDots } from './ui' + +export default function RegisterStep1({ onSuccess }) { + const { saveToken } = useAuth() + const [email, setEmail] = useState('') + const [pw, setPw] = useState('') + const [error, setError] = useState('') + const [loading, setLoading] = useState(false) + + const handleSubmit = async (e) => { + e?.preventDefault() + if (!email || !pw) { setError('Bitte alle Felder ausfüllen.'); return } + if (pw.length < 8) { setError('Passwort muss mindestens 8 Zeichen haben.'); return } + setError(''); setLoading(true) + try { + await registerUser(email, pw) + const { access_token } = await login(email, pw) + saveToken(access_token) + const me = await getMe(access_token) + onSuccess(me.id, access_token) + } catch (err) { + setError(err.message) + } finally { + setLoading(false) + } + } + + return ( +
+ + + + setEmail(e.target.value)} autoComplete="email" autoFocus /> + + + setPw(e.target.value)} autoComplete="new-password" /> + + + + ) +} diff --git a/src/components/auth/RegisterStep2.jsx b/src/components/auth/RegisterStep2.jsx new file mode 100644 index 0000000..60acd29 --- /dev/null +++ b/src/components/auth/RegisterStep2.jsx @@ -0,0 +1,85 @@ +import { useState, useEffect } from 'react' +import { checkUsername, createProfile, getLanguageOptions } from '../../api/directus' +import { useAuth } from '../../context/AuthContext' +import { FormGroup, Input, Select, Button, Alert, StepDots } from './ui' + +export default function RegisterStep2({ userId, userToken, onSuccess }) { + const { setUser } = useAuth() + const [username, setUsername] = useState('') + const [nativeLang, setNativeLang] = useState('') + const [targetLang, setTargetLang] = useState('') + const [languages, setLanguages] = useState([]) + const [error, setError] = useState('') + const [loading, setLoading] = useState(false) + + useEffect(() => { + getLanguageOptions() + .then(setLanguages) + .catch(() => setError('Sprachen konnten nicht geladen werden.')) + }, []) + + const handleSubmit = async (e) => { + e?.preventDefault() + if (!username || !nativeLang || !targetLang) { + setError('Bitte alle Felder ausfüllen.'); return + } + if (nativeLang === targetLang) { + setError('Muttersprache und Zielsprache dürfen nicht gleich sein.'); return + } + if (!/^[a-zA-Z0-9_]{3,20}$/.test(username)) { + setError('Username: 3–20 Zeichen, nur Buchstaben, Zahlen und _'); return + } + setError(''); setLoading(true) + try { + const available = await checkUsername(username, userToken) + if (!available) { + setError('Dieser Username ist bereits vergeben.'); setLoading(false); return + } + await createProfile({ userId, username, nativeLang, targetLang, userToken }) + setUser({ id: userId, username: userId, language_native: nativeLang, language_target: targetLang }) + onSuccess(username) + } catch (err) { + setError(err.message) + } finally { + setLoading(false) + } + } + + return ( +
+ + + + setUsername(e.target.value)} + autoFocus + autoComplete="username" + /> + + + + + + + + + + ) +} diff --git a/src/components/auth/auth.module.css b/src/components/auth/auth.module.css new file mode 100644 index 0000000..8c75324 --- /dev/null +++ b/src/components/auth/auth.module.css @@ -0,0 +1,79 @@ +.formGroup { margin-bottom: 16px; } + +.label { + display: block; + font-size: 11px; + font-weight: 500; + color: var(--muted); + letter-spacing: 0.06em; + text-transform: uppercase; + margin-bottom: 6px; +} + +.input { + width: 100%; + padding: 12px 14px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--bg); + font-family: 'DM Sans', sans-serif; + font-size: 15px; + color: var(--text); + outline: none; + transition: border-color 0.2s, box-shadow 0.2s; + appearance: none; + -webkit-appearance: none; +} +.input:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(92,122,94,0.12); + background: var(--surface); +} + +.selectWrap { position: relative; } +.selectArrow { + position: absolute; right: 14px; top: 50%; + transform: translateY(-50%); + width: 0; height: 0; + border-left: 5px solid transparent; + border-right: 5px solid transparent; + border-top: 6px solid var(--muted); + pointer-events: none; +} +.selectWrap .input { padding-right: 36px; cursor: pointer; } + +.btn { + width: 100%; padding: 13px; margin-top: 8px; + background: var(--accent); color: #fff; + border: none; border-radius: var(--radius); + font-family: 'DM Sans', sans-serif; + font-size: 15px; font-weight: 500; cursor: pointer; + display: flex; align-items: center; justify-content: center; gap: 8px; + transition: background 0.2s, transform 0.1s; +} +.btn:hover:not(:disabled) { background: #4a6650; } +.btn:active:not(:disabled) { transform: scale(0.98); } +.btn:disabled { background: var(--border); color: var(--muted); cursor: not-allowed; } + +.spinner { + width: 14px; height: 14px; + border: 2px solid rgba(255,255,255,0.35); + border-top-color: #fff; + border-radius: 50%; + animation: spin 0.7s linear infinite; +} + +.alert { + background: var(--danger-lt); + border: 1px solid #EBCBC8; + border-radius: var(--radius); + padding: 10px 14px; + font-size: 13px; color: var(--danger); + margin-bottom: 16px; +} + +.stepDots { display: flex; align-items: center; gap: 6px; margin-bottom: 24px; } +.stepDot { height: 6px; border-radius: 3px; transition: all 0.25s ease; } +.stepLabel { font-size: 11px; color: var(--muted); margin-left: 4px; } + +@keyframes spin { to { transform: rotate(360deg); } } diff --git a/src/components/auth/ui.jsx b/src/components/auth/ui.jsx new file mode 100644 index 0000000..261258e --- /dev/null +++ b/src/components/auth/ui.jsx @@ -0,0 +1,51 @@ +import styles from './auth.module.css' + +export function FormGroup({ label, children }) { + return ( +
+ {label && } + {children} +
+ ) +} + +export function Input(props) { + return +} + +export function Select({ children, ...props }) { + return ( +
+ +
+
+ ) +} + +export function Button({ loading, children, ...props }) { + return ( + + ) +} + +export function Alert({ message }) { + if (!message) return null + return
{message}
+} + +export function StepDots({ current, total }) { + return ( +
+ {Array.from({ length: total }).map((_, i) => ( +
+ ))} + Schritt {current + 1} von {total} +
+ ) +} diff --git a/src/context/AuthContext.jsx b/src/context/AuthContext.jsx new file mode 100644 index 0000000..f7dab78 --- /dev/null +++ b/src/context/AuthContext.jsx @@ -0,0 +1,29 @@ +import { createContext, useContext, useState, useEffect } from 'react' +import { getMe } from '../api/directus' + +const AuthContext = createContext(null) + +export function AuthProvider({ children }) { + const [token, setToken] = useState(() => localStorage.getItem('hejyou_token')) + const [user, setUser] = useState(null) + const [loading, setLoading] = useState(true) + + useEffect(() => { + if (!token) { setLoading(false); return } + getMe(token) + .then(setUser) + .catch(() => { localStorage.removeItem('hejyou_token'); setToken(null) }) + .finally(() => setLoading(false)) + }, [token]) + + const saveToken = (t) => { localStorage.setItem('hejyou_token', t); setToken(t) } + const logout = () => { localStorage.removeItem('hejyou_token'); setToken(null); setUser(null) } + + return ( + + {children} + + ) +} + +export const useAuth = () => useContext(AuthContext) diff --git a/src/index.css b/src/index.css new file mode 100644 index 0000000..92c5585 --- /dev/null +++ b/src/index.css @@ -0,0 +1,22 @@ +@import url('https://fonts.googleapis.com/css2?family=Lora:wght@700&family=Nunito:wght@400;500;600;700;800&display=swap'); + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: 'Nunito', -apple-system, BlinkMacSystemFont, sans-serif; + background: #EDE0CE; + color: #4A3728; + height: 100dvh; + overflow: hidden; + line-height: 1.7; +} + +#root { + height: 100dvh; + display: flex; + flex-direction: column; +} diff --git a/src/main.jsx b/src/main.jsx new file mode 100644 index 0000000..54b39dd --- /dev/null +++ b/src/main.jsx @@ -0,0 +1,10 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import App from './App.jsx' +import './index.css' + +ReactDOM.createRoot(document.getElementById('root')).render( + + + , +) diff --git a/src/pages/Feed.css b/src/pages/Feed.css new file mode 100644 index 0000000..6548892 --- /dev/null +++ b/src/pages/Feed.css @@ -0,0 +1,17 @@ +.feed { + height: 100%; + background: #EDE0CE; + overflow-y: auto; + scroll-snap-type: y mandatory; + -webkit-overflow-scrolling: touch; +} + +.feed-slot { + scroll-snap-align: start; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + padding: 24px 20px; + flex-shrink: 0; +} diff --git a/src/pages/Feed.jsx b/src/pages/Feed.jsx new file mode 100644 index 0000000..3e38053 --- /dev/null +++ b/src/pages/Feed.jsx @@ -0,0 +1,324 @@ +import { useEffect, useRef, useState } from 'react' +import './Feed.css' +import { useAuth } from '../context/AuthContext' +import { + getActiveLearningPair, getWords, getQuestions, getUserProgress, + getLanguageOptions, langById, + saveProgress, addPointsToPair, + getQAPairsAtLevel, assetUrl, +} from '../api/directus' +import NewWordTextCard from '../components/NewWordTextCard' +import NewWordVoiceCard from '../components/NewWordVoiceCard' +import LetterOrderCard from '../components/LetterOrderCard' +import SentenceFillCard from '../components/SentenceFillCard' +import LanguageParentCard from '../components/LanguageParentCard' + +// Ein Wort gilt als gemeistert, wenn es in der aktiven Sprachrichtung +// mindestens MASTERY_THRESHOLD korrekt beantwortete Kacheln gesammelt hat. +const MASTERY_THRESHOLD = 3 +// Wie viele verschiedene Wörter gleichzeitig im Feed erscheinen +const FEED_WORD_BUDGET = 6 + +// Punkteformel: selbes/niedrigeres Level = 1 Punkt, jeder Level höher = +1 Punkt +function computePoints(cardLevel, userLevel) { + return Math.max(1, 1 + ((cardLevel || 1) - (userLevel || 1))) +} + +function shuffle(arr) { + const a = [...arr] + for (let i = a.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)) + ;[a[i], a[j]] = [a[j], a[i]] + } + return a +} + +const pickN = (arr, n) => shuffle(arr).slice(0, Math.max(0, n)) + +function pickWordsToLearn(unmastered, userLevel, budget) { + if (unmastered.length === 0) return [] + const same = unmastered.filter(w => (w.level || 1) === userLevel) + const higher = unmastered.filter(w => (w.level || 1) > userLevel && (w.level || 1) <= userLevel + 2) + const farHigher = unmastered.filter(w => (w.level || 1) > userLevel + 2) + const lower = unmastered.filter(w => (w.level || 1) < userLevel) + + if (same.length > 0) { + const sameN = Math.ceil(budget * 0.8) + const higherN = budget - sameN + let result = [ + ...pickN(same, Math.min(sameN, same.length)), + ...pickN(higher, Math.min(higherN, higher.length)), + ] + if (result.length < budget) { + const used = new Set(result.map(w => w.id)) + const rest = unmastered.filter(w => !used.has(w.id)) + result = [...result, ...pickN(rest, budget - result.length)] + } + return shuffle(result) + } + if (higher.length > 0) return pickN(higher, budget) + if (farHigher.length > 0) return pickN(farHigher, budget) + return pickN(lower, budget) +} + +function buildWordCards(words, userLevel, fromLang, toLang) { + const cards = [] + words.forEach(w => { + const word = w[`title_${toLang.suffix}`] + const translation = w[`title_${fromLang.suffix}`] + if (!word || !translation) return + const level = w.level || 1 + const points = computePoints(level, userLevel) + + const base = { language: toLang.label, points, word, translation, level } + + cards.push({ + type: 'text', + meta: { wordId: w.id, cardType: 'write', points, level }, + card: { ...base, baseForm: word, prompt: `Schreib das Wort auf ${toLang.label}` }, + }) + cards.push({ + type: 'voice', + meta: { wordId: w.id, cardType: 'speak', points, level }, + card: { ...base, baseForm: word, prompt: `Sprich das Wort auf ${toLang.label}`, speechLang: toLang.speech }, + }) + if (word.length >= 4) { + cards.push({ + type: 'letter', + meta: { wordId: w.id, cardType: 'write', points, level }, + card: { ...base, prompt: 'Tippe die Buchstaben in der richtigen Reihenfolge' }, + }) + } + }) + return cards +} + +function buildLanguageParentCards(qaPairs, userLevel, toLang, token) { + return qaPairs.map(qp => { + const points = computePoints(qp.level, userLevel) + return { + type: 'languparent', + meta: { pairId: qp.pairId, cardType: 'speak', points, level: qp.level }, + card: { + language: toLang.label, + points, + level: qp.level, + statement: qp.statement, + imageUrl: assetUrl(qp.pictureFileId, token), + primaryWord: qp.primaryWord, + speechLang: toLang.speech, + selections: qp.selections, + }, + } + }) +} + +function buildQuestionCardsFor(questions, masteredWordIds, userLevel, toLang) { + const cards = [] + questions.forEach(q => { + const wordIds = (q.related_words || []).map(rw => rw.words_id).filter(Boolean) + if (wordIds.length === 0) return + if (!wordIds.every(id => masteredWordIds.has(id))) return + + const qText = q[`question_${toLang.suffix}`] + const answer = q[`answer_${toLang.suffix}`] + if (!qText || !answer) return + + const level = q.level || 1 + const points = computePoints(level, userLevel) + + cards.push({ + type: 'sentence', + meta: { questionId: q.id, wordIds, cardType: 'sentence_fill', points, level }, + card: { + language: toLang.label, points, level, + word: answer, + translation: qText, + prompt: 'Antworte auf die Frage', + }, + }) + }) + return cards +} + +export default function Feed() { + const { user, token } = useAuth() + const [cards, setCards] = useState([]) + const [loading, setLoading] = useState(true) + const [ctx, setCtx] = useState(null) + const [runningPoints, setRunningPoints] = useState(0) + const [empty, setEmpty] = useState(false) + + // Laufende Mastery-Verwaltung für diese Session + const correctsRef = useRef({}) // wordId -> Anzahl korrekter Antworten (persistiert + session) + const masteredRef = useRef(new Set()) + const questionsRef = useRef([]) + const appendedQRef = useRef(new Set()) // bereits angehängte questionIds + const userLevelRef = useRef(1) + const toLangRef = useRef(null) + const pointsQueueRef = useRef(Promise.resolve()) // serialisiert Punkte-Updates + + useEffect(() => { + async function load() { + try { + const [pair, langs] = await Promise.all([ + getActiveLearningPair(user.username, token), + getLanguageOptions(), + ]) + if (!pair) { setEmpty(true); setLoading(false); return } + + const fromLang = langById(pair.language_from, langs) + const toLang = langById(pair.language_to, langs) + if (!fromLang || !toLang) { setEmpty(true); setLoading(false); return } + + const userLevel = pair.current_level || 1 + + const [words, questions, progress, qaPairs] = await Promise.all([ + getWords(token), + getQuestions(token), + getUserProgress(user.username, token, pair.language_to), + getQAPairsAtLevel(userLevel, token, toLang.suffix), + ]) + + const correctsByWord = {} + progress.forEach(p => { + if (p.result === 'correct' && p.word) { + correctsByWord[p.word] = (correctsByWord[p.word] || 0) + 1 + } + }) + const mastered = new Set( + Object.entries(correctsByWord) + .filter(([, c]) => c >= MASTERY_THRESHOLD) + .map(([id]) => id) + ) + + correctsRef.current = correctsByWord + masteredRef.current = mastered + questionsRef.current = questions + userLevelRef.current = userLevel + toLangRef.current = toLang + + const unmastered = words.filter(w => !mastered.has(w.id)) + const chosen = pickWordsToLearn(unmastered, userLevel, FEED_WORD_BUDGET) + + const wordCards = buildWordCards(chosen, userLevel, fromLang, toLang) + const questionCards = buildQuestionCardsFor(questions, mastered, userLevel, toLang) + const lpCards = buildLanguageParentCards(qaPairs, userLevel, toLang, token) + + // bereits angehängte Frage-IDs merken, damit wir sie nicht doppelt einstreuen + questionCards.forEach(c => appendedQRef.current.add(c.meta.questionId)) + + const allCards = [...lpCards, ...wordCards, ...questionCards] + setCards(allCards) + setEmpty(allCards.length === 0) + setCtx({ + pair, + fromLangId: pair.language_from, + toLangId: pair.language_to, + profileId: user.username, + }) + setRunningPoints(pair.points || 0) + } catch (err) { + console.error('Feed load error', err) + setEmpty(true) + } finally { + setLoading(false) + } + } + load() + }, [user.username, token]) + + async function handleComplete(item, result) { + if (!ctx) return + const earned = result === 'correct' ? (item.meta.points || 1) : 0 + + saveProgress({ + user: ctx.profileId, + word: item.meta.wordId || null, + question: item.meta.questionId || null, + card_type: item.meta.cardType, + result, + points_earned: earned, + language_from: ctx.fromLangId, + language_to: ctx.toLangId, + }, token).catch(() => {}) + + // Punkte serialisiert patchen, damit parallele Karten nicht denselben Basiswert überschreiben + if (earned > 0) { + setRunningPoints(p => p + earned) + pointsQueueRef.current = pointsQueueRef.current.then(async () => { + ctx.pair.points = (ctx.pair.points || 0) + earned + try { await addPointsToPair(ctx.pair.id, ctx.pair.points, token) } catch {} + }) + } + + // In-Session-Mastery: korrekte Wort-Antwort erhöht Zähler; neu gemasterte + // Wörter können Frage-Kacheln freischalten. + if (result === 'correct' && item.meta.wordId) { + const wid = item.meta.wordId + const newCount = (correctsRef.current[wid] || 0) + 1 + correctsRef.current[wid] = newCount + + if (!masteredRef.current.has(wid) && newCount >= MASTERY_THRESHOLD) { + masteredRef.current.add(wid) + + const newQuestions = questionsRef.current.filter(q => { + if (appendedQRef.current.has(q.id)) return false + const wordIds = (q.related_words || []).map(rw => rw.words_id).filter(Boolean) + if (wordIds.length === 0) return false + return wordIds.every(id => masteredRef.current.has(id)) + }) + + if (newQuestions.length > 0) { + const extra = buildQuestionCardsFor( + newQuestions, + masteredRef.current, + userLevelRef.current, + toLangRef.current, + ) + extra.forEach(c => appendedQRef.current.add(c.meta.questionId)) + setCards(prev => [...prev, ...extra]) + setEmpty(false) + } + } + } + } + + if (loading) { + return ( +
+
+ Lade Karten… +
+
+ ) + } + + if (empty) { + return ( +
+
+ Super! Du hast alle Wörter deines Levels gemeistert. Neue Wörter kommen bald. +
+
+ ) + } + + return ( +
+ {cards.map((item, i) => { + const enrichedCard = { ...item.card, totalPoints: runningPoints } + const handler = (r) => handleComplete(item, r) + return ( +
+ {item.type === 'text' && } + {item.type === 'voice' && } + {item.type === 'letter' && } + {item.type === 'sentence' && } + {item.type === 'languparent' && } +
+ ) + })} +
+ ) +} diff --git a/src/pages/Game.jsx b/src/pages/Game.jsx new file mode 100644 index 0000000..fe8b55e --- /dev/null +++ b/src/pages/Game.jsx @@ -0,0 +1,7 @@ +export default function Game() { + return ( +
+

Dieser Bereich wird später kommen.

+
+ ) +} diff --git a/src/pages/Pro.jsx b/src/pages/Pro.jsx new file mode 100644 index 0000000..c92dcaf --- /dev/null +++ b/src/pages/Pro.jsx @@ -0,0 +1,7 @@ +export default function Pro() { + return ( +
+

Dieser Bereich wird später kommen.

+
+ ) +} diff --git a/src/pages/Profil.css b/src/pages/Profil.css new file mode 100644 index 0000000..12521d5 --- /dev/null +++ b/src/pages/Profil.css @@ -0,0 +1,194 @@ +/* ── Layout ────────────────────────────────────────────────── */ +.profil { + min-height: 100%; + background: #EDE0CE; + padding: 0 16px 32px; + overflow-y: auto; +} + +/* ── Header ────────────────────────────────────────────────── */ +.profil-header { + display: flex; + align-items: center; + gap: 14px; + padding: 20px 4px 16px; +} + +.avatar-wrap { + position: relative; + flex-shrink: 0; +} + +/* Animated ring */ +.avatar-ring { + width: 64px; + height: 64px; + border-radius: 50%; + background: conic-gradient(from 0deg, #EDE0CE, #C4A882, #7A5C3A, #D4B896, #EDE0CE); + animation: spin-ring 4s linear infinite; + padding: 2px; +} + +@keyframes spin-ring { + to { transform: rotate(360deg); } +} + +.avatar-inner { + width: 100%; + height: 100%; + border-radius: 50%; + background: #EDE0CE; + padding: 2px; + display: flex; + align-items: center; + justify-content: center; +} + +.avatar { + width: 100%; + height: 100%; + border-radius: 50%; + background: linear-gradient(135deg, #7A5C3A, #4A3728); + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; + font-weight: 800; + color: #F5EFE6; + letter-spacing: 1px; +} + +/* Online dot */ +.online-dot { + position: absolute; + bottom: 2px; + right: 2px; + width: 11px; + height: 11px; + background: #7A5C3A; + border-radius: 50%; + border: 2px solid #EDE0CE; + z-index: 2; +} + +.online-dot::after { + content: ''; + position: absolute; + inset: -4px; + border-radius: 50%; + border: 2px solid #7A5C3A; + animation: pulse-ring 1.8s ease-out infinite; +} + +@keyframes pulse-ring { + 0% { opacity: 0.7; transform: scale(0.8); } + 100% { opacity: 0; transform: scale(1.8); } +} + +/* Level badge on avatar */ +.avatar-level-badge { + position: absolute; + top: -4px; + right: -6px; + z-index: 3; + filter: drop-shadow(0 1px 3px rgba(74, 55, 40, 0.3)); +} + +.profil-info { + display: flex; + flex-direction: column; + gap: 2px; +} + +.profil-name { + font-family: 'Lora', Georgia, serif; + font-size: 18px; + font-weight: 700; + color: #4A3728; +} + +.profil-handle { + font-size: 12px; + color: #7A5C3A; +} + +/* ── Cards ──────────────────────────────────────────────────── */ +.progress-card, +.skills-card { + background: #F5EFE6; + border: 0.5px solid #D4B896; + border-radius: 16px; + padding: 16px; + margin-bottom: 12px; +} + +.card-title { + font-size: 11px; + font-weight: 500; + color: #8C7A65; + letter-spacing: 0.07em; + margin-bottom: 12px; +} + +/* ── XP Section ─────────────────────────────────────────────── */ +.xp-row { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; +} + +.lang-label { + font-size: 13px; + font-weight: 500; + color: #4A3728; +} + +.xp-value { + font-size: 13px; + font-weight: 500; + color: #7A5C3A; +} + +.xp-bar { + background: #D4B896; + border-radius: 99px; + height: 8px; + width: 100%; + margin-bottom: 8px; + overflow: hidden; +} + +.xp-fill { + height: 100%; + border-radius: 99px; + background: #7A5C3A; +} + +/* Level row */ +.level-row { + display: flex; + justify-content: space-between; + align-items: center; +} + +.level-pill { + background: #7A5C3A; + color: #EDE0CE; + font-size: 11px; + font-weight: 500; + padding: 3px 10px; + border-radius: 99px; +} + +.level-hint { + font-size: 11px; + color: #8C7A65; +} + +/* ── Radar ───────────────────────────────────────────────────── */ +.radar-wrap { + display: flex; + justify-content: center; + padding: 8px 0 4px; +} diff --git a/src/pages/Profil.jsx b/src/pages/Profil.jsx new file mode 100644 index 0000000..800a650 --- /dev/null +++ b/src/pages/Profil.jsx @@ -0,0 +1,195 @@ +import { useEffect, useState } from 'react' +import './Profil.css' +import { useAuth } from '../context/AuthContext' +import { getProfilData, getActiveLearningPair, getLanguageOptions, langById } from '../api/directus' + +const SKILLS = [ + { label: 'Vokabular', value: 0.78 }, + { label: 'Grammatik', value: 0.65 }, + { label: 'Sprechen', value: 0.60 }, + { label: 'Hören', value: 0.52 }, + { label: 'Lesen', value: 0.62 }, +] + +/* ── Radar Chart ─────────────────────────────────────────────── */ +function RadarChart({ skills, animate }) { + const size = 220 + const cx = 110 + const cy = 105 + const r = 70 + const n = skills.length + + const angle = (i) => (Math.PI * 2 * i) / n - Math.PI / 2 + + const point = (i, ratio) => ({ + x: cx + r * ratio * Math.cos(angle(i)), + y: cy + r * ratio * Math.sin(angle(i)), + }) + + const gridPoly = (ratio) => + skills.map((_, i) => `${point(i, ratio).x},${point(i, ratio).y}`).join(' ') + + const dataPoly = skills + .map((s, i) => `${point(i, animate ? s.value : 0).x},${point(i, animate ? s.value : 0).y}`) + .join(' ') + + const labelAnchor = (i) => { + const x = Math.cos(angle(i)) + if (x > 0.1) return 'start' + if (x < -0.1) return 'end' + return 'middle' + } + + const labelOffset = (i) => { + const y = Math.sin(angle(i)) + return y > 0.1 ? 10 : y < -0.1 ? -4 : 4 + } + + return ( + + {[1, 0.8, 0.6, 0.4].map((lvl, idx) => ( + + ))} + {skills.map((_, i) => { + const p = point(i, 1) + return + })} + + {skills.map((s, i) => { + const p = point(i, animate ? s.value : 0) + return + })} + {skills.map((s, i) => { + const p = point(i, 1.28) + return ( + + {s.label} + + ) + })} + + ) +} + +/* ── Main Component ──────────────────────────────────────────── */ +export default function Profil() { + const { user, token } = useAuth() + const [radarReady, setRadarReady] = useState(false) + const [profil, setProfil] = useState(null) + const [pair, setPair] = useState(null) + const [langs, setLangs] = useState([]) + + useEffect(() => { + const t = setTimeout(() => setRadarReady(true), 120) + return () => clearTimeout(t) + }, []) + + useEffect(() => { + async function load() { + try { + const [p, lp, langs] = await Promise.all([ + getProfilData(token), + getActiveLearningPair(user.username, token), + getLanguageOptions(), + ]) + setProfil(p) + setPair(lp) + setLangs(langs) + } catch { + // Profildaten nicht ladbar – zeige Fallback + } + } + load() + }, [token, user.username]) + + const displayName = profil?.username?.username_public || user?.username || '…' + const initials = displayName.slice(0, 2).toUpperCase() + const points = pair?.points ?? profil?.points_total ?? 0 + const level = pair?.current_level ?? 1 + const xpMax = level * 500 + const xpPct = Math.min((points / xpMax) * 100, 100) + const toLang = pair ? langById(pair.language_to, langs) : null + const langLabel = toLang ? `${toLang.flag} ${toLang.label}` : 'Zielsprache' + const streak = profil?.streak_days ?? 0 + + return ( +
+ {/* ── Header ── */} +
+
+
+
+
{initials}
+
+
+ +
+ + + + + + + + + + + {level} + + +
+
+ +
+

{displayName}

+

@{displayName.toLowerCase()}

+ {streak > 0 && ( +

+ 🔥 {streak} Tag{streak !== 1 ? 'e' : ''} Streak +

+ )} +
+
+ + {/* ── Progress Card ── */} +
+

DEIN FORTSCHRITT

+ +
+ {langLabel} + {points.toLocaleString('de')} / {xpMax.toLocaleString('de')} XP +
+ +
+
+
+ +
+ Level {level} + {(xpMax - points).toLocaleString('de')} XP bis Level {level + 1} +
+
+ + {/* ── Skills Card ── */} +
+

FÄHIGKEITEN

+
+ +
+
+
+ ) +} diff --git a/src/utils/confetti.js b/src/utils/confetti.js new file mode 100644 index 0000000..101d430 --- /dev/null +++ b/src/utils/confetti.js @@ -0,0 +1,10 @@ +import confetti from 'canvas-confetti' + +export function triggerConfetti() { + confetti({ + particleCount: 120, + spread: 70, + origin: { y: 0.55 }, + colors: ['#7A5C3A', '#C4A882', '#EDE0CE', '#D4B896', '#F5EFE6'], + }) +} diff --git a/vite.config.js b/vite.config.js new file mode 100644 index 0000000..9ffcc67 --- /dev/null +++ b/vite.config.js @@ -0,0 +1,6 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], +})