diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index 8c7ce61..d8ebf85 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -60,6 +60,24 @@ export default [ } }, + { + // Vitest runs with `globals: true`, so the test APIs are ambient. + files: [ 'test/vitest/**/*.js' ], + languageOptions: { + globals: { + describe: 'readonly', + it: 'readonly', + test: 'readonly', + expect: 'readonly', + vi: 'readonly', + beforeEach: 'readonly', + afterEach: 'readonly', + beforeAll: 'readonly', + afterAll: 'readonly' + } + } + }, + { files: [ 'src-pwa/custom-service-worker.js' ], languageOptions: { diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 51ca807..cd61662 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -13,6 +13,7 @@ "dexie": "^4.3.0", "pinia": "^3.0.1", "quasar": "^2.16.0", + "register-service-worker": "^1.7.2", "three": "^0.183.0", "vue": "^3.4.18", "vue-router": "^4.0.0", @@ -21,14 +22,26 @@ "devDependencies": { "@eslint/js": "^9.14.0", "@quasar/app-vite": "^2.1.0", + "@vitejs/plugin-vue": "^6.0.9", "@vue/eslint-config-prettier": "^10.1.0", + "@vue/test-utils": "^2.5.1", "autoprefixer": "^10.4.2", "eslint": "^9.14.0", "eslint-plugin-vue": "^9.30.0", + "fake-indexeddb": "^6.2.5", "globals": "^15.12.0", + "happy-dom": "^20.14.5", "postcss": "^8.4.14", "prettier": "^3.3.3", - "vite-plugin-checker": "^0.9.0" + "vite-plugin-checker": "^0.9.0", + "vitest": "^3.2.7", + "workbox-build": "^7.4.1", + "workbox-cacheable-response": "^7.4.1", + "workbox-core": "^7.4.1", + "workbox-expiration": "^7.4.1", + "workbox-precaching": "^7.4.1", + "workbox-routing": "^7.4.1", + "workbox-strategies": "^7.4.1" }, "engines": { "node": "^28 || ^26 || ^24 || ^22 || ^20 || ^18", @@ -42,13 +55,13 @@ } }, "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==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", + "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -56,31 +69,444 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@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/core/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/@babel/core/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/@babel/core/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/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@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-annotate-as-pure": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.29.7.tgz", + "integrity": "sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/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/@babel/helper-compilation-targets/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/@babel/helper-create-class-features-plugin": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.29.7.tgz", + "integrity": "sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-member-expression-to-functions": "^7.29.7", + "@babel/helper-optimise-call-expression": "^7.29.7", + "@babel/helper-replace-supers": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", + "@babel/traverse": "^7.29.7", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-create-class-features-plugin/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/@babel/helper-create-regexp-features-plugin": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.29.7.tgz", + "integrity": "sha512-907Uymvqgg1dwUA+7IGwFAOSYzQOuzPXKNJ1yxzwPffzkYFg2q2eHi1fIOs6sXkG9NbIUMunnUlkYsfRFNvomg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "regexpu-core": "^6.3.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-create-regexp-features-plugin/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/@babel/helper-define-polyfill-provider": { + "version": "0.6.8", + "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.8.tgz", + "integrity": "sha512-47UwBLPpQi1NoWzLuHNjRoHlYXMwIJoBf7MFou6viC/sIHWYygpvr0B6IAyh5sBdA2nr2LPIRww8lfaUVQINBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "debug": "^4.4.3", + "lodash.debounce": "^4.0.8", + "resolve": "^1.22.11" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/@babel/helper-define-polyfill-provider/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/@babel/helper-define-polyfill-provider/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/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-member-expression-to-functions": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.29.7.tgz", + "integrity": "sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-optimise-call-expression": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.29.7.tgz", + "integrity": "sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-remap-async-to-generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.29.7.tgz", + "integrity": "sha512-16AMiW26DbXWBbr3B8wNozKM0ydMLB892vaOaJW/fPJdnT8vJk5sdkQcU/isqUxyCE0cEoa8wZOcbgDuC4b6Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-wrap-function": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-replace-supers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.29.7.tgz", + "integrity": "sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-member-expression-to-functions": "^7.29.7", + "@babel/helper-optimise-call-expression": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-skip-transparent-expression-wrappers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.29.7.tgz", + "integrity": "sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "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==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "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==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, - "node_modules/@babel/parser": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", - "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-wrap-function": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.29.7.tgz", + "integrity": "sha512-iES0Skag9ERIF68aXadpO6dbXa03mNWK3sEqJaMnLNs/eC3l0lkImdfoy6Y09/SfkpawdAB4RjQ7PVA7TcVGdw==", + "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" }, "bin": { "parser": "bin/babel-parser.js" @@ -89,14 +515,1213 @@ "node": ">=6.0.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==", + "node_modules/@babel/plugin-bugfix-firefox-class-in-computed-class-key": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.29.7.tgz", + "integrity": "sha512-j8SrR0zLZrRsC09DlszEx8FpMiwukKffYXMK0d5LmOglO7vGG6sz/BR/20yHqWH+Lnn31JTt2PE3hIWNgM2J6w==", + "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-safari-class-field-initializer-scope": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.29.7.tgz", + "integrity": "sha512-r8j8escF+U2FUHo0KOhPUdMzUO+jp9fInva6+ACVAF3Y97Ev+5iNZwiqTghmzNeWwDkOPlYuTcfb1vDaoZKmAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.29.7.tgz", + "integrity": "sha512-GE1TFSiuFeGsCxmYXZl8HwoPrVlwe4rHPFE8weieGKZqnDORK+Ar3vgWMgW+AOxQ6/2TgLSKx9p6W7O4rC6qgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-safari-rest-destructuring-rhs-array": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-rest-destructuring-rhs-array/-/plugin-bugfix-safari-rest-destructuring-rhs-array-7.29.7.tgz", + "integrity": "sha512-oBNVCvnO5tND+xSopWvV8WNGfpTfgP4Zr/YXXSj8zfmcPktp5Ku/aZlsIowgSD4fjmgHn6sGmB9APVsU5zOdhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.29.7.tgz", + "integrity": "sha512-QQt9qKHZ2sg/kivaLr7lnQr8HVrQDdBNSfCsTjiDxRuX/K5ORyKq+Bu8Xr0cDE3Dfkv0cw28Ve0EKyKMvulkOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", + "@babel/plugin-transform-optional-chaining": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.13.0" + } + }, + "node_modules/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.29.7.tgz", + "integrity": "sha512-pn6QacGLgvCcwc+syUhKE/qSjV2D1IHDB84RNxWYSt1mW3K/SCtjinZ2p0cETJxAWBjPy3K/1lHwG5BjjPxNlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-proposal-private-property-in-object": { + "version": "7.21.0-placeholder-for-preset-env.2", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz", + "integrity": "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-assertions": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.29.7.tgz", + "integrity": "sha512-/An1OCBN93thpBAGyfsK2pcf0jvju1SAtKkL2Ny++B5Sy6sqgzXDQH1cZxWbF96Wuk+bn41MDA9bLd4VVAw6rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.29.7.tgz", + "integrity": "sha512-zGYcYfq/WmZ4V+kBIXQon9dSSc8ircGZqw9ZaNhhGj9nZkeBu1jHLBDQqYYi5WA9uawvA2sIMbry2nCFhf5Djg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-unicode-sets-regex": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz", + "integrity": "sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.18.6", + "@babel/helper-plugin-utils": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-arrow-functions": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.29.7.tgz", + "integrity": "sha512-N7zArUXWzAMzm+/N0uPBeVB3Fam5lMxtUwMmDK5f/IBBS7a7p1qeUoxd/6CckXoxUdgsntq1Dh8xNW06maZbDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-async-generator-functions": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.29.7.tgz", + "integrity": "sha512-d98gXZkgswvkyohMBABkhm3GeXhYj8psWfwQ2C7gtfrKGTykQa/iOIi+JJhwMjPlZ6Vm2XN+DCf3Es1EoG4ZLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-remap-async-to-generator": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-async-to-generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.29.7.tgz", + "integrity": "sha512-pcUb2SS+RMo9TWVBwKGI5ShtoG7R+zBsFmCKDa6fe8c+hPr3XJlZgoE5j6i8W7gDjhyvy+85vmYexanvXh3d1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-remap-async-to-generator": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-block-scoped-functions": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.29.7.tgz", + "integrity": "sha512-cUSmjh72N+rN4PrkFlN1dJwNCwjVp5d38/CQrEsFggkD10UiFlBFgdH3tv5dNsLuHY+3S8db2xCHjhZcv5WgvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-block-scoping": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.29.7.tgz", + "integrity": "sha512-ONyr4+AZhKh8yKWInVxU9AXA9EbsyeLcL6V0dJy6M2/62vuvpGm29zzuymbTpdc451GEpDIdAyPLP3r+P61yKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-class-properties": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.29.7.tgz", + "integrity": "sha512-GtcpjFvanPfzNQi3eTitsCqtRRmmqzpy/A+yhTR1HaZo1Ly3EA8ZXxlPyHdR8/IuRMYc3E4wdGBewB2QKQjAaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-class-static-block": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.29.7.tgz", + "integrity": "sha512-kibJgmEdX2iMwsHY2tSZNDgj8PwIlCQz7FK9KuGKO8zsuoUwSEhoNnNVp/emKWrbY4HeO6kkXfdMqRKKKXBm2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.12.0" + } + }, + "node_modules/@babel/plugin-transform-classes": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.29.7.tgz", + "integrity": "sha512-qV0OGGBVacduzQHE649JyCneOFI/maT+YKsO+K4Yi3xv2wTPNjM/W2o2gdzMwEAZz7fXNTHAe0NcSg30bIN69g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-replace-supers": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-computed-properties": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.29.7.tgz", + "integrity": "sha512-RK7/IyU5phpuCdBAuig5VkzG/EnbDaui5SQGdU9BFrHdV+mV4cUjLMQ9lJDjLNtWHsqtiefpGZUXQP2BiTYMsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/template": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-destructuring": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.29.7.tgz", + "integrity": "sha512-iPX8aD6H9zV5s7ZsqTdNocPN/MGQ5sSMnElKrktxjJRMnB2jN/1p2+R7GkfD6CAYoVFqy5A4XnSIUeGgJzIWpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-dotall-regex": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.29.7.tgz", + "integrity": "sha512-3qc18hsD2RdZiyJNDNc7HQpv6xbncwh8FYtxNFFzclSyh/trPD9KkVR9BDECUjDLvb7yJVF15GfYUuC+LMkkiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-duplicate-keys": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.29.7.tgz", + "integrity": "sha512-6IvRRriEMqnBwD6chtxdLpMYCHWEzN+oL5cyQtjykya19UgzbmKhxmhZgKC/LHxS2nYr9Q/qYPZ5Lr6jOL9+yQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-duplicate-named-capturing-groups-regex": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.29.7.tgz", + "integrity": "sha512-2wiIyo2BjtgU7HufSeDnL9L2O7zr8jmhFKuSr65VpRkUiRKRNpb0mdlk56+XPPKoIrfHqzbMuglDvZun0RISsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-dynamic-import": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.29.7.tgz", + "integrity": "sha512-giOlEm/EFjfjr+te9NsdjkUo2v4f8rS/SXPumRVHAtbNcyNlvtREkU1dZzaIDclNpnaVhlCqRdFKhJBjBikzLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-explicit-resource-management": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-explicit-resource-management/-/plugin-transform-explicit-resource-management-7.29.7.tgz", + "integrity": "sha512-Rstj7coNz8sE+7Ju7ihpHLI564lsK5pUpNNlvptCIC/16E/S5hbl6n3kESPKdNRmqEWlpn5xpS5Q2dvXBsySLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/plugin-transform-destructuring": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-exponentiation-operator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.29.7.tgz", + "integrity": "sha512-zFpMOTLZBdW5LfObqcSbL6kefg4R4eLdmvS0wbN9M6D5Mym/sKm9toOoWyVOa+xDjvCnuWcHls2YonXwHvH3CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-export-namespace-from": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.29.7.tgz", + "integrity": "sha512-24B2nOy2TeJSMheqwPD4DDQOV/elLSIlKxjZt4i05H5AgdPdWR3n18HnNrcJ+j76WJd9gbwb9jPjNYUy6RautA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-for-of": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.29.7.tgz", + "integrity": "sha512-zeSIHh0+E1Um1WJRXCFlHQYu2ieJNdivLLjlBEp+dIBu3S51n+SZZmIXjxnItw6pz56Cn+KvK68BIBVsxq2JiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-function-name": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.29.7.tgz", + "integrity": "sha512-otRWaHXE6fbAGkePvaj/kvs3HsqXfPhlnzwSOlnFgbqCPMd975dW+4wZ00WFBt+/YlBGcJwNrARQTOJOb4ZrIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-json-strings": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.29.7.tgz", + "integrity": "sha512-RRnE2+eon1rJAq8MnoF1b5kTpY1vU88twHcvcKMrsqP/jxIRqDVs9iJB5fqPuqyeFAW0wJo4MlUIPpQCq/aRsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-literals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.29.7.tgz", + "integrity": "sha512-DZ/oLP21ZuWx1vKqnoNv6/tvEK48AQOBRai40CX9dTjGluvT/YZCyY3rryDtyUqCEoyNroy5KKPwX2iQCiRvyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-logical-assignment-operators": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.29.7.tgz", + "integrity": "sha512-A0H91hh6W8MFRkp5TqJmMr39jzGD1A1E1Ysiv2O06Sfbhkapm+XyIzxWCEh5kqwOZ1/8QZ0dY3SeQ7XBqfJd5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-member-expression-literals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.29.7.tgz", + "integrity": "sha512-hl1kwFZCCiDyfH25Xmco9jTrkPgnS9pmOzSG7W5I4SaGbLeqKv417hcU2RKmaxoPEgsoJh7ZPOrnPGq99bHoUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-amd": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.29.7.tgz", + "integrity": "sha512-fxtQoH3m5ywUSIfaH0FGCzWu4McsYon5bD3K4XnskC7f+OyQMj7rsOMi4NvvmJ83WwBAg4UCe+ov4VZlqEvyew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-commonjs": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.29.7.tgz", + "integrity": "sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-systemjs": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.29.8.tgz", + "integrity": "sha512-6iSnEK0zlkLKU4heofK/AdmRD4e2SHVpJMtrwnTCzhnaM98ria4rTrOXBBi45BTTYnJtO8txnPsX4fChYXkmeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.8" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-umd": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.29.7.tgz", + "integrity": "sha512-B4UkaTK3QpgCwJnrxKfMPKdo92CN7OKXAlpAAnM3UPu0Q0lCCk57ylA9AJbRy2v8dDKOPAAWcoR6CMyeoHwRCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-named-capturing-groups-regex": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.29.7.tgz", + "integrity": "sha512-vuFoLwr4qnv2xbZ16SQd6uPcH5FNrLHhk/Jzo++0XJFcaDsr4gjJVg6j398oMHiC+83k/GiBzviwF5KBJkPUtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-new-target": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.29.7.tgz", + "integrity": "sha512-fEo41GmsOUhOBlw8ioo6zvjX5Xc2Lqkzlyfqbpsk3eB6TReV18uhxZ0esfEokVbY2+PVJAQHNKxER6lGrzNd3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-nullish-coalescing-operator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.29.7.tgz", + "integrity": "sha512-idmp1dFaekP9GbcMvG24Kvw2BfhFZjHnNJCkV4WuIY4PskJzwI3f1N5OdgYke38T7rftO6ERulFRn2cFeZwRkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-numeric-separator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.29.7.tgz", + "integrity": "sha512-zR7fv/z14OjgHl4AgRtkDBvBMhIzCxqV/qN/2BCRC7LjFwvuzjYe7gDWxC4Wl/SNsLM6SE1IWvRPYMgSJaUvNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-object-rest-spread": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.29.7.tgz", + "integrity": "sha512-Ld98jn4c0smUywL57m7SgsHq3OpThOa6LqZJif3G6jYOovPleoFhVrBJ1WegRApSFB2wu4+RelAj9AC9G08Z4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/plugin-transform-destructuring": "^7.29.7", + "@babel/plugin-transform-parameters": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-object-super": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.29.7.tgz", + "integrity": "sha512-Ea/diGcw0twB5IlZPO5sgET6fJsLJqPABqTuFWIR+iMPGPZJkATEIWx0wa+aEQ5UY1CBQyP/gkAiLEqn1vBiQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-replace-supers": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-optional-catch-binding": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.29.7.tgz", + "integrity": "sha512-sLsyndxK2VwX6yNUOakMb7Sh553ZTe/vVM1XJ+9Z5aW1ytsc8xOIwmyk05NNjN60vkc5/KqoTH6hB4V41LJhng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-optional-chaining": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.29.7.tgz", + "integrity": "sha512-6GM1dhvK3gNODkXcEcMCOLEDCLSoZ/sBbro2Ax8HURyasQ4NshagQixkRFdh5niI6E4gmA/jYI/4aT7rRos3ZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-parameters": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.29.7.tgz", + "integrity": "sha512-ZDOBqV/qLYJI0YElr8DcENEyARsFQeESqWXH6gZlghYXuPPjvweuDhP4VyEi4BlUBlLRFZVjxoZDMjxhLW766g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-private-methods": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.29.7.tgz", + "integrity": "sha512-/6Rz4DK1ETDEM/bWHsPHcaEe7ZaT1EqSXjtSP/L0DijOYuaUhiRiOKcwpZ8P7zR4xXEHc2ITdiCgBm9Tpyv9ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-private-property-in-object": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.29.7.tgz", + "integrity": "sha512-+BNo06dnrzdNNqCm1X6YUaVv0DKk8Q+JYcoZfOkLhYWNCXzlwTSRq8zGWayT1csjcpNXV9CQTBRRbmTLZac5cA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-property-literals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.29.7.tgz", + "integrity": "sha512-bOMRLQuI0A5ZqHq3OWJ89/rXpJ/NJrbVhXiP4zwPGMs6kpcVsuTUNjwoE30K0Qm3mf48a/TnRYYD6vPNqcg6jA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-regenerator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.29.8.tgz", + "integrity": "sha512-0UpIXPtdDtMXfnV2OJAVMLpj3H/92vmkA6lpSRakmycJvj3VUy6Xs1dM8tXRugupykr5WB+LpiVl0J8LMVg2mg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-regexp-modifiers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-7.29.7.tgz", + "integrity": "sha512-mB5Fs0VWrJ42ZCmc8114v60qetdaUVNkj9PmSZRmanCZM3S9hm0CFRLjRmYIsuXav14l2jvZ+4T8iiCGnhj3nQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-reserved-words": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.29.7.tgz", + "integrity": "sha512-5+YhdpVgmfSmwZyLMftfaiffLRMHjzIRHFHHLdibcSyJm2pasMrKHrO3Ptrt2DRshjvpgjEJJ1zVW14WPq/6QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-shorthand-properties": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.29.7.tgz", + "integrity": "sha512-I+WYbGBAiCn7nA6xBrlgPH+MB7HWb4u8pv5S0Pv7OtwNvIFvCCb24YlttKEeUFVurfBCEaOTnuhlqsb7f0Z5Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-spread": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.29.8.tgz", + "integrity": "sha512-4S9ksMGVWUshvgK0mKfvZky7leuG5/uoFVwMpAomJ8bMoDJiNHRVmc1EglwW/CmGVSqqWpEbXm9FmbRit22qoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-sticky-regex": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.29.7.tgz", + "integrity": "sha512-BCHzNYJGe9l7EpwwDBN/ztlL2NYFFq8hp9ddjtUEM9f2O7S7kKV/lL6Fwo7IF7NSkYhPK2vO+86nIGltA90MsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-template-literals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.29.7.tgz", + "integrity": "sha512-NCSEJ4sLFU2gqAub45HYh4fus2yQ36rr6ei6vpU7NdoJqCpxvEG8E6eJpscGyXP3VHD2Ny+fSXr04k1hoUrFqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-typeof-symbol": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.29.7.tgz", + "integrity": "sha512-223mNGoTkBiTEWFoK+Q6Go3tueMRclO8vxxxxquNCYuNI4jWOofFKJRRDu6SDrB8Sgo1UEGW9T4GAQ8ZyRso1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-escapes": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.29.7.tgz", + "integrity": "sha512-jCfXxSjf94lf4E0hKE0AByxF6F3/pVFqRdUUNkDJhsY0m1ZKjnN6ZYyMeHNpzflxb/0q5b7t3p+BE+SLF1WOtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-property-regex": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.29.7.tgz", + "integrity": "sha512-OgZ+zoAJgZLUCunsTRQ5LAjOywDv5zzZ2/hQ5aMw1pGXyY2rtE8/chXYUmu3AlVHKpm10KEdG9aMwbI/K76ZGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-regex": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.29.7.tgz", + "integrity": "sha512-7D/x/23/d/3VqZ0QA+LGbZMlGwZjztBygSWWWsfTPoQ1oQ6Q1P6Mr3d0kk42XabyUVw+fha3LqdRsFqeKqvCyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-sets-regex": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.29.7.tgz", + "integrity": "sha512-BLOhLht9DOJwIxlmp91wHvkXv1lguuHS3/FwUO8HL1H0u8s4hR1gASVFyilu9iGtcTRYqjTZmlsFFeQletntEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/preset-env": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.29.7.tgz", + "integrity": "sha512-GYzX36n1nsciIb0uyH0GHwxwtNwPQIcpxSeiVLDtG/B7jB5xXgchnmL1f/jCX5o+pwnaDBtO60ONSJhEBJfxYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.29.7", + "@babel/plugin-bugfix-safari-class-field-initializer-scope": "^7.29.7", + "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.29.7", + "@babel/plugin-bugfix-safari-rest-destructuring-rhs-array": "^7.29.7", + "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.29.7", + "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.29.7", + "@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2", + "@babel/plugin-syntax-import-assertions": "^7.29.7", + "@babel/plugin-syntax-import-attributes": "^7.29.7", + "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6", + "@babel/plugin-transform-arrow-functions": "^7.29.7", + "@babel/plugin-transform-async-generator-functions": "^7.29.7", + "@babel/plugin-transform-async-to-generator": "^7.29.7", + "@babel/plugin-transform-block-scoped-functions": "^7.29.7", + "@babel/plugin-transform-block-scoping": "^7.29.7", + "@babel/plugin-transform-class-properties": "^7.29.7", + "@babel/plugin-transform-class-static-block": "^7.29.7", + "@babel/plugin-transform-classes": "^7.29.7", + "@babel/plugin-transform-computed-properties": "^7.29.7", + "@babel/plugin-transform-destructuring": "^7.29.7", + "@babel/plugin-transform-dotall-regex": "^7.29.7", + "@babel/plugin-transform-duplicate-keys": "^7.29.7", + "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.29.7", + "@babel/plugin-transform-dynamic-import": "^7.29.7", + "@babel/plugin-transform-explicit-resource-management": "^7.29.7", + "@babel/plugin-transform-exponentiation-operator": "^7.29.7", + "@babel/plugin-transform-export-namespace-from": "^7.29.7", + "@babel/plugin-transform-for-of": "^7.29.7", + "@babel/plugin-transform-function-name": "^7.29.7", + "@babel/plugin-transform-json-strings": "^7.29.7", + "@babel/plugin-transform-literals": "^7.29.7", + "@babel/plugin-transform-logical-assignment-operators": "^7.29.7", + "@babel/plugin-transform-member-expression-literals": "^7.29.7", + "@babel/plugin-transform-modules-amd": "^7.29.7", + "@babel/plugin-transform-modules-commonjs": "^7.29.7", + "@babel/plugin-transform-modules-systemjs": "^7.29.7", + "@babel/plugin-transform-modules-umd": "^7.29.7", + "@babel/plugin-transform-named-capturing-groups-regex": "^7.29.7", + "@babel/plugin-transform-new-target": "^7.29.7", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.29.7", + "@babel/plugin-transform-numeric-separator": "^7.29.7", + "@babel/plugin-transform-object-rest-spread": "^7.29.7", + "@babel/plugin-transform-object-super": "^7.29.7", + "@babel/plugin-transform-optional-catch-binding": "^7.29.7", + "@babel/plugin-transform-optional-chaining": "^7.29.7", + "@babel/plugin-transform-parameters": "^7.29.7", + "@babel/plugin-transform-private-methods": "^7.29.7", + "@babel/plugin-transform-private-property-in-object": "^7.29.7", + "@babel/plugin-transform-property-literals": "^7.29.7", + "@babel/plugin-transform-regenerator": "^7.29.7", + "@babel/plugin-transform-regexp-modifiers": "^7.29.7", + "@babel/plugin-transform-reserved-words": "^7.29.7", + "@babel/plugin-transform-shorthand-properties": "^7.29.7", + "@babel/plugin-transform-spread": "^7.29.7", + "@babel/plugin-transform-sticky-regex": "^7.29.7", + "@babel/plugin-transform-template-literals": "^7.29.7", + "@babel/plugin-transform-typeof-symbol": "^7.29.7", + "@babel/plugin-transform-unicode-escapes": "^7.29.7", + "@babel/plugin-transform-unicode-property-regex": "^7.29.7", + "@babel/plugin-transform-unicode-regex": "^7.29.7", + "@babel/plugin-transform-unicode-sets-regex": "^7.29.7", + "@babel/preset-modules": "0.1.6-no-external-plugins", + "babel-plugin-polyfill-corejs2": "^0.4.15", + "babel-plugin-polyfill-corejs3": "^0.14.0", + "babel-plugin-polyfill-regenerator": "^0.6.6", + "core-js-compat": "^3.48.0", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/preset-env/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/@babel/preset-modules": { + "version": "0.1.6-no-external-plugins", + "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.6-no-external-plugins.tgz", + "integrity": "sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/types": "^7.4.4", + "esutils": "^2.0.2" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse/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/@babel/traverse/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/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -973,6 +2598,17 @@ "@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", @@ -1011,6 +2647,13 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@one-ini/wasm": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.2.1.tgz", + "integrity": "sha512-TUqERXGNTifZ9y2g3wPxQrw3HpHv/02DsW3D90T9x0hhonrL1ZqpSmNrU2XkoIq0fP1N6gZfVQzy2Fw1ZvGBNg==", + "dev": true, + "license": "MIT" + }, "node_modules/@parcel/watcher": { "version": "2.5.6", "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz", @@ -1513,12 +3156,155 @@ } }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.2", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.2.tgz", - "integrity": "sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", "dev": true, "license": "MIT" }, + "node_modules/@rollup/plugin-babel": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-6.1.0.tgz", + "integrity": "sha512-dFZNuFD2YRcoomP4oYf+DvQNSUA9ih+A3vUqopQx5EdtPGo3WBnQcI/S8pwpz91UsGfL0HsMSOlaMld8HrbubA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.18.6", + "@rollup/pluginutils": "^5.0.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0", + "@types/babel__core": "^7.1.9", + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "@types/babel__core": { + "optional": true + }, + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-node-resolve": { + "version": "16.0.3", + "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-16.0.3.tgz", + "integrity": "sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "@types/resolve": "1.20.2", + "deepmerge": "^4.2.2", + "is-module": "^1.0.0", + "resolve": "^1.22.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^2.78.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-replace": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@rollup/plugin-replace/-/plugin-replace-6.0.3.tgz", + "integrity": "sha512-J4RZarRvQAm5IF0/LwUUg+obsm+xZhYnbMXmXROyoSE1ATJe3oXSb9L5MMppdxP2ylNSjv6zFBwKYjcKMucVfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "magic-string": "^0.30.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-terser": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-terser/-/plugin-terser-1.0.0.tgz", + "integrity": "sha512-FnCxhTBx6bMOYQrar6C8h3scPt8/JwIzw3+AJ2K++6guogH5fYaIFia+zZuhqv0eo1RN7W1Pz630SyvLbDjhtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "serialize-javascript": "^7.0.3", + "smob": "^1.0.0", + "terser": "^5.17.4" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "rollup": "^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-terser/node_modules/serialize-javascript": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.1.2.tgz", + "integrity": "sha512-GL2BWwVa6JydKO6l/ljVgjAZF4QJ3S7dWDWi53s5GZT8PJzD2fNxi67HANQGKAqPrwEpL5ba7gUBGi0Ls/sEoQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@rollup/pluginutils": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.4.0.tgz", + "integrity": "sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", @@ -1866,6 +3652,22 @@ "win32" ] }, + "node_modules/@trickfilm400/rollup-plugin-off-main-thread": { + "version": "3.0.0-pre1", + "resolved": "https://registry.npmjs.org/@trickfilm400/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-3.0.0-pre1.tgz", + "integrity": "sha512-/67zpWDBLV+oYAEL682s1ktXL0HgqX76f6gaVGkGnVZlBbm1zd0v4Bz8MFF2GGhoX9rvfq3KSQHubFHwa6w6/Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "ejs": "^3.1.10", + "json5": "^2.2.3", + "magic-string": "^0.30.21", + "string.prototype.matchall": "^4.0.12" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/@types/body-parser": { "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", @@ -1877,6 +3679,17 @@ "@types/node": "*" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/chrome": { "version": "0.0.262", "resolved": "https://registry.npmjs.org/@types/chrome/-/chrome-0.0.262.tgz", @@ -1916,6 +3729,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -2028,6 +3848,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/resolve": { + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", + "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/send": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", @@ -2061,23 +3888,172 @@ "@types/node": "*" } }, - "node_modules/@vitejs/plugin-vue": { - "version": "6.0.4", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.4.tgz", - "integrity": "sha512-uM5iXipgYIn13UUQCZNdWkYk+sysBeA97d5mHsAoAt1u/wpN3+zxOmsVJWosuzX+IMGRzeYUNytztrYznboIkQ==", + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/whatwg-mimetype": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", + "integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", "dev": true, "license": "MIT", "dependencies": { - "@rolldown/pluginutils": "1.0.0-rc.2" + "@types/node": "*" + } + }, + "node_modules/@vitejs/plugin-vue": { + "version": "6.0.9", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.9.tgz", + "integrity": "sha512-rD/MORlhaZMlXWW0rEn4FB4wMinWC0z/D7Ye160S5+Rs1mC8ZDAdcDp4SjKfUhG5t8q1ktcPVw4xuTkPRlzrSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" }, "engines": { "node": "^20.19.0 || >=22.12.0" }, "peerDependencies": { - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", "vue": "^3.2.25" } }, + "node_modules/@vitest/expect": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", + "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz", + "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz", + "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.7", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz", + "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz", + "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz", + "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@vue/compiler-core": { "version": "3.5.29", "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.29.tgz", @@ -2238,6 +4214,37 @@ "integrity": "sha512-w7SR0A5zyRByL9XUkCfdLs7t9XOHUyJ67qPGQjOou3p6GvBeBW+AVjUUmlxtZ4PIYaRvE+1LmK44O4uajlZwcg==", "license": "MIT" }, + "node_modules/@vue/test-utils": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@vue/test-utils/-/test-utils-2.5.1.tgz", + "integrity": "sha512-V/4a99odJ7hapr0bRt35XxljGk8hefq31gSOnlKTcEq2fUeoj2qb+FcpJYfQu4qfYhW9Ug+O2+CZ3QmZ7gXDzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-beautify": "^2.0.0", + "vue-component-type-helpers": "^3.0.0" + }, + "peerDependencies": { + "@vue/compiler-dom": "3.x", + "@vue/server-renderer": "3.x", + "vue": "3.x" + }, + "peerDependenciesMeta": { + "@vue/server-renderer": { + "optional": true + } + } + }, + "node_modules/abbrev": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-5.0.0.tgz", + "integrity": "sha512-/XrFJgzQQQHpti1raDJC6m4ws6aNktmjBlhk8Fdlk7LwCEuDoieEJJY9OFHjfiFJFFRM2tK+Ky/IsfbbmlMu1w==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, "node_modules/abort-controller": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", @@ -2429,6 +4436,23 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", @@ -2436,6 +4460,38 @@ "dev": true, "license": "MIT" }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/async": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", @@ -2443,6 +4499,26 @@ "dev": true, "license": "MIT" }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/at-least-node": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", + "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 4.0.0" + } + }, "node_modules/autoprefixer": { "version": "10.4.24", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.24.tgz", @@ -2480,6 +4556,22 @@ "postcss": "^8.1.0" } }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/b4a": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.0.tgz", @@ -2495,6 +4587,58 @@ } } }, + "node_modules/babel-plugin-polyfill-corejs2": { + "version": "0.4.17", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.17.tgz", + "integrity": "sha512-aTyf30K/rqAsNwN76zYrdtx8obu0E4KoUME29B1xj+B3WxgvWkp943vYQ+z8Mv3lw9xHXMHpvSPOBxzAkIa94w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-define-polyfill-provider": "^0.6.8", + "semver": "^6.3.1" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-corejs2/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/babel-plugin-polyfill-corejs3": { + "version": "0.14.2", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.14.2.tgz", + "integrity": "sha512-coWpDLJ410R781Npmn/SIBZEsAetR4xVi0SxLMXPaMO4lSf1MwnkGYMtkFxew0Dn8B3/CpbpYxN0JCgg8mn67g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.8", + "core-js-compat": "^3.48.0" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-regenerator": { + "version": "0.6.8", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.8.tgz", + "integrity": "sha512-M762rNHfSF1EV3SLtnCJXFoQbbIIz0OyRwnCmV0KPC7qosSfCO0QLTSuJX3ayAebubhE6oYBAYPrBA5ljowaZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.8" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -2539,9 +4683,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", - "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==", + "version": "2.11.25", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.25.tgz", + "integrity": "sha512-gMmEShwwq7FJqMwvfRwvCl00v4kN+KOfJqXn+f4nrufak5gNHJOksd/60Dvjuz7sI8Y5WiSFBa8FEYr+zoyqCw==", "dev": true, "license": "Apache-2.0", "bin": { @@ -2657,9 +4801,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", - "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "version": "4.29.0", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.0.tgz", + "integrity": "sha512-3GSvyjvDI4Dur1Meg2BekJquu5uF+9R9a1+5M1Mde192eZoXbeXjzgOsgqPS2V8D5wrrip0gR5Hf/GhWQ9ZzaA==", "dev": true, "funding": [ { @@ -2677,11 +4821,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.9.0", - "caniuse-lite": "^1.0.30001759", - "electron-to-chromium": "^1.5.263", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.2.0" + "baseline-browser-mapping": "^2.11.23", + "caniuse-lite": "^1.0.30001810", + "electron-to-chromium": "^1.5.427", + "node-releases": "^2.0.55", + "update-browserslist-db": "^1.3.3" }, "bin": { "browserslist": "cli.js" @@ -2732,6 +4876,19 @@ "dev": true, "license": "MIT" }, + "node_modules/buffer-image-size": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/buffer-image-size/-/buffer-image-size-0.6.4.tgz", + "integrity": "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + }, + "engines": { + "node": ">=4.0" + } + }, "node_modules/bundle-name": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", @@ -2758,6 +4915,35 @@ "node": ">= 0.8" } }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -2811,9 +4997,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001774", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001774.tgz", - "integrity": "sha512-DDdwPGz99nmIEv216hKSgLD+D4ikHQHjBC/seF98N9CPqRX4M5mSxT9eTV6oyisnJcuzxtZy4n17yKKQYmYQOA==", + "version": "1.0.30001812", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001812.tgz", + "integrity": "sha512-qN+QNNBr93TCmFrmte0bBCjSDMuRvt78VlHT99qIGPszm4QsqCX8lnyWUFkHi8B7ZBAPq8SH+UqyXNy/odMdng==", "dev": true, "funding": [ { @@ -2831,6 +5017,23 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -2855,6 +5058,16 @@ "dev": true, "license": "MIT" }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -3040,6 +5253,16 @@ "node": ">=14" } }, + "node_modules/common-tags": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/common-tags/-/common-tags-1.8.2.tgz", + "integrity": "sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/compress-commons": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-6.0.2.tgz", @@ -3103,6 +5326,17 @@ "dev": true, "license": "MIT" }, + "node_modules/config-chain": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz", + "integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ini": "^1.3.4", + "proto-list": "~1.2.1" + } + }, "node_modules/content-disposition": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -3126,6 +5360,13 @@ "node": ">= 0.6" } }, + "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/cookie": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", @@ -3158,6 +5399,23 @@ "url": "https://github.com/sponsors/mesqueeb" } }, + "node_modules/core-js-compat": { + "version": "3.50.0", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.50.0.tgz", + "integrity": "sha512-XGpFGbMLHwSt74YLTKho7Ib242qi6O8MSX+sRokV4oz7iKXvQWGYZthjIhjRGMxjzVkAubBO512dKGYcefmX3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.7" + }, + "engines": { + "node": ">=6.4.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", @@ -3207,6 +5465,16 @@ "node": ">= 8" } }, + "node_modules/crypto-random-string": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-2.0.0.tgz", + "integrity": "sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -3226,6 +5494,60 @@ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -3236,6 +5558,16 @@ "ms": "2.0.0" } }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -3243,6 +5575,16 @@ "dev": true, "license": "MIT" }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/default-browser": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", @@ -3286,6 +5628,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/define-lazy-prop": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", @@ -3299,6 +5659,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -3415,6 +5793,74 @@ "dev": true, "license": "MIT" }, + "node_modules/editorconfig": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-3.0.2.tgz", + "integrity": "sha512-T0ix8GhtxyKVfUFEcvdNDt3YGqlwkFHbD4/5bgFUDgFmxhI/cSRAeJ87/Sz//Cq8Eam6JX/e23RkoFO71P7aAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@one-ini/wasm": "0.2.1", + "commander": "^14.0.3", + "minimatch": "~10.2.4", + "semver": "^7.7.4" + }, + "bin": { + "editorconfig": "bin/editorconfig" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/editorconfig/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/editorconfig/node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/editorconfig/node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/editorconfig/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -3422,10 +5868,26 @@ "dev": true, "license": "MIT" }, + "node_modules/ejs": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", + "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "jake": "^10.8.5" + }, + "bin": { + "ejs": "bin/cli.js" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/electron-to-chromium": { - "version": "1.5.302", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz", - "integrity": "sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==", + "version": "1.5.438", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.438.tgz", + "integrity": "sha512-AN9xMU1hJiT65LkCUPL1DZm5TCbOPb2Qsm5pYwFLEXp6/qj9SdT4yMiqs7Qqstwvkn1zF7l36SRGt+s6XcJ0FA==", "dev": true, "license": "ISC" }, @@ -3472,6 +5934,94 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/es-abstract": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", + "integrity": "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-abstract-get": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-abstract-get/-/es-abstract-get-1.0.0.tgz", + "integrity": "sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.2", + "is-callable": "^1.2.7", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -3492,10 +6042,17 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", "dev": true, "license": "MIT", "dependencies": { @@ -3505,6 +6062,43 @@ "node": ">= 0.4" } }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.4.tgz", + "integrity": "sha512-yPDz7wqpg1/mmHLmS3tcfTfbw5f1eryXvyghYBffGdERwe+mV7ZcWzTR8LR17Kvqt3qfPurjlonmnq3MKXIOXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-abstract-get": "^1.0.0", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "is-callable": "^1.2.7", + "is-date-object": "^1.1.0", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/esbuild": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", @@ -3874,6 +6468,19 @@ "node": ">=0.10.0" } }, + "node_modules/eta": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/eta/-/eta-4.6.0.tgz", + "integrity": "sha512-lW6is4T1NFOYnmqGZIfvixqj7A7sSvScF+DN8EK6K58xI5MZ5UvYe0GjopxOXQtZvUn4eDdVuZ8XSoYWTMEKwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/bgub/eta?sponsor=1" + } + }, "node_modules/etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", @@ -3914,6 +6521,16 @@ "bare-events": "^2.7.0" } }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/express": { "version": "4.22.1", "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", @@ -3961,6 +6578,16 @@ "url": "https://opencollective.com/express" } }, + "node_modules/fake-indexeddb": { + "version": "6.2.5", + "resolved": "https://registry.npmjs.org/fake-indexeddb/-/fake-indexeddb-6.2.5.tgz", + "integrity": "sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -3996,6 +6623,23 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-uri": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -4009,6 +6653,39 @@ "node": ">=16.0.0" } }, + "node_modules/filelist": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz", + "integrity": "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.0.1" + } + }, + "node_modules/filelist/node_modules/brace-expansion": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/filelist/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -4089,6 +6766,22 @@ "dev": true, "license": "ISC" }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/foreground-child": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", @@ -4180,6 +6873,60 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/function.prototype.name": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.2.0.tgz", + "integrity": "sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2", + "hasown": "^2.0.4", + "is-callable": "^1.2.7", + "is-document.all": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "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/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -4215,6 +6962,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-own-enumerable-property-symbols": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/get-own-enumerable-property-symbols/-/get-own-enumerable-property-symbols-3.0.2.tgz", + "integrity": "sha512-I0UBV/XOz1XkIJHEUDMZAbzCThU/H8DxmSfmdGcKPnVhu2VfFqr34jr9777IyaTYvxjedWhqVIilEDsCdP5G6g==", + "dev": true, + "license": "ISC" + }, "node_modules/get-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", @@ -4229,6 +6983,24 @@ "node": ">= 0.4" } }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/glob": { "version": "10.5.0", "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", @@ -4316,6 +7088,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -4336,6 +7125,51 @@ "dev": true, "license": "ISC" }, + "node_modules/happy-dom": { + "version": "20.14.5", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.14.5.tgz", + "integrity": "sha512-x/RzkpWO40bTjIoT30iQtt64FLLmH/iRcUCN2X//bLx7H3ifkdfPXyqsro/OYtqzIAhiLMMA7mmiOR9C3NOKjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": ">=20.0.0", + "@types/whatwg-mimetype": "^3.0.2", + "@types/ws": "^8.18.1", + "buffer-image-size": "^0.6.4", + "entities": "^7.0.1", + "whatwg-mimetype": "^3.0.0", + "ws": "^8.21.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/happy-dom/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -4346,6 +7180,35 @@ "node": ">=8" } }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -4359,10 +7222,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "dev": true, "license": "MIT", "dependencies": { @@ -4434,6 +7313,13 @@ "node": ">=0.10.0" } }, + "node_modules/idb": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", + "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==", + "dev": true, + "license": "ISC" + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -4506,6 +7392,13 @@ "dev": true, "license": "ISC" }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC" + }, "node_modules/inquirer": { "version": "9.3.8", "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-9.3.8.tgz", @@ -4530,6 +7423,21 @@ "node": ">=18" } }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -4540,6 +7448,60 @@ "node": ">= 0.10" } }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -4553,6 +7515,87 @@ "node": ">=8" } }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.17.0", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.17.0.tgz", + "integrity": "sha512-J/vG0zBCbIKOQFfufSwyXdMrsohyJIUNkrnmo6WZGzoM7tr/lsbfW5b2BvisL6zsyMzK9UxV9L6c7AoFbyXHOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-docker": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", @@ -4569,6 +7612,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-document.all": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-document.all/-/is-document.all-1.0.0.tgz", + "integrity": "sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -4579,6 +7638,22 @@ "node": ">=0.10.0" } }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -4589,6 +7664,26 @@ "node": ">=8" } }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -4631,6 +7726,39 @@ "node": ">=8" } }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-module": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", + "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -4641,6 +7769,33 @@ "node": ">=0.12.0" } }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-obj": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-1.0.1.tgz", + "integrity": "sha512-l4RyHgRqGN4Y3+9JHVrNqO+tN0rV5My76uW5/nuO4K1b6vw5G8d/cmFjP9tRfEsdhZNt0IFdZuK/c2Vr4Nb+Qg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-plain-object": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", @@ -4654,6 +7809,64 @@ "node": ">=0.10.0" } }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-regexp": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-regexp/-/is-regexp-1.0.0.tgz", + "integrity": "sha512-7zjFAPO4/gwyQAAgRRmqeEeyIICSdmCqa3tsVHMdBzaXXRiqopZL4Cyghg/XulGWrtABTpbnYYzzIRffLkP4oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-stream": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", @@ -4667,6 +7880,57 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-unicode-supported": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", @@ -4680,6 +7944,52 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-what": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz", @@ -4761,6 +8071,137 @@ "@pkgjs/parseargs": "^0.11.0" } }, + "node_modules/jake": { + "version": "10.9.4", + "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz", + "integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "async": "^3.2.6", + "filelist": "^1.0.4", + "picocolors": "^1.1.1" + }, + "bin": { + "jake": "bin/cli.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/js-beautify": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-2.0.3.tgz", + "integrity": "sha512-cyFbh3tkPhknnTD/0bLf0T0yy2ZIbqL05mttzbt4y1Zfr7NxqXQZ62dkBLKs3oHH/lpjmDRAnciJiSUyOy8XwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "config-chain": "^1.1.13", + "editorconfig": "^3.0.2", + "glob": "^13.0.6", + "js-cookie": "^3.0.8", + "nopt": "^10.0.1" + }, + "bin": { + "css-beautify": "js/bin/css-beautify.js", + "html-beautify": "js/bin/html-beautify.js", + "js-beautify": "js/bin/js-beautify.js" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/js-beautify/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/js-beautify/node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/js-beautify/node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/js-beautify/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/js-beautify/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/js-beautify/node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/js-cookie": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.8.tgz", + "integrity": "sha512-yeJd4aNAdYZQjaon2bpD/Gb0B/omw7HQOsynXXcOiWVCacbBcPlgn8S/d1X6blFSaHao7ozqtW7NZW19xpCtIw==", + "dev": true, + "license": "MIT" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -4781,6 +8222,19 @@ "js-yaml": "bin/js-yaml.js" } }, + "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/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -4802,6 +8256,19 @@ "dev": true, "license": "MIT" }, + "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/jsonfile": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", @@ -4815,6 +8282,16 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/jsonpointer": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/jsonpointer/-/jsonpointer-5.0.1.tgz", + "integrity": "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -4888,6 +8365,16 @@ "safe-buffer": "~5.1.0" } }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -4925,6 +8412,13 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.debounce": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", + "dev": true, + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -4949,6 +8443,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/lower-case": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", @@ -5205,11 +8706,30 @@ } }, "node_modules/node-releases": { - "version": "2.0.27", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", - "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "version": "2.0.57", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.57.tgz", + "integrity": "sha512-kQK9LGGFiHtrWiNhZtA7Qbw17AQz+dmsEKODRIVTXA9+e5MS/2gZEBhYJt13GrAz5/IOZKddH/0Z3TP/Zgo+yw==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/nopt": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-10.0.1.tgz", + "integrity": "sha512-df3sBr/6ax9hSGuC3CspvLlbnX8cP5L5nZwXF8cGN8l0zSWR6BvzmQ6jPUKjvo6+/xdpkNvEcucBNUdBeeV13g==", + "dev": true, + "license": "ISC", + "dependencies": { + "abbrev": "^5.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } }, "node_modules/normalize-path": { "version": "3.0.0", @@ -5277,6 +8797,37 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -5377,6 +8928,25 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/own-keys": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.2.tgz", + "integrity": "sha512-19YVAg7T+WTrxggPukVq7DjTv6+PJ867TmhCvBsYwmbFCsZd344rq2Ld1p0wo8f8Qrrhgp82c6FJRqdXWtSEhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "get-intrinsic": "^1.3.0", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -5481,6 +9051,13 @@ "node": ">=8" } }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, "node_modules/path-scurry": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", @@ -5512,6 +9089,16 @@ "dev": true, "license": "MIT" }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, "node_modules/perfect-debounce": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", @@ -5570,6 +9157,16 @@ "pathe": "^2.0.1" } }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", @@ -5658,6 +9255,19 @@ "node": ">=6.0.0" } }, + "node_modules/pretty-bytes": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz", + "integrity": "sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/process": { "version": "0.11.10", "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", @@ -5675,6 +9285,13 @@ "dev": true, "license": "MIT" }, + "node_modules/proto-list": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", + "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==", + "dev": true, + "license": "ISC" + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -5854,6 +9471,114 @@ "node": ">=8.10.0" } }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regenerate": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", + "integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==", + "dev": true, + "license": "MIT" + }, + "node_modules/regenerate-unicode-properties": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.2.2.tgz", + "integrity": "sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "regenerate": "^1.4.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexpu-core": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.4.0.tgz", + "integrity": "sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA==", + "dev": true, + "license": "MIT", + "dependencies": { + "regenerate": "^1.4.2", + "regenerate-unicode-properties": "^10.2.2", + "regjsgen": "^0.8.0", + "regjsparser": "^0.13.0", + "unicode-match-property-ecmascript": "^2.0.0", + "unicode-match-property-value-ecmascript": "^2.2.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/register-service-worker": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/register-service-worker/-/register-service-worker-1.7.2.tgz", + "integrity": "sha512-CiD3ZSanZqcMPRhtfct5K9f7i3OLCcBBWsJjLh1gW9RO/nS94sVzY59iS+fgYBOBqaBpf4EzfqUF3j9IG+xo8A==", + "license": "MIT" + }, + "node_modules/regjsgen": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.8.0.tgz", + "integrity": "sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/regjsparser": { + "version": "0.13.3", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.13.3.tgz", + "integrity": "sha512-ycwFAS14Jw4mppvmK4GR/J6u3WpWpjkEApehuHtLc/8VpPNpDMbQ4WjqwplXifGeyKOzHSFLmSPqzksDQE2Sfg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "jsesc": "~3.1.0" + }, + "bin": { + "regjsparser": "bin/parser" + } + }, "node_modules/relateurl": { "version": "0.2.7", "resolved": "https://registry.npmjs.org/relateurl/-/relateurl-0.2.7.tgz", @@ -5874,6 +9599,38 @@ "node": ">=0.10.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -6100,6 +9857,33 @@ "tslib": "^2.1.0" } }, + "node_modules/safe-array-concat": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz", + "integrity": "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "get-intrinsic": "^1.3.0", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-array-concat/node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -6121,6 +9905,48 @@ ], "license": "MIT" }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-push-apply/node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -6638,6 +10464,55 @@ "node": ">= 0.8.0" } }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -6682,15 +10557,15 @@ } }, "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" }, @@ -6702,14 +10577,14 @@ } }, "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" + "object-inspect": "^1.13.4" }, "engines": { "node": ">= 0.4" @@ -6757,6 +10632,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -6770,6 +10652,16 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/smob": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/smob/-/smob-1.6.2.tgz", + "integrity": "sha512-RQsvleCbF8cVHEv+xuDGaA4pOizFqJ0GgjtMSRo6oP8pnN7WsigHgVGey6aILRBKv4W2YOMHLqbKdnB6hpB9fw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -6819,6 +10711,13 @@ "node": ">=16" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -6829,6 +10728,27 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/streamx": { "version": "2.23.0", "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.23.0.tgz", @@ -6882,6 +10802,109 @@ "node": ">=8" } }, + "node_modules/string.prototype.matchall": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.1.0.tgz", + "integrity": "sha512-tHNHTxInrYLCga9O9YGxWA3G9/nnzQw8UGAyqGx3Ar1pSTTzIuM4woFSq4SowkXCjJIwq5sIiQvEfRI9tCH1qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.2", + "get-intrinsic": "^1.3.0", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "regexp.prototype.flags": "^1.5.4", + "set-function-name": "^2.0.2", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.11.tgz", + "integrity": "sha512-PwvK7BU+CMTJGYQCTZb5RWXIML92lftJLhQz1tBzgKiqGxJaMlBAa48POXaNAC2s4y8jr3EFqrkF9+44neS46w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.2", + "es-object-atoms": "^1.1.2", + "has-property-descriptors": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.10.tgz", + "integrity": "sha512-2+3aDAOmPTmuFwjDnmJG2ctEkQKVki7vOSqaxkv42Mowj1V6PnvuwFCRrR5lChUux1TBskPjfkeTOhqczDMxTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/stringify-object": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/stringify-object/-/stringify-object-3.3.0.tgz", + "integrity": "sha512-rHqiFh1elqCQ9WPLIC8I0Q/g/wj5J1eMkyoiD6eoQApWHP0FtlK7rqnhmabL5VUY9JQCcqwwvlOaSuutekgyrw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "get-own-enumerable-property-symbols": "^3.0.0", + "is-obj": "^1.0.1", + "is-regexp": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -6909,6 +10932,16 @@ "node": ">=8" } }, + "node_modules/strip-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-comments/-/strip-comments-2.0.1.tgz", + "integrity": "sha512-ZprKx+bBLXv067WTCALv8SSz5l2+XhpYCsVtSqlMnkAXMWDq+/ekVbl1ghqP9rUHTzv6sm/DwCOiYutU/yp1fw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -6922,6 +10955,26 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, "node_modules/superjson": { "version": "2.2.6", "resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz", @@ -6947,6 +11000,19 @@ "node": ">=8" } }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/sync-child-process": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/sync-child-process/-/sync-child-process-1.0.2.tgz", @@ -6998,6 +11064,48 @@ "streamx": "^2.15.0" } }, + "node_modules/temp-dir": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz", + "integrity": "sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/tempy": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tempy/-/tempy-0.6.0.tgz", + "integrity": "sha512-G13vtMYPT/J8A4X2SjdtBTphZlrp1gKv6hZiOjw14RCWg6GbHuQBGtjlx75xLbYV/wEc0D7G5K4rxKP/cXk8Bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-stream": "^2.0.0", + "temp-dir": "^2.0.0", + "type-fest": "^0.16.0", + "unique-string": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/tempy/node_modules/type-fest": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.16.0.tgz", + "integrity": "sha512-eaBzG6MxNzEn9kiwvtre90cXaNLkmadMWa1zQMs3XORCXNbsH/OewwbxC5ia9dCxIxnTAsSxXJaa/p5y8DlvJg==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/terser": { "version": "5.46.0", "resolved": "https://registry.npmjs.org/terser/-/terser-5.46.0.tgz", @@ -7047,6 +11155,20 @@ "dev": true, "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -7095,6 +11217,36 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.6.tgz", + "integrity": "sha512-u8KszXvGfU68hVcZpRHKG28T0krMuv2G5nDhiHaMLen/gIuFEgIJhaJuO69qjnXg5paSrbPMFfx3brNuN8eVSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -7180,6 +11332,83 @@ "node": ">= 0.6" } }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.5.tgz", + "integrity": "sha512-0FHJvLPqZ7KJzp17O13jfsAjsqazgrxBu2zEK95PmUz8lv2+GjRuxUInCr2Rk9Dms3ihN21zJ929ZO43yJ95QQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.9", + "for-each": "^0.3.5", + "gopd": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.10" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.8.tgz", + "integrity": "sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "for-each": "^0.3.5", + "gopd": "^1.2.0", + "is-typed-array": "^1.1.15", + "possible-typed-array-names": "^1.1.0", + "reflect.getprototypeof": "^1.0.10" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/ufo": { "version": "1.6.3", "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz", @@ -7187,6 +11416,25 @@ "dev": true, "license": "MIT" }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/undici-types": { "version": "7.18.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", @@ -7194,6 +11442,50 @@ "dev": true, "license": "MIT" }, + "node_modules/unicode-canonical-property-names-ecmascript": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", + "integrity": "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-ecmascript": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz", + "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "unicode-canonical-property-names-ecmascript": "^2.0.0", + "unicode-property-aliases-ecmascript": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-value-ecmascript": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.2.1.tgz", + "integrity": "sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-property-aliases-ecmascript": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.2.0.tgz", + "integrity": "sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/unicorn-magic": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", @@ -7207,6 +11499,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/unique-string": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-2.0.0.tgz", + "integrity": "sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "crypto-random-string": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/universalify": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", @@ -7227,10 +11532,21 @@ "node": ">= 0.8" } }, + "node_modules/upath": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/upath/-/upath-1.2.0.tgz", + "integrity": "sha512-aZwGpamFO61g3OlfT7OQCHqhGnW43ieH9WZeP7QxN/G/jS4jfqUkZxoryvJgVPEcrl5NL/ggHsSmLMHuH64Lhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4", + "yarn": "*" + } + }, "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==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz", + "integrity": "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==", "dev": true, "funding": [ { @@ -7377,6 +11693,54 @@ } } }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite-node/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/vite-node/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/vite-plugin-checker": { "version": "0.9.3", "resolved": "https://registry.npmjs.org/vite-plugin-checker/-/vite-plugin-checker-0.9.3.tgz", @@ -8026,6 +12390,117 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/vitest": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz", + "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.7", + "@vitest/mocker": "3.2.7", + "@vitest/pretty-format": "^3.2.7", + "@vitest/runner": "3.2.7", + "@vitest/snapshot": "3.2.7", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.7", + "@vitest/ui": "3.2.7", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/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/vitest/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/vitest/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/vscode-uri": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", @@ -8054,6 +12529,13 @@ } } }, + "node_modules/vue-component-type-helpers": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-3.3.11.tgz", + "integrity": "sha512-LwcxzeliO9fkQcpJG0PoX8X5kmAhKmH9wkpDLxNabwzkQ9Zeib2YVHwFV4pcWmMLfXVfjr/dSV+DaJ3cIPgSNA==", + "dev": true, + "license": "MIT" + }, "node_modules/vue-eslint-parser": { "version": "9.4.3", "resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-9.4.3.tgz", @@ -8207,6 +12689,16 @@ "node": ">=18.0.0" } }, + "node_modules/whatwg-mimetype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -8223,6 +12715,119 @@ "node": ">= 8" } }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type/node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.24", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.24.tgz", + "integrity": "sha512-wk4Mf4pR5mRP7eYuuTBCIQ9d0ud2Fv2jRLQpfgnRjbOxAFHmjKFValgTpitVKzJJS8ajnYQV2Du1SZ8j6b/EUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wildcard": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/wildcard/-/wildcard-2.0.1.tgz", @@ -8240,6 +12845,397 @@ "node": ">=0.10.0" } }, + "node_modules/workbox-background-sync": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-background-sync/-/workbox-background-sync-7.4.1.tgz", + "integrity": "sha512-HhT7KE8tOWDm02wRNshXUnUPofMlhenF2DBdUnDPOubhizzPeItkYTmAB6td1Z2cjYPa98vzEiPLEuzn5hN66g==", + "dev": true, + "license": "MIT", + "dependencies": { + "idb": "^7.0.1", + "workbox-core": "7.4.1" + } + }, + "node_modules/workbox-broadcast-update": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-broadcast-update/-/workbox-broadcast-update-7.4.1.tgz", + "integrity": "sha512-uAlgslKLvbQY+suirIdnBCSYrcgBhjp81Nj4l1lj/Jmj0MJO2CJERnCJjT0GFVwmReV0N+zs78K6gqd5gr9/+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-core": "7.4.1" + } + }, + "node_modules/workbox-build": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-build/-/workbox-build-7.4.1.tgz", + "integrity": "sha512-SDhxIvEAde9Gy/5w4Yo1Jh/M49Z0qE3q0oteyE8zGq0DScxFqVBcCtIXFuLtmtxRQZCMbf0prco4VyEu3KBQuw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@apideck/better-ajv-errors": "^0.3.1", + "@babel/core": "^7.24.4", + "@babel/preset-env": "^7.11.0", + "@babel/runtime": "^7.11.2", + "@rollup/plugin-babel": "^6.1.0", + "@rollup/plugin-node-resolve": "^16.0.3", + "@rollup/plugin-replace": "^6.0.3", + "@rollup/plugin-terser": "^1.0.0", + "@trickfilm400/rollup-plugin-off-main-thread": "^3.0.0-pre1", + "ajv": "^8.6.0", + "common-tags": "^1.8.0", + "eta": "^4.5.1", + "fast-json-stable-stringify": "^2.1.0", + "fs-extra": "^9.0.1", + "glob": "^11.0.1", + "pretty-bytes": "^5.3.0", + "rollup": "^4.53.3", + "source-map": "^0.8.0-beta.0", + "stringify-object": "^3.3.0", + "strip-comments": "^2.0.1", + "tempy": "^0.6.0", + "upath": "^1.2.0", + "workbox-background-sync": "7.4.1", + "workbox-broadcast-update": "7.4.1", + "workbox-cacheable-response": "7.4.1", + "workbox-core": "7.4.1", + "workbox-expiration": "7.4.1", + "workbox-google-analytics": "7.4.1", + "workbox-navigation-preload": "7.4.1", + "workbox-precaching": "7.4.1", + "workbox-range-requests": "7.4.1", + "workbox-recipes": "7.4.1", + "workbox-routing": "7.4.1", + "workbox-strategies": "7.4.1", + "workbox-streams": "7.4.1", + "workbox-sw": "7.4.1", + "workbox-window": "7.4.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/workbox-build/node_modules/@apideck/better-ajv-errors": { + "version": "0.3.7", + "resolved": "https://registry.npmjs.org/@apideck/better-ajv-errors/-/better-ajv-errors-0.3.7.tgz", + "integrity": "sha512-TajUJwGWbDwkCx/CZi7tRE8PVB7simCvKJfHUsSdvps+aTM/PDPP4gkLmKnc+x3CE//y9i/nj74GqdL/hwk7Iw==", + "dev": true, + "license": "MIT", + "dependencies": { + "jsonpointer": "^5.0.1", + "leven": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "ajv": ">=8" + } + }, + "node_modules/workbox-build/node_modules/@isaacs/cliui": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz", + "integrity": "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/workbox-build/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/workbox-build/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/workbox-build/node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/workbox-build/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/workbox-build/node_modules/glob": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz", + "integrity": "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "foreground-child": "^3.3.1", + "jackspeak": "^4.1.1", + "minimatch": "^10.1.1", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^2.0.0" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/workbox-build/node_modules/jackspeak": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.2.3.tgz", + "integrity": "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^9.0.0" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/workbox-build/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/workbox-build/node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/workbox-build/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/workbox-build/node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/workbox-build/node_modules/source-map": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.8.0.tgz", + "integrity": "sha512-d8EqvL+k/SOXCreS/SUzg2ciyHqBBLcN/yuRjFsbvVhHTE2pgei7oAhmPM7kWFbkX6OSMQfUq4KbkF3au9lhYQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, + "node_modules/workbox-cacheable-response": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-cacheable-response/-/workbox-cacheable-response-7.4.1.tgz", + "integrity": "sha512-8xaFoJdDc2OjrlbbL3gEeBO1WKcMwRqwLRupgqahYXu75yXajPLuwrbXMrIGZuWYXrQwk0xDjOxZ/ujCy/oJYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-core": "7.4.1" + } + }, + "node_modules/workbox-core": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-core/-/workbox-core-7.4.1.tgz", + "integrity": "sha512-DT+vu46eh/2vRsSHTY4Xmc32Z1rr9PRlQUXr1Dx30ZuXRWwOsvZgGgcwxcasubQLQmbTNYZjv44LkBAQ4tT5tQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/workbox-expiration": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-expiration/-/workbox-expiration-7.4.1.tgz", + "integrity": "sha512-lRKUF7b+OGbeXkQk1s6MHXOa3d7Xxf7Of31W6c6hCfipfIyrtdWZ89stq21AHZMaoG7VNFoHply4Ox+rU31TWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "idb": "^7.0.1", + "workbox-core": "7.4.1" + } + }, + "node_modules/workbox-google-analytics": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-google-analytics/-/workbox-google-analytics-7.4.1.tgz", + "integrity": "sha512-Mks1JwLEt++ZAkF6sS1OpSh9RtAMIsiDgRpK+codiHGIPXeaUOgi4cPc3GFadUl8V5QPeypEk8Oxgl3HlwVzHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-background-sync": "7.4.1", + "workbox-core": "7.4.1", + "workbox-routing": "7.4.1", + "workbox-strategies": "7.4.1" + } + }, + "node_modules/workbox-navigation-preload": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-navigation-preload/-/workbox-navigation-preload-7.4.1.tgz", + "integrity": "sha512-C4KVsjPcYKJOhr631AxR9XoG2rLF3QiTk5aMv36MXOjtWvm8axwNFAtKUPGsWUwLXXAMgYM1En7fsvndaXeXRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-core": "7.4.1" + } + }, + "node_modules/workbox-precaching": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-precaching/-/workbox-precaching-7.4.1.tgz", + "integrity": "sha512-cdr/9qByww7yzEp7zg/qI4ukUrrNjQLgN+ONQRpjy/VqGQXwkgHwr00KksGJK8v0VifwDXBb8a4cWNZH71jn3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-core": "7.4.1", + "workbox-routing": "7.4.1", + "workbox-strategies": "7.4.1" + } + }, + "node_modules/workbox-range-requests": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-range-requests/-/workbox-range-requests-7.4.1.tgz", + "integrity": "sha512-7i2oxAUE82gHdAJBCAQ04JzNOdRPqzuOzGfoUyJpFSmeqBNYGPrAH8GPoPjUQTfp+NycwrD2H68VtuF8qxv0vQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-core": "7.4.1" + } + }, + "node_modules/workbox-recipes": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-recipes/-/workbox-recipes-7.4.1.tgz", + "integrity": "sha512-gnbVfmV4/TtmQaM4x9AtuXhcdstJsep3XMVeztOrQVPT+R6+6DeBjGTCQ7fFCXm+4GEHUA5VEBTyi5+4gWGeog==", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-cacheable-response": "7.4.1", + "workbox-core": "7.4.1", + "workbox-expiration": "7.4.1", + "workbox-precaching": "7.4.1", + "workbox-routing": "7.4.1", + "workbox-strategies": "7.4.1" + } + }, + "node_modules/workbox-routing": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-routing/-/workbox-routing-7.4.1.tgz", + "integrity": "sha512-yubJGErZOusuidAenaL5ypfhQOa7urxP/f8E0ws7FPb4039RiWXUWBAyUkmUoOL/BcQGen3h0J8872d51IYxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-core": "7.4.1" + } + }, + "node_modules/workbox-strategies": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-strategies/-/workbox-strategies-7.4.1.tgz", + "integrity": "sha512-GZxpaw9NbmOelj7667uZ2kpk5BFpOGbO4X0qjwh5ls8XQ8C+Lha5LQchTiUzsTFSS+NlUpftYAyOVXvQUrcqOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-core": "7.4.1" + } + }, + "node_modules/workbox-streams": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-streams/-/workbox-streams-7.4.1.tgz", + "integrity": "sha512-HWWtraKUbJknd9kgqGcpQ3G114HOPYvqs8HaJMDs2ebLNAimDkVDaWfAXE6Ybl+m8U6KsCE6pWyLYuigWmnAXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-core": "7.4.1", + "workbox-routing": "7.4.1" + } + }, + "node_modules/workbox-sw": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-sw/-/workbox-sw-7.4.1.tgz", + "integrity": "sha512-fez5f2DUlDJWTFYkCWQpY10N8gtztd849NswCbVFk0QlcSM4HT5A8x4g4ii650yem4I8tHY0R7JZahwp3ltIPw==", + "dev": true, + "license": "MIT" + }, + "node_modules/workbox-window": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-window/-/workbox-window-7.4.1.tgz", + "integrity": "sha512-notZDH2u8VXaqyuD7xaqIfEFi6SRM4SUSd7ewe9PDsVqADuepxX2ZMY3uvuZGxzY5ZOsGC/vD3A/3smFtJt4/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/trusted-types": "^2.0.2", + "workbox-core": "7.4.1" + } + }, "node_modules/wrap-ansi": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", @@ -8274,6 +13270,28 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/wsl-utils": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", @@ -8310,6 +13328,13 @@ "node": ">=10" } }, + "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" + }, "node_modules/yargs": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 762bf83..1a6fa90 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,18 +7,24 @@ "type": "module", "private": true, "scripts": { - "lint": "eslint -c ./eslint.config.js \"src/**/*.{js,cjs,mjs,vue}\"", + "lint": "eslint -c ./eslint.config.js \"src/**/*.{js,cjs,mjs,vue}\" test/vitest", "format": "prettier --write \"**/*.{js,vue,scss,html,md,json}\" --ignore-path .gitignore", - "test": "echo \"No test specified\" && exit 0", + "test": "vitest run", "dev": "npm install && quasar dev", - "build": "quasar build", - "postinstall": "quasar prepare" + "dev:pwa": "npm install && quasar dev -m pwa", + "build": "quasar build -m pwa", + "build:spa": "quasar build", + "serve:pwa": "quasar serve dist/pwa --history --port 9100", + "postinstall": "quasar prepare", + "test:watch": "vitest", + "test:tz": "TZ=America/Los_Angeles vitest run test/vitest/calendar-date.spec.js test/vitest/components-timeline.spec.js && TZ=Pacific/Kiritimati vitest run test/vitest/calendar-date.spec.js test/vitest/components-timeline.spec.js && TZ=Europe/Berlin vitest run test/vitest/calendar-date.spec.js test/vitest/components-timeline.spec.js" }, "dependencies": { "@quasar/extras": "^1.16.4", "dexie": "^4.3.0", "pinia": "^3.0.1", "quasar": "^2.16.0", + "register-service-worker": "^1.7.2", "three": "^0.183.0", "vue": "^3.4.18", "vue-router": "^4.0.0", @@ -32,18 +38,30 @@ "devDependencies": { "@eslint/js": "^9.14.0", "@quasar/app-vite": "^2.1.0", + "@vitejs/plugin-vue": "^6.0.9", "@vue/eslint-config-prettier": "^10.1.0", + "@vue/test-utils": "^2.5.1", "autoprefixer": "^10.4.2", "eslint": "^9.14.0", "eslint-plugin-vue": "^9.30.0", + "fake-indexeddb": "^6.2.5", "globals": "^15.12.0", + "happy-dom": "^20.14.5", "postcss": "^8.4.14", "prettier": "^3.3.3", - "vite-plugin-checker": "^0.9.0" + "vite-plugin-checker": "^0.9.0", + "vitest": "^3.2.7", + "workbox-build": "^7.4.1", + "workbox-cacheable-response": "^7.4.1", + "workbox-core": "^7.4.1", + "workbox-expiration": "^7.4.1", + "workbox-precaching": "^7.4.1", + "workbox-routing": "^7.4.1", + "workbox-strategies": "^7.4.1" }, "engines": { "node": "^28 || ^26 || ^24 || ^22 || ^20 || ^18", "npm": ">= 6.13.4", "yarn": ">= 1.21.1" } -} \ No newline at end of file +} diff --git a/frontend/public/favicon.ico b/frontend/public/favicon.ico index ae7bbdb..e10511e 100644 Binary files a/frontend/public/favicon.ico and b/frontend/public/favicon.ico differ diff --git a/frontend/public/icons/apple-icon-120x120.png b/frontend/public/icons/apple-icon-120x120.png new file mode 100644 index 0000000..2cc51b9 Binary files /dev/null and b/frontend/public/icons/apple-icon-120x120.png differ diff --git a/frontend/public/icons/apple-icon-152x152.png b/frontend/public/icons/apple-icon-152x152.png new file mode 100644 index 0000000..9d39590 Binary files /dev/null and b/frontend/public/icons/apple-icon-152x152.png differ diff --git a/frontend/public/icons/apple-icon-167x167.png b/frontend/public/icons/apple-icon-167x167.png new file mode 100644 index 0000000..55dbfcc Binary files /dev/null and b/frontend/public/icons/apple-icon-167x167.png differ diff --git a/frontend/public/icons/apple-icon-180x180.png b/frontend/public/icons/apple-icon-180x180.png new file mode 100644 index 0000000..0e9022f Binary files /dev/null and b/frontend/public/icons/apple-icon-180x180.png differ diff --git a/frontend/public/icons/favicon-128x128.png b/frontend/public/icons/favicon-128x128.png index 1401176..f6c9d2a 100644 Binary files a/frontend/public/icons/favicon-128x128.png and b/frontend/public/icons/favicon-128x128.png differ diff --git a/frontend/public/icons/favicon-16x16.png b/frontend/public/icons/favicon-16x16.png index 679063a..9cbd9d6 100644 Binary files a/frontend/public/icons/favicon-16x16.png and b/frontend/public/icons/favicon-16x16.png differ diff --git a/frontend/public/icons/favicon-32x32.png b/frontend/public/icons/favicon-32x32.png index fd1fbc6..8750807 100644 Binary files a/frontend/public/icons/favicon-32x32.png and b/frontend/public/icons/favicon-32x32.png differ diff --git a/frontend/public/icons/favicon-96x96.png b/frontend/public/icons/favicon-96x96.png index e93b80a..213c274 100644 Binary files a/frontend/public/icons/favicon-96x96.png and b/frontend/public/icons/favicon-96x96.png differ diff --git a/frontend/public/icons/icon-128x128.png b/frontend/public/icons/icon-128x128.png new file mode 100644 index 0000000..f6c9d2a Binary files /dev/null and b/frontend/public/icons/icon-128x128.png differ diff --git a/frontend/public/icons/icon-192x192.png b/frontend/public/icons/icon-192x192.png new file mode 100644 index 0000000..4da6416 Binary files /dev/null and b/frontend/public/icons/icon-192x192.png differ diff --git a/frontend/public/icons/icon-256x256.png b/frontend/public/icons/icon-256x256.png new file mode 100644 index 0000000..c3d70d3 Binary files /dev/null and b/frontend/public/icons/icon-256x256.png differ diff --git a/frontend/public/icons/icon-384x384.png b/frontend/public/icons/icon-384x384.png new file mode 100644 index 0000000..f61e448 Binary files /dev/null and b/frontend/public/icons/icon-384x384.png differ diff --git a/frontend/public/icons/icon-512x512.png b/frontend/public/icons/icon-512x512.png new file mode 100644 index 0000000..0324ed2 Binary files /dev/null and b/frontend/public/icons/icon-512x512.png differ diff --git a/frontend/public/icons/ms-icon-144x144.png b/frontend/public/icons/ms-icon-144x144.png new file mode 100644 index 0000000..f305a80 Binary files /dev/null and b/frontend/public/icons/ms-icon-144x144.png differ diff --git a/frontend/public/icons/safari-pinned-tab.svg b/frontend/public/icons/safari-pinned-tab.svg new file mode 100644 index 0000000..d475e22 --- /dev/null +++ b/frontend/public/icons/safari-pinned-tab.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/public/images/bg-image-1.jpg b/frontend/public/images/bg-image-1.jpg index 9de4148..7138f9f 100644 Binary files a/frontend/public/images/bg-image-1.jpg and b/frontend/public/images/bg-image-1.jpg differ diff --git a/frontend/public/images/bg-image-10.jpg b/frontend/public/images/bg-image-10.jpg index c5873d6..7c9a991 100644 Binary files a/frontend/public/images/bg-image-10.jpg and b/frontend/public/images/bg-image-10.jpg differ diff --git a/frontend/public/images/bg-image-7.jpg b/frontend/public/images/bg-image-7.jpg index 011a4c1..631e527 100644 Binary files a/frontend/public/images/bg-image-7.jpg and b/frontend/public/images/bg-image-7.jpg differ diff --git a/frontend/public/images/bg-image-8.jpg b/frontend/public/images/bg-image-8.jpg index c5873d6..7c9a991 100644 Binary files a/frontend/public/images/bg-image-8.jpg and b/frontend/public/images/bg-image-8.jpg differ diff --git a/frontend/public/images/bg-thumbs/bg-image-1.jpg b/frontend/public/images/bg-thumbs/bg-image-1.jpg new file mode 100644 index 0000000..76c2b5e Binary files /dev/null and b/frontend/public/images/bg-thumbs/bg-image-1.jpg differ diff --git a/frontend/public/images/bg-thumbs/bg-image-10.jpg b/frontend/public/images/bg-thumbs/bg-image-10.jpg new file mode 100644 index 0000000..8cdbcf5 Binary files /dev/null and b/frontend/public/images/bg-thumbs/bg-image-10.jpg differ diff --git a/frontend/public/images/bg-thumbs/bg-image-2.jpg b/frontend/public/images/bg-thumbs/bg-image-2.jpg new file mode 100644 index 0000000..3bff6bd Binary files /dev/null and b/frontend/public/images/bg-thumbs/bg-image-2.jpg differ diff --git a/frontend/public/images/bg-thumbs/bg-image-3.jpg b/frontend/public/images/bg-thumbs/bg-image-3.jpg new file mode 100644 index 0000000..79a76dc Binary files /dev/null and b/frontend/public/images/bg-thumbs/bg-image-3.jpg differ diff --git a/frontend/public/images/bg-thumbs/bg-image-4.jpg b/frontend/public/images/bg-thumbs/bg-image-4.jpg new file mode 100644 index 0000000..ec3ebe2 Binary files /dev/null and b/frontend/public/images/bg-thumbs/bg-image-4.jpg differ diff --git a/frontend/public/images/bg-thumbs/bg-image-5.jpg b/frontend/public/images/bg-thumbs/bg-image-5.jpg new file mode 100644 index 0000000..ec26bce Binary files /dev/null and b/frontend/public/images/bg-thumbs/bg-image-5.jpg differ diff --git a/frontend/public/images/bg-thumbs/bg-image-6.jpg b/frontend/public/images/bg-thumbs/bg-image-6.jpg new file mode 100644 index 0000000..53f5eba Binary files /dev/null and b/frontend/public/images/bg-thumbs/bg-image-6.jpg differ diff --git a/frontend/public/images/bg-thumbs/bg-image-7.jpg b/frontend/public/images/bg-thumbs/bg-image-7.jpg new file mode 100644 index 0000000..a59792d Binary files /dev/null and b/frontend/public/images/bg-thumbs/bg-image-7.jpg differ diff --git a/frontend/public/images/bg-thumbs/bg-image-8.jpg b/frontend/public/images/bg-thumbs/bg-image-8.jpg new file mode 100644 index 0000000..8cdbcf5 Binary files /dev/null and b/frontend/public/images/bg-thumbs/bg-image-8.jpg differ diff --git a/frontend/public/images/bg-thumbs/bg-image-9.jpg b/frontend/public/images/bg-thumbs/bg-image-9.jpg new file mode 100644 index 0000000..ce39c3f Binary files /dev/null and b/frontend/public/images/bg-thumbs/bg-image-9.jpg differ diff --git a/frontend/quasar.config.js b/frontend/quasar.config.js index b90233b..3c322d0 100644 --- a/frontend/quasar.config.js +++ b/frontend/quasar.config.js @@ -81,6 +81,20 @@ export default defineConfig((ctx) => { // https: true, open: true, // opens browser window automatically allowedHosts: ['app.thats-me.test'], + // When the app is opened via http://localhost:9000 the API client falls + // back to the relative base "/api" (see src/services/apiClient.js). + // Forward those requests to the Laravel container so the login works + // without VITE_API_BASE. "laravel.test" resolves inside the Docker + // network (dev container and quasar.app). When running the dev server + // on the host, point DEV_API_PROXY_TARGET at https://api.thats-me.test. + proxy: { + '/api': { + target: process.env.DEV_API_PROXY_TARGET || 'http://laravel.test', + changeOrigin: true, + // Traefik serves a self-signed certificate in local development. + secure: false, + }, + }, }, // https://v2.quasar.dev/quasar-cli-vite/quasar-config-file#framework @@ -98,7 +112,9 @@ export default defineConfig((ctx) => { // directives: [], // Quasar plugins - plugins: ['Dark'], + // Notify is used by the event panel and settings for upload/save + // errors; without it registered those calls silently did nothing. + plugins: ['Dark', 'Notify'], }, htmlVariables: { @@ -148,15 +164,37 @@ export default defineConfig((ctx) => { // https://v2.quasar.dev/quasar-cli-vite/developing-pwa/configuring-pwa pwa: { - workboxMode: 'GenerateSW', // 'GenerateSW' or 'InjectManifest' - // swFilename: 'sw.js', - // manifestFilename: 'manifest.json', - // extendManifestJson (json) {}, - // useCredentialsForManifestTag: true, - // injectPwaMetaTags: false, - // extendPWACustomSWConf (esbuildConf) {}, - // extendGenerateSWOptions (cfg) {}, - // extendInjectManifestOptions (cfg) {} + workboxMode: 'GenerateSW', + // What the service worker keeps for offline use is the app shell only. + // The API is never cached (the sync queue owns offline writes, the + // stores own offline reads), and the large bundled photos are fetched + // on first use rather than precached on install. + extendGenerateSWOptions (cfg) { + cfg.skipWaiting = true + cfg.clientsClaim = true + cfg.cleanupOutdatedCaches = true + cfg.globPatterns = [ + '**/*.{js,css,html,ico,svg,woff,woff2,json,webmanifest}', + 'icons/*.png', + 'images/bg-thumbs/*.jpg' + ] + cfg.globIgnores = [ ...(cfg.globIgnores || []), 'images/bg-image-*.jpg', 'images/gallery/**', 'videos/**', 'audio/**', 'demo/**', 'js/**' ] + cfg.navigateFallbackDenylist = [ ...(cfg.navigateFallbackDenylist || []), /^\/api\//, /^\/event-media\// ] + cfg.runtimeCaching = [ + { + urlPattern: /\/images\/.*\.(?:jpg|jpeg|png|webp)$/, + handler: 'CacheFirst', + options: { + cacheName: 'tm-backgrounds', + expiration: { maxEntries: 30, maxAgeSeconds: 60 * 60 * 24 * 60 } + } + }, + { + urlPattern: /\/api\//, + handler: 'NetworkOnly' + } + ] + } }, // Full list of options: https://v2.quasar.dev/quasar-cli-vite/developing-cordova-apps/configuring-cordova diff --git a/frontend/src-pwa/manifest.json b/frontend/src-pwa/manifest.json new file mode 100644 index 0000000..daefe39 --- /dev/null +++ b/frontend/src-pwa/manifest.json @@ -0,0 +1,17 @@ +{ + "name": "That's Me", + "short_name": "That's Me", + "description": "Deine LifeWave: Ereignisse, Bilder und Sprachnotizen auf einer Zeitlinie.", + "lang": "de", + "display": "standalone", + "orientation": "any", + "background_color": "#1a1b1c", + "theme_color": "#292a2b", + "icons": [ + { "src": "icons/icon-128x128.png", "sizes": "128x128", "type": "image/png" }, + { "src": "icons/icon-192x192.png", "sizes": "192x192", "type": "image/png" }, + { "src": "icons/icon-256x256.png", "sizes": "256x256", "type": "image/png" }, + { "src": "icons/icon-384x384.png", "sizes": "384x384", "type": "image/png" }, + { "src": "icons/icon-512x512.png", "sizes": "512x512", "type": "image/png" } + ] +} diff --git a/frontend/src-pwa/register-service-worker.js b/frontend/src-pwa/register-service-worker.js new file mode 100644 index 0000000..436d1ac --- /dev/null +++ b/frontend/src-pwa/register-service-worker.js @@ -0,0 +1,22 @@ +import { register } from 'register-service-worker' + +/** + * Registers the service worker that makes the app shell available without + * a connection. The shell is everything the build produced (scripts, + * styles, icons); the user's own data comes from IndexedDB and the sync + * queue, never from this cache. + * + * The worker takes over as soon as a new build is installed + * (skipWaiting/clientsClaim in quasar.config.js). The app is told so it can + * offer a reload; it never reloads on its own, that would throw away an + * open draft. + */ +register(process.env.SERVICE_WORKER_FILE, { + updated() { + window.dispatchEvent(new CustomEvent('tm:app-updated')) + }, + + error(err) { + console.warn('Service worker registration failed:', err) + } +}) diff --git a/frontend/src/App.vue b/frontend/src/App.vue index b22f395..41ba18e 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -3,5 +3,27 @@ diff --git a/frontend/src/assets/logo-thatsme-negativ.svg b/frontend/src/assets/logo-thatsme-negativ.svg new file mode 100644 index 0000000..c07025c --- /dev/null +++ b/frontend/src/assets/logo-thatsme-negativ.svg @@ -0,0 +1,107 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/assets/logo-thatsme-positiv.svg b/frontend/src/assets/logo-thatsme-positiv.svg new file mode 100644 index 0000000..33a331f --- /dev/null +++ b/frontend/src/assets/logo-thatsme-positiv.svg @@ -0,0 +1,105 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/components/AddEventButton.vue b/frontend/src/components/AddEventButton.vue index 2a58325..ac0a20f 100644 --- a/frontend/src/components/AddEventButton.vue +++ b/frontend/src/components/AddEventButton.vue @@ -1,33 +1,42 @@ diff --git a/frontend/src/components/AppSettingsModal.vue b/frontend/src/components/AppSettingsModal.vue index e4443b6..8f0334f 100644 --- a/frontend/src/components/AppSettingsModal.vue +++ b/frontend/src/components/AppSettingsModal.vue @@ -1,7 +1,7 @@ diff --git a/frontend/src/components/ConfirmDialog.vue b/frontend/src/components/ConfirmDialog.vue new file mode 100644 index 0000000..6a93896 --- /dev/null +++ b/frontend/src/components/ConfirmDialog.vue @@ -0,0 +1,70 @@ + + + + + diff --git a/frontend/src/components/EventPanel.vue b/frontend/src/components/EventPanel.vue index 2024cd3..4f9610b 100644 --- a/frontend/src/components/EventPanel.vue +++ b/frontend/src/components/EventPanel.vue @@ -6,7 +6,7 @@ :style="panelHeight != null ? { height: panelHeight + 'dvh' } : {}" :class="{ 'event-panel--dragging': isDragging }" > - +
- + + + + +
+ Entwurf. Wird mit dem ersten Eintrag gespeichert. +
+
+ {{ saveStateLabel }} + +
+ +
+
- + -
+ +
+ + +
+ - - -
@@ -102,62 +187,144 @@
-
- -
-
- Emotional Level - - {{ emotionLabel }} - -
- - -
- -
- -
- Sehr negativ - Neutral - Sehr positiv -
- -
-
- Punktfarbe (optional) - + +
+
+ Stimmung + {{ emotionLabel }} +
-
+ +
+ + + +
+ +
+ + Der Punkt auf der Welle nimmt die Farbe der Stimmung.
+ +
+ Kategorie + + + + +
+ + + +
+ Personen + + + +
@@ -174,83 +341,132 @@ />
- +
- Weitere Medien +
+ Bilder & Videos + +
- + -
-
+
+ Bild / Video +
+ + +
+
- - - -
Key Image entfernen?
-
- Das Key Image wird endgültig aus diesem Event entfernt. -
-
+ - - - - -
-
+ + + @@ -288,24 +520,37 @@ diff --git a/frontend/src/components/TimelineView.vue b/frontend/src/components/TimelineView.vue index fb37b57..c12826b 100644 --- a/frontend/src/components/TimelineView.vue +++ b/frontend/src/components/TimelineView.vue @@ -20,52 +20,8 @@ :selected="eventsStore.selectedEventId === event.id" @select="$emit('dotSelect', event.id)" /> - - -
-
- {{ label.month }} -
-
- - -
- -
- - - {{ label.year }} - - -
-
-
@@ -74,6 +30,7 @@ import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue' import { useEventsStore } from 'stores/events' import { DEFAULT_TIMELINE_ZOOM, useSettingsStore } from 'stores/settings' import GlowDot from 'components/GlowDot.vue' +import { calendarMonthIndex, calendarYear, compareCalendarDates } from 'src/utils/calendarDate' const emit = defineEmits(['dotSelect', 'viewUpdate']) const eventsStore = useEventsStore() @@ -100,8 +57,7 @@ const displayEvents = computed(() => { const sorted = [...eventsStore.sortedEvents] if (!showGhost.value) return sorted const ghost = eventsStore.ghostEvent - const ghostDate = new Date(ghost.date) - const insertIdx = sorted.findIndex((e) => new Date(e.date) > ghostDate) + const insertIdx = sorted.findIndex((e) => compareCalendarDates(e.date, ghost.date) > 0) if (insertIdx === -1) { sorted.push(ghost) } else { @@ -122,21 +78,7 @@ function getEventX(index) { return PADDING.value + index * EVENT_SPACING.value } -// Month names -const MONTHS = ['Jan', 'Feb', 'Mär', 'Apr', 'Mai', 'Jun', 'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Dez'] - -// One label per event showing its month -const eventLabels = computed(() => { - return displayEvents.value.map((event, index) => { - const d = new Date(event.date) - return { - key: `${event.id}-${index}`, - month: MONTHS[d.getMonth()], - year: d.getFullYear(), - fullMonth: `${d.getFullYear()}-${d.getMonth()}` - } - }) -}) +const MONTHS_LONG = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'] // Year ranges — for each year, the world-space X range of its events const yearRanges = computed(() => { @@ -144,11 +86,11 @@ const yearRanges = computed(() => { const events = displayEvents.value if (events.length === 0) return ranges - let currentYear = new Date(events[0].date).getFullYear() + let currentYear = calendarYear(events[0].date) let startIdx = 0 for (let i = 1; i < events.length; i++) { - const year = new Date(events[i].date).getFullYear() + const year = calendarYear(events[i].date) if (year !== currentYear) { ranges.push({ year: currentYear, startX: getEventX(startIdx), endX: getEventX(i - 1) }) currentYear = year @@ -159,57 +101,9 @@ const yearRanges = computed(() => { return ranges }) -// All years that exist in the data (for prev/next navigation) +// All years that exist in the data, ascending (for the year navigation) const allYears = computed(() => yearRanges.value.map(r => r.year)) -// Sticky year labels — positioned relative to viewport, clamped to edges -const YEAR_MARGIN = 24 -const stickyYearLabels = computed(() => { - const sl = scrollLeft.value - const vw = viewportWidth.value - const viewLeft = sl - const viewRight = sl + vw - const years = allYears.value - - // Find years whose event range overlaps the viewport - const visible = yearRanges.value.filter(r => r.endX >= viewLeft && r.startX <= viewRight) - if (visible.length === 0) return [] - - function makeLabel(year, left) { - const idx = years.indexOf(year) - return { year, left, hasPrev: idx > 0, hasNext: idx < years.length - 1 } - } - - if (visible.length === 1) { - const r = visible[0] - const visStart = Math.max(r.startX, viewLeft) - const visEnd = Math.min(r.endX, viewRight) - const center = (visStart + visEnd) / 2 - sl - const clamped = Math.max(YEAR_MARGIN, Math.min(vw - YEAR_MARGIN, center)) - return [makeLabel(r.year, clamped)] - } - - // Multiple years: first pins left, last pins right, middles float naturally - const result = [] - for (let i = 0; i < visible.length; i++) { - const r = visible[i] - const visStart = Math.max(r.startX, viewLeft) - const visEnd = Math.min(r.endX, viewRight) - const center = (visStart + visEnd) / 2 - sl - - let pos - if (i === 0) { - pos = Math.max(YEAR_MARGIN, Math.min(vw / 2 - 30, center)) - } else if (i === visible.length - 1) { - pos = Math.min(vw - YEAR_MARGIN, Math.max(vw / 2 + 30, center)) - } else { - pos = Math.max(YEAR_MARGIN + 60, Math.min(vw - YEAR_MARGIN - 60, center)) - } - result.push(makeLabel(r.year, pos)) - } - return result -}) - // Virtualization: only render events near the viewport const VIS_BUFFER = 2 const VIEW_EMIT_BUFFER = 3 @@ -238,25 +132,27 @@ const visibleEvents = computed(() => { })) }) -const visibleLabels = computed(() => { - const { start, end } = visibleRange.value - if (end < start) return [] - return eventLabels.value.slice(start, end + 1).map((label, i) => ({ - label, - globalIndex: start + i - })) -}) - -// Active label — closest event to center of viewport (O(1) via index) -const activeLabel = computed(() => { +// The event closest to the viewport centre (O(1) via index) +const activeIndex = computed(() => { const total = displayEvents.value.length - if (total === 0) return null + if (total === 0) return -1 const centerX = scrollLeft.value + viewportWidth.value / 2 const spacing = EVENT_SPACING.value - if (spacing <= 0) return null + if (spacing <= 0) return 0 const index = Math.round((centerX - PADDING.value) / spacing) - const clamped = Math.max(0, Math.min(total - 1, index)) - return eventLabels.value[clamped]?.key ?? null + return Math.max(0, Math.min(total - 1, index)) +}) + +// Month and year of the centred event, read from the calendar date so the +// navigation never drifts into the neighbouring month in western zones. +const activePosition = computed(() => { + const event = displayEvents.value[activeIndex.value] + if (!event) return null + const month = calendarMonthIndex(event.date) + return { + year: calendarYear(event.date), + month: month === null ? '' : MONTHS_LONG[month] + } }) function onScroll() { @@ -267,15 +163,6 @@ function onScroll() { } } -function scrollToIndex(index) { - if (!timelineRef.value) return - const x = getEventX(index) - timelineRef.value.scrollTo({ - left: x - viewportWidth.value / 2, - behavior: 'smooth' - }) -} - function scrollToYearCenter(year) { if (!timelineRef.value) return // Find exact year, or nearest in the requested direction @@ -632,7 +519,19 @@ function zoomTo(value) { if (newZoom !== zoomLevel.value) applyZoom(newZoom) } -defineExpose({ timelineRef, zoomIn, zoomOut, zoomTo, zoomLevel, MIN_ZOOM, MAX_ZOOM, forceReflow }) +defineExpose({ + timelineRef, + zoomIn, + zoomOut, + zoomTo, + zoomLevel, + MIN_ZOOM, + MAX_ZOOM, + forceReflow, + activePosition, + years: allYears, + scrollToYearCenter +}) diff --git a/frontend/src/components/UserMenu.vue b/frontend/src/components/UserMenu.vue index 5480c19..7da01b5 100644 --- a/frontend/src/components/UserMenu.vue +++ b/frontend/src/components/UserMenu.vue @@ -14,70 +14,28 @@
- +
- - -
- -
- - -
- - - -
- - - - - - -
-
@@ -96,7 +54,7 @@
{{ authStore.currentUser?.name ?? 'Demo User' }}
-
Demo Account · v{{ APP_VERSION }}
+
{{ accountLabel }} · v{{ APP_VERSION }}
@@ -104,15 +62,28 @@ diff --git a/frontend/src/components/VoiceNotesCard.vue b/frontend/src/components/VoiceNotesCard.vue new file mode 100644 index 0000000..b680869 --- /dev/null +++ b/frontend/src/components/VoiceNotesCard.vue @@ -0,0 +1,473 @@ + + + + + diff --git a/frontend/src/components/VoiceWaveform.vue b/frontend/src/components/VoiceWaveform.vue new file mode 100644 index 0000000..c047e5b --- /dev/null +++ b/frontend/src/components/VoiceWaveform.vue @@ -0,0 +1,105 @@ + + + + + diff --git a/frontend/src/components/ZoomControl.vue b/frontend/src/components/ZoomControl.vue index c53bb45..8fe12aa 100644 --- a/frontend/src/components/ZoomControl.vue +++ b/frontend/src/components/ZoomControl.vue @@ -1,14 +1,24 @@ @@ -57,18 +67,19 @@ function updateFromPointer(e) { display: flex; flex-direction: column; align-items: center; - gap: 4px; - padding: 6px 4px; - border-radius: 22px; - width: 36px; + gap: 2px; + padding: 4px; + border-radius: 24px; + width: 44px; } +/* 40px targets: about the minimum a thumb hits reliably */ .zoom-control__btn { display: flex; align-items: center; justify-content: center; - width: 28px; - height: 28px; + width: 36px; + height: 36px; border: none; background: none; color: inherit; @@ -84,19 +95,31 @@ function updateFromPointer(e) { .zoom-control__track { position: relative; - width: 3px; - height: 80px; - background: rgba(255, 255, 255, 0.12); - border-radius: 2px; + width: 28px; + height: 96px; cursor: pointer; touch-action: none; } +/* The line itself */ +.zoom-control__track::before { + content: ''; + position: absolute; + left: 50%; + top: 0; + bottom: 0; + width: 3px; + transform: translateX(-50%); + background: rgba(255, 255, 255, 0.12); + border-radius: 2px; +} + .zoom-control__fill { position: absolute; bottom: 0; - left: 0; - width: 100%; + left: 50%; + width: 3px; + transform: translateX(-50%); border-radius: 2px; background: rgba(255, 255, 255, 0.35); pointer-events: none; @@ -105,8 +128,8 @@ function updateFromPointer(e) { .zoom-control__thumb { position: absolute; left: 50%; - width: 10px; - height: 10px; + width: 14px; + height: 14px; border-radius: 50%; background: #fff; transform: translate(-50%, 50%); @@ -115,7 +138,7 @@ function updateFromPointer(e) { } /* Light mode adjustments */ -.body--light .zoom-control__track { +.body--light .zoom-control__track::before { background: rgba(0, 0, 0, 0.1); } diff --git a/frontend/src/composables/useImageCache.js b/frontend/src/composables/useImageCache.js index cca82f9..7a5d9d5 100644 --- a/frontend/src/composables/useImageCache.js +++ b/frontend/src/composables/useImageCache.js @@ -1,13 +1,55 @@ import { ref, unref, watch } from 'vue' import { db } from 'src/db' -import { apiFetch } from 'src/services/apiClient' +import { API_BASE, apiFetch } from 'src/services/apiClient' const THUMB_SIZE = 200 -// In-memory URL cache: avoids repeated IndexedDB reads and blob URL creation -// Shared across all component instances +// In-memory cache: avoids repeated IndexedDB reads and blob URL creation. +// Shared across all component instances, therefore scoped by user — the URL +// alone is not a safe key, because two users' images can share a path and a +// cached blob would otherwise outlive the session that fetched it. const memoryCache = new Map() +// The user the cache currently holds data for. +let scopeUserId = null + +/** + * Point the image cache at a user. Switching users (or logging out, with + * `null`) drops every cached blob so nothing carries over. + */ +export function setImageCacheScope(userId) { + const nextScope = userId ?? null + if (nextScope === scopeUserId) return + + const previousScope = scopeUserId + scopeUserId = nextScope + purgeMemoryCache() + + if (previousScope !== null) { + db.scopedImageCache + .where('userId') + .equals(previousScope) + .delete() + .catch(e => console.warn('Image cache purge failed:', e)) + } +} + +function purgeMemoryCache() { + for (const blobUrl of memoryCache.values()) { + URL.revokeObjectURL(blobUrl) + } + memoryCache.clear() +} + +/** + * Cache key: API origin + owning user + image URL. All three matter — the + * same path means different bytes for a different user or a different + * backend. + */ +function cacheKey(imageUrl) { + return `${API_BASE}|${scopeUserId ?? 'anonymous'}|${imageUrl}` +} + function isProtectedApiImageUrl(imageUrl) { return imageUrl.startsWith('/event-media/') || imageUrl.startsWith('/settings/media/') } @@ -66,8 +108,11 @@ async function fetchAndCache(imageUrl, eventId) { const thumbBlob = await createThumbnail(blob) // Store in IndexedDB - await db.imageCache.put({ + const key = cacheKey(imageUrl) + await db.scopedImageCache.put({ + key, url: imageUrl, + userId: scopeUserId, eventId, type: 'thumbnail', blob: thumbBlob, @@ -75,7 +120,7 @@ async function fetchAndCache(imageUrl, eventId) { }) const blobUrl = URL.createObjectURL(thumbBlob) - memoryCache.set(imageUrl, blobUrl) + memoryCache.set(key, blobUrl) return blobUrl } @@ -84,14 +129,16 @@ async function fetchAndCache(imageUrl, eventId) { * Returns null if not cached. */ async function getCachedImage(imageUrl) { + const key = cacheKey(imageUrl) + // Check memory first - if (memoryCache.has(imageUrl)) return memoryCache.get(imageUrl) + if (memoryCache.has(key)) return memoryCache.get(key) try { - const entry = await db.imageCache.get(imageUrl) + const entry = await db.scopedImageCache.get(key) if (entry?.blob) { const blobUrl = URL.createObjectURL(entry.blob) - memoryCache.set(imageUrl, blobUrl) + memoryCache.set(key, blobUrl) return blobUrl } } catch (e) { @@ -120,8 +167,9 @@ export function useImageCache(imageUrl, eventId) { } // 1. Memory cache (instant) - if (memoryCache.has(nextUrl)) { - resolvedSrc.value = memoryCache.get(nextUrl) + const key = cacheKey(nextUrl) + if (memoryCache.has(key)) { + resolvedSrc.value = memoryCache.get(key) loading.value = false return } @@ -167,13 +215,14 @@ export async function resolveFullRes(imageUrl) { if (!imageUrl) return null if (isProtectedApiImageUrl(imageUrl)) { - if (memoryCache.has(imageUrl)) return memoryCache.get(imageUrl) + const key = cacheKey(imageUrl) + if (memoryCache.has(key)) return memoryCache.get(key) try { const response = await apiFetch(imageUrl) if (!response.ok) throw new Error(`Fetch failed: ${response.status}`) const blobUrl = URL.createObjectURL(await response.blob()) - memoryCache.set(imageUrl, blobUrl) + memoryCache.set(key, blobUrl) return blobUrl } catch (error) { console.warn('Full-res image load failed:', error) @@ -193,14 +242,14 @@ export async function resolveFullRes(imageUrl) { */ export async function clearEventImages(eventId) { try { - const entries = await db.imageCache.where('eventId').equals(eventId).toArray() + const entries = await db.scopedImageCache.where('eventId').equals(eventId).toArray() for (const entry of entries) { - if (memoryCache.has(entry.url)) { - URL.revokeObjectURL(memoryCache.get(entry.url)) - memoryCache.delete(entry.url) + if (memoryCache.has(entry.key)) { + URL.revokeObjectURL(memoryCache.get(entry.key)) + memoryCache.delete(entry.key) } } - await db.imageCache.where('eventId').equals(eventId).delete() + await db.scopedImageCache.where('eventId').equals(eventId).delete() } catch (e) { console.warn('Clear event images failed:', e) } diff --git a/frontend/src/composables/useStoreFeedback.js b/frontend/src/composables/useStoreFeedback.js new file mode 100644 index 0000000..5ed7b69 --- /dev/null +++ b/frontend/src/composables/useStoreFeedback.js @@ -0,0 +1,45 @@ +import { watch } from 'vue' +import { useQuasar } from 'quasar' +import { useRouter } from 'vue-router' +import { useAuthStore } from 'stores/auth' +import { useEventsStore } from 'stores/events' +import { useSettingsStore } from 'stores/settings' + +const NOTIFY_TIMEOUT_MS = 6000 + +/** + * Turns store-level failures into something the user can see, and ends the + * screen session when the auth store says it is over. + * + * Kept out of the layout so it can be exercised on its own: the layout also + * owns the WebGL scene and cannot be mounted in a DOM-only test environment. + */ +export function useStoreFeedback() { + const $q = useQuasar() + const router = useRouter() + const authStore = useAuthStore() + const eventsStore = useEventsStore() + const settingsStore = useSettingsStore() + + // Each message is consumed after it is shown, so the same error occurring + // twice in a row still produces a second notification. + function notifyAndConsume(read, clear, type) { + watch(read, (message) => { + if (!message) return + $q.notify({ type, message, timeout: NOTIFY_TIMEOUT_MS }) + clear() + }) + } + + notifyAndConsume(() => eventsStore.loadError, () => { eventsStore.loadError = null }, 'warning') + notifyAndConsume(() => eventsStore.lastError, () => { eventsStore.lastError = null }, 'negative') + notifyAndConsume(() => settingsStore.lastError, () => { settingsStore.lastError = null }, 'warning') + + // An expired token ends the session from inside the API client. Without + // this the app stayed on screen in a state where nothing could be saved. + watch(() => authStore.isAuthenticated, (isAuthenticated) => { + if (!isAuthenticated && router.currentRoute.value.path !== '/login') { + router.replace('/login') + } + }) +} diff --git a/frontend/src/composables/useVoiceRecorder.js b/frontend/src/composables/useVoiceRecorder.js new file mode 100644 index 0000000..c44040c --- /dev/null +++ b/frontend/src/composables/useVoiceRecorder.js @@ -0,0 +1,264 @@ +import { onBeforeUnmount, ref } from 'vue' + +export const MAX_VOICE_NOTE_MS = 5 * 60 * 1000 + +// Number of level samples kept for the live waveform and how often a new +// one is taken. 64 samples at 50 ms show the last ~3 seconds. +export const LEVEL_HISTORY = 64 +const LEVEL_SAMPLE_MS = 40 + +// Preferred containers in order. Chrome and Firefox record WebM/Opus, +// Safari records MP4/AAC; the first supported one wins. +const CANDIDATE_TYPES = [ + 'audio/webm;codecs=opus', + 'audio/webm', + 'audio/mp4', + 'audio/ogg;codecs=opus' +] + +/** + * Records a voice note with the browser's MediaRecorder. + * + * `start()` asks for the microphone and begins recording; `stop()` resolves + * with `{ blob, mimeType, durationMs }` or null when nothing was captured. + * Recording ends on its own at `maxDurationMs`. The microphone is released + * on stop and when the owning component unmounts. + * + * While recording, `level` holds the current input loudness (0..1) and + * `levels` the recent history, newest last, for a live waveform. + */ +export function useVoiceRecorder({ maxDurationMs = MAX_VOICE_NOTE_MS } = {}) { + const isSupported = + typeof window !== 'undefined' && + typeof MediaRecorder !== 'undefined' && + typeof navigator !== 'undefined' && + Boolean(navigator.mediaDevices?.getUserMedia) + + const isRecording = ref(false) + const elapsedMs = ref(0) + const error = ref('') + const level = ref(0) + const levels = ref(Array(LEVEL_HISTORY).fill(0)) + + let recorder = null + let stream = null + let chunks = [] + let startedAt = 0 + let timer = 0 + let mimeType = '' + let resolveStop = null + let audioContext = null + let analyser = null + let analyserBuffer = null + let meterFrame = 0 + let lastSampleAt = 0 + + function pickMimeType() { + if (typeof MediaRecorder.isTypeSupported !== 'function') return '' + return CANDIDATE_TYPES.find(type => MediaRecorder.isTypeSupported(type)) ?? '' + } + + async function start() { + if (!isSupported) { + error.value = 'Aufnahmen werden von diesem Browser nicht unterstützt.' + return false + } + if (isRecording.value) return true + + error.value = '' + + // Created inside the click, before the permission prompt: a context + // created after the (possibly long) prompt starts suspended and the + // waveform lags behind the recording. + prepareAudioContext() + + try { + stream = await navigator.mediaDevices.getUserMedia({ audio: true }) + } catch (e) { + stopMeter() + error.value = e?.name === 'NotAllowedError' || e?.name === 'SecurityError' + ? 'Der Zugriff auf das Mikrofon wurde abgelehnt.' + : 'Das Mikrofon konnte nicht gestartet werden.' + return false + } + + try { + mimeType = pickMimeType() + recorder = mimeType ? new MediaRecorder(stream, { mimeType }) : new MediaRecorder(stream) + } catch { + stopMeter() + releaseStream() + error.value = 'Die Aufnahme konnte nicht gestartet werden.' + return false + } + + chunks = [] + recorder.ondataavailable = (event) => { + if (event.data && event.data.size > 0) chunks.push(event.data) + } + recorder.onstop = finish + recorder.onerror = () => { + error.value = 'Die Aufnahme ist fehlgeschlagen.' + } + + recorder.start(250) + startedAt = Date.now() + elapsedMs.value = 0 + isRecording.value = true + startMeter() + + timer = setInterval(() => { + elapsedMs.value = Date.now() - startedAt + if (elapsedMs.value >= maxDurationMs) stop() + }, 200) + + return true + } + + function stop() { + return new Promise((resolve) => { + if (!recorder || recorder.state === 'inactive') { + resolve(null) + return + } + resolveStop = resolve + recorder.stop() + }) + } + + function finish() { + clearInterval(timer) + timer = 0 + + const durationMs = Math.max(0, Date.now() - startedAt) + const fullType = recorder?.mimeType || mimeType || 'audio/webm' + const blob = new Blob(chunks, { type: fullType }) + + stopMeter() + releaseStream() + isRecording.value = false + + const result = blob.size > 0 + ? { blob, mimeType: fullType.split(';')[0], durationMs } + : null + + const resolve = resolveStop + resolveStop = null + resolve?.(result) + } + + function releaseStream() { + stream?.getTracks().forEach(track => track.stop()) + stream = null + recorder = null + } + + // ---- Input level meter (Web Audio analyser on the same stream) ---- + + function prepareAudioContext() { + const AudioContextClass = window.AudioContext || window.webkitAudioContext + if (!AudioContextClass || audioContext) return + try { + audioContext = new AudioContextClass() + // Safari keeps a context suspended until it is resumed from a user + // gesture; we are inside the record button's click here. + audioContext.resume?.().catch?.(() => {}) + } catch { + audioContext = null + } + } + + function startMeter() { + if (!stream) return + prepareAudioContext() + if (!audioContext) return + + try { + audioContext.resume?.().catch?.(() => {}) + analyser = audioContext.createAnalyser() + analyser.fftSize = 1024 + analyser.smoothingTimeConstant = 0 + analyserBuffer = new Uint8Array(analyser.fftSize) + audioContext.createMediaStreamSource(stream).connect(analyser) + } catch { + // No meter, but the recording itself is unaffected. + analyser = null + return + } + + levels.value = Array(LEVEL_HISTORY).fill(0) + level.value = 0 + lastSampleAt = 0 + meterFrame = requestAnimationFrame(sampleLevel) + } + + function sampleLevel(now) { + if (!analyser) return + meterFrame = requestAnimationFrame(sampleLevel) + + analyser.getByteTimeDomainData(analyserBuffer) + let sumSquares = 0 + let peak = 0 + for (let i = 0; i < analyserBuffer.length; i++) { + const centred = Math.abs(analyserBuffer[i] - 128) / 128 + sumSquares += centred * centred + if (centred > peak) peak = centred + } + // Speech sits far below full scale. Peak and RMS are both lifted, the + // louder one wins, and the curve is compressed so quiet talking is + // clearly visible while shouting still tops out at 1. + const rms = Math.sqrt(sumSquares / analyserBuffer.length) + const raw = Math.max(peak * 1.6, rms * 4.5) + const next = raw < 0.02 ? 0 : Math.min(1, Math.pow(raw, 0.7)) + level.value = next + + if (now - lastSampleAt >= LEVEL_SAMPLE_MS) { + lastSampleAt = now + const history = levels.value.slice(1) + history.push(next) + levels.value = history + } + } + + function stopMeter() { + if (meterFrame) cancelAnimationFrame(meterFrame) + meterFrame = 0 + analyser = null + analyserBuffer = null + level.value = 0 + audioContext?.close?.().catch?.(() => {}) + audioContext = null + } + + onBeforeUnmount(() => { + clearInterval(timer) + stopMeter() + if (recorder && recorder.state !== 'inactive') { + recorder.onstop = null + try { recorder.stop() } catch { /* already stopped */ } + } + releaseStream() + isRecording.value = false + }) + + return { isSupported, isRecording, elapsedMs, error, level, levels, start, stop } +} + +/** Format milliseconds as m:ss for durations shown next to media. */ +export function formatDuration(ms) { + if (!Number.isFinite(ms) || ms <= 0) return '' + const totalSeconds = Math.round(ms / 1000) + const minutes = Math.floor(totalSeconds / 60) + const seconds = totalSeconds % 60 + return `${minutes}:${String(seconds).padStart(2, '0')}` +} + +/** File extension for a recorded MIME type. */ +export function extensionForAudioType(mimeType) { + if (!mimeType) return 'webm' + if (mimeType.includes('mp4')) return 'm4a' + if (mimeType.includes('ogg')) return 'ogg' + if (mimeType.includes('mpeg')) return 'mp3' + if (mimeType.includes('wav')) return 'wav' + return 'webm' +} diff --git a/frontend/src/config/eventCategories.js b/frontend/src/config/eventCategories.js new file mode 100644 index 0000000..e3a9841 --- /dev/null +++ b/frontend/src/config/eventCategories.js @@ -0,0 +1,21 @@ +/** + * Event categories. Stored by key (see backend EventFields::CATEGORIES); the + * labels and icons are presentation only and may change freely. + */ +export const EVENT_CATEGORIES = [ + { value: 'family', label: 'Familie', icon: 'family_restroom' }, + { value: 'friends', label: 'Freunde', icon: 'groups' }, + { value: 'love', label: 'Liebe', icon: 'favorite' }, + { value: 'work', label: 'Arbeit', icon: 'work' }, + { value: 'education', label: 'Ausbildung', icon: 'school' }, + { value: 'health', label: 'Gesundheit', icon: 'monitor_heart' }, + { value: 'travel', label: 'Reise', icon: 'flight' }, + { value: 'home', label: 'Wohnen', icon: 'home' }, + { value: 'hobby', label: 'Hobby', icon: 'sports_esports' }, + { value: 'loss', label: 'Verlust', icon: 'spa' }, + { value: 'other', label: 'Sonstiges', icon: 'more_horiz' } +] + +export function findEventCategory(value) { + return EVENT_CATEGORIES.find(category => category.value === value) ?? null +} diff --git a/frontend/src/config/wavePresets.js b/frontend/src/config/wavePresets.js new file mode 100644 index 0000000..cbccc49 --- /dev/null +++ b/frontend/src/config/wavePresets.js @@ -0,0 +1,242 @@ +/** + * Built-in LifeWave presets. + * + * Each preset is a partial `floatingLines` object; missing keys fall back to + * FLOATING_LINES_DEFAULTS. `preview` holds a few colours for the tile in the + * settings panel, so the panel never has to load the large background + * photos just to show a swatch. + * + * Glow mode adds light on top of the ground and only works on dark ground; + * light presets therefore use static lines. + */ + +export const BRAND_PRESET_ID = 'builtin:gold' + +const GOLD_LINES = '#f5c26b\n#d59040\n#3a2a14' + +export const WAVE_PRESETS = [ + { + id: BRAND_PRESET_ID, + name: 'Gold', + description: 'Die Marke: goldene Linien auf tiefem Anthrazit.', + preview: { ground: ['#2f3032', '#1a1b1c'], lines: ['#f5c26b', '#d59040'] }, + floatingLines: { + lineMode: 'glow', + gradientStops: GOLD_LINES, + bgCenter: '#2f3032', + bgEdge: '#1a1b1c', + backgroundImage: '', + glowSize: 18, + glowStrength: 1.6, + lineBrightness: 0.9, + horizonMode: 'glow', + horizonOpacity: 0.25, + labelColor: '#f7f3ea', + dotBorderWidth: 0 + } + }, + { + id: 'builtin:paper', + name: 'Papier', + description: 'Hell und ruhig: dunkles Gold auf Papier, für Tageslicht.', + preview: { ground: ['#f7f3ea', '#e6ddca'], lines: ['#d59040', '#b9752f'] }, + floatingLines: { + lineMode: 'static', + staticLineColor: '#d59040', + staticLineShadowStrength: 0.15, + lineThickness: 1.2, + lineBrightness: 1, + bgCenter: '#f7f3ea', + bgEdge: '#e6ddca', + backgroundImage: '', + glowSize: 10, + glowStrength: 0.8, + horizonMode: 'off', + labelColor: '#292a2b', + labelOpacity: 0.85, + dotBorderWidth: 1.5, + dotBorderColor: '#292a2b' + } + }, + { + id: 'builtin:aurora', + name: 'Aurora', + description: 'Grünes und blaues Nordlicht mit weiten, langsamen Wellen.', + preview: { ground: ['#04121a', '#000000'], lines: ['#5cf2c2', '#2f8fff'] }, + floatingLines: { + lineMode: 'glow', + gradientStops: '#5cf2c2\n#2f8fff\n#0a0a12', + bgCenter: '#04121a', + bgEdge: '#000000', + backgroundImage: '', + speed: 0.6, + spread: 0.09, + waveFrequency: 4, + bezierCurvature: 0.3, + glowSize: 24, + glowStrength: 2, + horizonMode: 'fog', + horizonOpacity: 0.45, + labelColor: '#ffffff' + } + }, + { + id: 'builtin:dusk', + name: 'Abendrot', + description: 'Warmer Horizont unten, Nacht oben, Linien in Orange und Rosé.', + preview: { ground: ['#3a1a2b', '#120711'], lines: ['#ffb86b', '#ff5e7e'] }, + floatingLines: { + lineMode: 'glow', + gradientStops: '#ffb86b\n#ff5e7e\n#2a0f1f', + bgCenter: '#3a1a2b', + bgEdge: '#120711', + backgroundImage: '', + speed: 0.8, + glowSize: 20, + glowStrength: 1.8, + horizonMode: 'split', + horizonBlend: 0.6, + labelColor: '#f7f3ea' + } + }, + { + id: 'builtin:milkyway', + name: 'Milchstraße', + description: 'Sternenhimmel mit feinen, kühlen Linien.', + preview: { ground: ['#1b1a24', '#050408'], lines: ['#ffffff', '#9fb4ff'] }, + floatingLines: { + lineMode: 'glow', + gradientStops: '#ffffff\n#9fb4ff\n#1a1f3a', + backgroundImage: '/images/bg-image-1.jpg', + bgCenter: '#1b1a24', + bgEdge: '#050408', + lineBrightness: 0.75, + lineThickness: 0.8, + glowSize: 16, + glowStrength: 1.4, + horizonMode: 'off', + labelColor: '#ffffff' + } + }, + { + id: 'builtin:silk', + name: 'Seide', + description: 'Schwarzer Stoff, darauf klare goldene Linien.', + preview: { ground: ['#2a2a2a', '#0b0b0b'], lines: ['#f5c26b', '#f5c26b'] }, + floatingLines: { + lineMode: 'static', + staticLineColor: '#f5c26b', + staticLineShadowStrength: 0.35, + lineThickness: 1.1, + lineCount: 8, + backgroundImage: '/images/bg-image-2.jpg', + bgCenter: '#2a2a2a', + bgEdge: '#0b0b0b', + glowSize: 14, + glowStrength: 1.2, + horizonMode: 'off', + labelColor: '#f7f3ea' + } + }, + { + id: 'builtin:rain', + name: 'Regen', + description: 'Tropfen auf dunklem Glas, Linien in Türkis.', + preview: { ground: ['#1e2224', '#0a0c0d'], lines: ['#7fe3ff', '#1fa2c8'] }, + floatingLines: { + lineMode: 'glow', + gradientStops: '#7fe3ff\n#1fa2c8\n#0a1a22', + backgroundImage: '/images/bg-image-3.jpg', + bgCenter: '#1e2224', + bgEdge: '#0a0c0d', + speed: 1.2, + glowSize: 22, + glowStrength: 1.8, + horizonMode: 'off', + labelColor: '#ffffff' + } + }, + { + id: 'builtin:leaves', + name: 'Blattwerk', + description: 'Dunkles Grün mit Gold, wie Sonne durch Blätter.', + preview: { ground: ['#12301a', '#05110a'], lines: ['#f5c26b', '#8bc34a'] }, + floatingLines: { + lineMode: 'glow', + gradientStops: '#f5c26b\n#8bc34a\n#0d1a0c', + backgroundImage: '/images/bg-image-4.jpg', + bgCenter: '#12301a', + bgEdge: '#05110a', + speed: 0.7, + glowSize: 18, + glowStrength: 1.5, + lineBrightness: 0.85, + horizonMode: 'off', + labelColor: '#f7f3ea' + } + }, + { + id: 'builtin:moon', + name: 'Mondsichel', + description: 'Fast schwarz, wenige dünne helle Linien. Reduziert.', + preview: { ground: ['#161616', '#000000'], lines: ['#e8e8e8', '#8a8a8a'] }, + floatingLines: { + lineMode: 'static', + staticLineColor: '#e8e8e8', + staticLineShadowStrength: 0, + lineCount: 5, + lineThickness: 0.6, + spread: 0.035, + speed: 0.5, + backgroundImage: '/images/bg-image-8.jpg', + bgCenter: '#161616', + bgEdge: '#000000', + glowSize: 10, + glowStrength: 1, + horizonMode: 'off', + labelColor: '#f7f3ea', + labelOpacity: 0.9 + } + }, + { + id: 'builtin:neon', + name: 'Farbrausch', + description: 'Magenta und Blau, Punkte mit weißem Rand. Laut und fröhlich.', + preview: { ground: ['#ff4fd8', '#2b3cff'], lines: ['#ffffff', '#ff6ad5'] }, + floatingLines: { + lineMode: 'glow', + gradientStops: '#ffffff\n#ff6ad5\n#2b0a3a', + backgroundImage: '/images/bg-image-6.jpg', + bgCenter: '#3a0a5a', + bgEdge: '#0d0620', + speed: 1.3, + waveFrequency: 8, + glowSize: 20, + glowStrength: 2.2, + horizonMode: 'off', + labelColor: '#ffffff', + dotBorderWidth: 2, + dotBorderColor: '#ffffff' + } + } +] + +export function findWavePreset(id) { + return WAVE_PRESETS.find(preset => preset.id === id) ?? null +} + +export function isBuiltInPresetId(id) { + return typeof id === 'string' && id.startsWith('builtin:') +} + +/** CSS for a preset tile: ground gradient with the line colours as a band. */ +export function presetPreviewStyle(preset) { + const [g1, g2] = preset.preview.ground + const [l1, l2] = preset.preview.lines + return { + background: [ + `linear-gradient(100deg, transparent 38%, ${l1} 46%, ${l2} 54%, transparent 62%)`, + `radial-gradient(circle at 50% 40%, ${g1}, ${g2})` + ].join(', ') + } +} diff --git a/frontend/src/css/app.scss b/frontend/src/css/app.scss index a8b507c..7a29a7f 100644 --- a/frontend/src/css/app.scss +++ b/frontend/src/css/app.scss @@ -1,9 +1,21 @@ :root { - --tm-accent: #737373; - --tm-accent-rgb: 115, 115, 115; - --q-primary: #737373; - --q-secondary: #737373; - --q-accent: #737373; + // Brand palette (from the logo): gold accent, ink for text on light + // ground, paper for text on dark ground. + --tm-gold: #f5c26b; + --tm-gold-dark: #d59040; + --tm-gold-rgb: 245, 194, 107; + --tm-gold-gradient: linear-gradient(135deg, #f5c26b, #d59040); + --tm-ink: #292a2b; + --tm-ink-rgb: 41, 42, 43; + --tm-paper: #f7f3ea; + --tm-paper-rgb: 247, 243, 234; + + // Selected accent; the settings store overrides these at runtime. + --tm-accent: #f5c26b; + --tm-accent-rgb: 245, 194, 107; + --q-primary: #f5c26b; + --q-secondary: #d59040; + --q-accent: #f5c26b; } // Glass button style @@ -25,16 +37,16 @@ // Glass panel style — strong blur for slide-up panels .glass--panel { - background: rgba(255, 255, 255, 0.7); + background: rgba(var(--tm-paper-rgb), 0.78); border-top: 1px solid rgba(var(--tm-accent-rgb), 0.24); backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px); - color: #1a1a1a; + color: var(--tm-ink); .body--dark & { - background: rgba(30, 30, 30, 0.7); - border-top-color: rgba(255, 255, 255, 0.08); - color: #f5f5f5; + background: rgba(var(--tm-ink-rgb), 0.74); + border-top-color: rgba(var(--tm-paper-rgb), 0.1); + color: var(--tm-paper); } } diff --git a/frontend/src/db/index.js b/frontend/src/db/index.js index 3975f0e..43479b8 100644 --- a/frontend/src/db/index.js +++ b/frontend/src/db/index.js @@ -31,3 +31,17 @@ db.version(3).stores({ eventMedia: 'id, eventId, userId, createdAt', meta: 'key' }) + +db.version(4).stores({ + events: 'id, userId, [userId+date], updatedAt, syncStatus', + syncQueue: '++queueId, userId, eventId, action, createdAt', + // The image cache was keyed by URL alone, so a cached image stayed + // readable after a user switch and survived logout. The key now carries + // the API origin, the owning user and the variant. The primary key + // changes, so the old table is dropped — it is a pure cache and simply + // refills on the next load. + imageCache: null, + scopedImageCache: 'key, userId, eventId, type, cachedAt', + eventMedia: 'id, eventId, userId, createdAt', + meta: 'key' +}) diff --git a/frontend/src/layouts/LifeWaveLayout.vue b/frontend/src/layouts/LifeWaveLayout.vue index 6cc8e3f..824812c 100644 --- a/frontend/src/layouts/LifeWaveLayout.vue +++ b/frontend/src/layouts/LifeWaveLayout.vue @@ -45,12 +45,48 @@
- - +
+ + +
+ + Events werden geladen … +
+ +
+
{{ floatingLinesRef?.fpsDisplay ?? 0 }} FPS @@ -62,29 +98,57 @@ - - + +
+
+ + +
- - + - - + +
+ + + + + @@ -155,6 +219,8 @@ import EventPanel from 'components/EventPanel.vue' import FloatingLines from 'components/FloatingLines.vue' import LifeWaveSettings from 'components/LifeWaveSettings.vue' import TimelineView from 'components/TimelineView.vue' +import TimelineNav from 'components/TimelineNav.vue' +import BrandLogo from 'components/BrandLogo.vue' import AppSettingsModal from 'components/AppSettingsModal.vue' import UserMenu from 'components/UserMenu.vue' import ZoomControl from 'components/ZoomControl.vue' @@ -162,6 +228,8 @@ import { useAuthStore } from 'stores/auth' import { useEventsStore } from 'stores/events' import { useSettingsStore } from 'stores/settings' import { resolveFullRes } from 'composables/useImageCache' +import { useStoreFeedback } from 'composables/useStoreFeedback' +import { TIMELINE_TOP_PX, emotionToYPercent } from 'src/utils/timelinePosition' const $q = useQuasar() const router = useRouter() @@ -170,6 +238,7 @@ const eventsStore = useEventsStore() const settingsStore = useSettingsStore() const isDark = computed(() => $q.dark.isActive) const settingsOpen = ref(false) +const zoomOpen = ref(false) const userMenuOpen = ref(false) const appSettingsOpen = ref(false) const floatingLinesRef = ref(null) @@ -236,7 +305,46 @@ function onWindowResize() { hardResizeTimer = setTimeout(runHardResizeSync, 120) } +// Escape closes whatever is on top, one layer at a time. Quasar dialogs +// (the delete confirmations) handle Escape themselves; while one is open +// the key must not also close the panel underneath. +function onKeydown(event) { + if (event.key !== 'Escape' || event.defaultPrevented) return + if (document.querySelector('.q-dialog')) return + + if (appSettingsOpen.value) { + appSettingsOpen.value = false + } else if (userMenuOpen.value) { + userMenuOpen.value = false + } else if (eventsStore.panelOpen) { + eventsStore.closePanel() + } else if (settingsOpen.value) { + settingsOpen.value = false + } else if (zoomOpen.value) { + zoomOpen.value = false + } else { + return + } + event.preventDefault() +} + +// A new build was installed by the service worker. Offered, not forced: a +// reload in the middle of an edit would lose the draft. +function onAppUpdated() { + $q.notify({ + type: 'info', + message: 'Eine neue Version der App ist bereit.', + timeout: 0, + actions: [ + { label: 'Neu laden', color: 'white', handler: () => window.location.reload() }, + { label: 'Später', color: 'white', handler: () => {} } + ] + }) +} + onMounted(() => { + window.addEventListener('keydown', onKeydown) + window.addEventListener('tm:app-updated', onAppUpdated) if (layoutRef.value) { layoutWidth.value = layoutRef.value.clientWidth layoutHeight.value = layoutRef.value.clientHeight @@ -254,6 +362,8 @@ onMounted(() => { }) onBeforeUnmount(() => { + window.removeEventListener('keydown', onKeydown) + window.removeEventListener('tm:app-updated', onAppUpdated) window.removeEventListener('resize', onWindowResize) if (hardResizeTimer) clearTimeout(hardResizeTimer) if (hardResizeRaf) cancelAnimationFrame(hardResizeRaf) @@ -282,7 +392,7 @@ function screenToUV(sx, sy) { } // Compute shader point positions from event positions -const TIMELINE_TOP = 40 // CSS: .timeline-container { top: 40px } +const TIMELINE_TOP = TIMELINE_TOP_PX // Select up to 16 points for shader lines (shader uniform limit). // TimelineView already emits a small contiguous window around the viewport. @@ -317,7 +427,7 @@ const shaderPointY = computed(() => { const sel = shaderSelection.value const tlHeight = timelineState.value.containerHeight for (let i = 0; i < sel.length; i++) { - const yPercent = 48 - sel[i].emotion * 30 + const yPercent = emotionToYPercent(sel[i].emotion) const screenY = TIMELINE_TOP + (yPercent / 100) * tlHeight ys[i] = screenToUV(0, screenY).y } @@ -336,6 +446,26 @@ const parsedGradient = computed(() => { .filter(s => s.length > 0 && s.startsWith('#')) }) +// Position and year navigation, read from TimelineView +const activePosition = computed(() => timelineViewRef.value?.activePosition ?? null) +const timelineYears = computed(() => timelineViewRef.value?.years ?? []) +const activeYearIndex = computed(() => timelineYears.value.indexOf(activePosition.value?.year)) +const canGoPrevYear = computed(() => activeYearIndex.value > 0) +const canGoNextYear = computed(() => + activeYearIndex.value !== -1 && activeYearIndex.value < timelineYears.value.length - 1 +) + +function goToYear(index) { + const year = timelineYears.value[index] + if (year !== undefined) timelineViewRef.value?.scrollToYearCenter(year) +} + +// The zoom slider is a transient control; the panel opening or the wave +// settings sliding up hide it again. +watch([() => eventsStore.panelOpen, settingsOpen], ([panelOpen, wave]) => { + if (panelOpen || wave) zoomOpen.value = false +}) + // Zoom state from TimelineView const currentZoom = computed(() => timelineViewRef.value?.zoomLevel ?? 1) const zoomMin = computed(() => timelineViewRef.value?.MIN_ZOOM ?? 0.4) @@ -347,9 +477,11 @@ function onZoomTo(value) { timelineViewRef.value.zoomTo?.(clamped) } -const toggleDarkMode = () => { - $q.dark.toggle() -} +// Empty state only when the account really has no events and nothing is +// being created right now. +const showEmptyState = computed(() => + eventsStore.isLoaded && eventsStore.events.length === 0 && !eventsStore.panelOpen +) const onAddEvent = () => { eventsStore.openPanel() @@ -364,11 +496,32 @@ const onUserMenuNavigate = (target) => { userMenuOpen.value = false if (target === 'settings') { appSettingsOpen.value = true + } else if (target === 'appearance') { + // The wave settings sit behind the event panel; close it first so the + // choice is not swallowed. + if (eventsStore.panelOpen) eventsStore.closePanel() + settingsOpen.value = true } else if (target === 'logout') { authStore.logout() router.replace('/login') } } + +// Connection state for remote users. Silent while everything is sent. +const syncLabel = computed(() => { + if (!eventsStore.isRemoteUser) return '' + const count = eventsStore.pendingSyncCount + if (!eventsStore.isOnline) return count > 0 ? `Offline · ${count} ausstehend` : 'Offline' + if (eventsStore.isSyncing) return 'Wird übertragen …' + if (count > 0) return `${count} ausstehend` + return '' +}) +const syncTitle = computed(() => (eventsStore.isOnline + ? 'Änderungen jetzt übertragen' + : 'Keine Verbindung. Änderungen werden lokal gespeichert und später übertragen.')) + +// Error notifications and the redirect once the session has ended. +useStoreFeedback() diff --git a/frontend/src/legacy/README.md b/frontend/src/legacy/README.md deleted file mode 100644 index 2717455..0000000 --- a/frontend/src/legacy/README.md +++ /dev/null @@ -1,5 +0,0 @@ -Legacy frontend screens archived on 2026-04-30. - -These files are not mounted by `frontend/src/router/routes.js`. They are kept here -as reference while the active LifeWave experience is consolidated around events, -the timeline, and Floating Lines. diff --git a/frontend/src/legacy/pages/CategorySelector.vue b/frontend/src/legacy/pages/CategorySelector.vue deleted file mode 100644 index c03e179..0000000 --- a/frontend/src/legacy/pages/CategorySelector.vue +++ /dev/null @@ -1,369 +0,0 @@ - - - - - \ No newline at end of file diff --git a/frontend/src/legacy/pages/EditPage.vue b/frontend/src/legacy/pages/EditPage.vue deleted file mode 100644 index 03af6ca..0000000 --- a/frontend/src/legacy/pages/EditPage.vue +++ /dev/null @@ -1,2037 +0,0 @@ - - - - - \ No newline at end of file diff --git a/frontend/src/legacy/pages/EntryDetailPage.vue b/frontend/src/legacy/pages/EntryDetailPage.vue deleted file mode 100644 index db1f822..0000000 --- a/frontend/src/legacy/pages/EntryDetailPage.vue +++ /dev/null @@ -1,1224 +0,0 @@ - - - - - \ No newline at end of file diff --git a/frontend/src/legacy/pages/PersonSelector.vue b/frontend/src/legacy/pages/PersonSelector.vue deleted file mode 100644 index 2254e7f..0000000 --- a/frontend/src/legacy/pages/PersonSelector.vue +++ /dev/null @@ -1,423 +0,0 @@ - - - - - \ No newline at end of file diff --git a/frontend/src/legacy/pages/TagSelector.vue b/frontend/src/legacy/pages/TagSelector.vue deleted file mode 100644 index e703d0d..0000000 --- a/frontend/src/legacy/pages/TagSelector.vue +++ /dev/null @@ -1,445 +0,0 @@ - - - - - \ No newline at end of file diff --git a/frontend/src/legacy/pages/WavePage.vue b/frontend/src/legacy/pages/WavePage.vue deleted file mode 100644 index e125986..0000000 --- a/frontend/src/legacy/pages/WavePage.vue +++ /dev/null @@ -1,488 +0,0 @@ - - - - - \ No newline at end of file diff --git a/frontend/src/legacy/utils/ConnectedDotsVisualization.ts b/frontend/src/legacy/utils/ConnectedDotsVisualization.ts deleted file mode 100644 index eba1518..0000000 --- a/frontend/src/legacy/utils/ConnectedDotsVisualization.ts +++ /dev/null @@ -1,550 +0,0 @@ -// Define interfaces -export interface DotConfig { - id: number; - value: number; - x: number; - link?: string; // URL to navigate to when dot is clicked - onClick?: () => void; // Function to call when dot is clicked - imageUrl?: string; // Image to display in tooltip - title?: string; // Optional title for the tooltip - description?: string; // Optional description for the tooltip -} -export interface Config { - totalWidth: number; - height: number; - dotRadius: number; - xUnitSize: number; - tension: number; - showGrid: boolean; - tooltipWidth: number; - tooltipHeight: number; -} -interface ControlPoints { - x1: number; - y1: number; - x2: number; - y2: number; -} -interface TooltipEdges { - leftmost: number; - rightmost: number; -} -export class ConnectedDotsVisualization { - private config: Config; - private dots: DotConfig[]; - private preloadedImages: Map = new Map(); - // DOM Elements - private scrollContainer: HTMLElement; - private svg: SVGElement; - private gridGroup: SVGGElement; - private curvePath: SVGPathElement; - private dotsGroup: SVGGElement; - private tooltipGroup: SVGGElement; - // Active tooltip - private activeTooltip: SVGElement | null = null; - constructor( - containerId: string, - dots: DotConfig[], - config?: Partial - ) { - // Use the provided dots or empty array - this.dots = dots || []; - // Calculate the total width based on dots data - const xUnitSize = config?.xUnitSize || 100; - let calculatedWidth = 0; - if (this.dots.length > 0) { - // Find the minimum and maximum x values - const minX = Math.min(...this.dots.map((dot) => dot.x)); - const maxX = Math.max(...this.dots.map((dot) => dot.x)); - // Calculate width based on the range of x values - // Add padding on both sides (3 units on each side) - calculatedWidth = (maxX - minX + 6) * xUnitSize; - } else { - calculatedWidth = 6 * xUnitSize; // Default width if no dots - } - // Default configuration - this.config = { - totalWidth: calculatedWidth, - height: window.innerHeight, - dotRadius: 6, - xUnitSize: xUnitSize, - tension: 0.5, - showGrid: false, - tooltipWidth: 128, - tooltipHeight: 128, - ...config, - }; - // Initialize DOM elements - this.scrollContainer = document.getElementById(containerId) as HTMLElement; - - // Calculate the container height dynamically - const containerHeight = - this.scrollContainer.clientHeight || - this.scrollContainer.offsetHeight || - window.innerHeight; - - // Default configuration - this.config = { - totalWidth: calculatedWidth, - height: containerHeight, // Use the calculated container height - dotRadius: 6, - xUnitSize: xUnitSize, - tension: 0.5, - showGrid: false, - tooltipWidth: 128, - tooltipHeight: 128, - ...config, - }; - - // Create SVG elements - this.svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); - this.gridGroup = document.createElementNS( - "http://www.w3.org/2000/svg", - "g" - ); - this.curvePath = document.createElementNS( - "http://www.w3.org/2000/svg", - "path" - ); - this.dotsGroup = document.createElementNS( - "http://www.w3.org/2000/svg", - "g" - ); - this.tooltipGroup = document.createElementNS( - "http://www.w3.org/2000/svg", - "g" - ); - // Initialize the visualization - this.addStyles(); - this.initializeSVG(); - this.setupEventListeners(); - this.preloadImages(); - this.render(); - } - private preloadImages(): void { - // Extract all unique image URLs from dots - const imageUrls: string[] = this.dots - .filter((dot) => dot.imageUrl) - // biome-ignore lint/style/noNonNullAssertion: - .map((dot) => dot.imageUrl!) - .filter((url, index, self) => self.indexOf(url) === index); // Remove duplicates - // Create a loading indicator (optional) - const loadingCount = { current: 0, total: imageUrls.length }; - if (imageUrls.length > 0) { - console.log(`Preloading ${imageUrls.length} images...`); - } - // Preload each image - for (const url of imageUrls) { - const img = new Image(); - // Optional loading events - img.onload = () => { - loadingCount.current++; - if (loadingCount.current === loadingCount.total) { - console.log("All images preloaded successfully"); - } - }; - img.onerror = () => { - loadingCount.current++; - console.error(`Failed to preload image: ${url}`); - }; - // Set src to start loading - img.src = url; - // Store in map for potential later use - this.preloadedImages.set(url, img); - } - } - private addStyles(): void { - // Add necessary styles for tooltips and interactions - const styleId = "connected-dots-styles"; - if (!document.getElementById(styleId)) { - const style = document.createElement("style"); - style.id = styleId; - // style.textContent = ` - - // `; - document.head.appendChild(style); - } - } - private initializeSVG(): void { - // Configure SVG - this.svg.setAttribute("width", `${this.config.totalWidth}`); - this.svg.setAttribute("height", `${this.config.height}`); - this.svg.style.overflow = "visible"; - this.scrollContainer.appendChild(this.svg); - // Configure grid group - this.gridGroup.classList.add("grid"); - this.svg.appendChild(this.gridGroup); - // Configure curve path - this.curvePath.setAttribute("fill", "none"); - this.curvePath.setAttribute("stroke", "white"); - this.curvePath.setAttribute("stroke-width", "2"); - this.curvePath.setAttribute("stroke-linecap", "round"); - this.curvePath.classList.add("curve-path"); - this.svg.appendChild(this.curvePath); - // Configure dots group - this.svg.appendChild(this.dotsGroup); - // Configure tooltip group (always on top) - this.tooltipGroup.classList.add("tooltips"); - this.svg.appendChild(this.tooltipGroup); - } - private setupEventListeners(): void { - // Event listeners removed as the controls were removed - } - private getDotX(x: number): number { - return (x + 3) * this.config.xUnitSize; - } - private getDotY(value: number): number { - const centerY = this.config.height / 1.95; - // Calculate raw Y position - // height of the amplitude - const rawY = centerY - (value / 3) * ((this.config.height / 2) * 0.6); - // Calculate minimum Y position to ensure tooltip fits - const minY = this.config.tooltipHeight + 40; // tooltip height + some padding - // Ensure Y is never less than minimum (never too high on screen) - return Math.max(rawY, minY); - } - private calculateBezierControlPoints( - dots: DotConfig[], - index: number - ): ControlPoints { - const tension = this.config.tension * 500; // Scale tension for Bezier curve Rundung Kurve - // Get current point and its neighbors - const curr = dots[index]; - const next = dots[index + 1]; - // Calculate control points for a smooth bezier curve - const x1 = this.getDotX(curr.x) + tension; - const y1 = this.getDotY(curr.value); - const x2 = this.getDotX(next.x) - tension; - const y2 = this.getDotY(next.value); - return { x1, y1, x2, y2 }; - } - private generateBezierPath(): string { - if (this.dots.length < 2) return ""; - let path = `M ${this.getDotX(this.dots[0].x)} ${this.getDotY( - this.dots[0].value - )}`; - for (let i = 0; i < this.dots.length - 1; i++) { - const { x1, y1, x2, y2 } = this.calculateBezierControlPoints( - this.dots, - i - ); - const nextX = this.getDotX(this.dots[i + 1].x); - const nextY = this.getDotY(this.dots[i + 1].value); - path += ` C ${x1} ${y1}, ${x2} ${y2}, ${nextX} ${nextY}`; - } - return path; - } - private drawGrid(): void { - // Clear previous grid - while (this.gridGroup.firstChild) { - this.gridGroup.removeChild(this.gridGroup.firstChild); - } - if (!this.config.showGrid) return; - // Horizontal grid lines - for (const value of [-3, -2, -1, 0, 1, 2, 3]) { - const line = document.createElementNS( - "http://www.w3.org/2000/svg", - "line" - ); - line.setAttribute("x1", "0"); - line.setAttribute("y1", this.getDotY(value).toString()); - line.setAttribute("x2", this.config.totalWidth.toString()); - line.setAttribute("y2", this.getDotY(value).toString()); - line.setAttribute("stroke", "rgba(219, 39, 119, 0.4)"); - line.setAttribute("stroke-width", "1"); - this.gridGroup.appendChild(line); - const text = document.createElementNS( - "http://www.w3.org/2000/svg", - "text" - ); - text.setAttribute("x", "10"); - text.setAttribute("y", (this.getDotY(value) + 4).toString()); - text.setAttribute("fill", "rgba(219, 39, 119, 0.8)"); - text.setAttribute("font-size", "12"); - text.textContent = value.toString(); - this.gridGroup.appendChild(text); - } - // Vertical grid lines - const numVertLines = Math.ceil( - this.config.totalWidth / this.config.xUnitSize - ); - for (let i = 0; i < numVertLines; i++) { - const x = i * this.config.xUnitSize; - const xValue = i - 3; // Starting from -3 - const line = document.createElementNS( - "http://www.w3.org/2000/svg", - "line" - ); - line.setAttribute("x1", x.toString()); - line.setAttribute("y1", "0"); - line.setAttribute("x2", x.toString()); - line.setAttribute("y2", this.config.height.toString()); - line.setAttribute("stroke", "rgba(219, 39, 119, 0.4)"); - line.setAttribute("stroke-width", "1"); - this.gridGroup.appendChild(line); - if (xValue !== 0) { - const text = document.createElementNS( - "http://www.w3.org/2000/svg", - "text" - ); - text.setAttribute("x", x.toString()); - text.setAttribute("y", (this.config.height / 2 + 20).toString()); - text.setAttribute("fill", "rgba(219, 39, 119, 0.8)"); - text.setAttribute("font-size", "12"); - text.setAttribute("text-anchor", "middle"); - text.textContent = xValue.toString(); - this.gridGroup.appendChild(text); - } - } - } - - private createTooltip(dot: DotConfig, x: number, y: number): SVGElement { - const tooltip = document.createElementNS("http://www.w3.org/2000/svg", "g"); - tooltip.classList.add("dot-tooltip"); - tooltip.setAttribute("data-dot-id", dot.id.toString()); - - // Calculate tooltip dimensions and position - const tooltipWidth = 128; // Base width for your tooltip - const tooltipHeight = (4 / 3) * tooltipWidth; - const tooltipX = x - tooltipWidth / 2; - let tooltipY = y - tooltipHeight - 10; // Positioned above the dot - tooltipY = Math.max(tooltipY, 10); // Ensure it doesn't go above the view - - // Create background rectangle - const bg = document.createElementNS("http://www.w3.org/2000/svg", "rect"); - bg.setAttribute("x", tooltipX.toString()); - bg.setAttribute("y", tooltipY.toString()); - bg.setAttribute("width", tooltipWidth.toString()); - bg.setAttribute("height", tooltipHeight.toString()); - bg.setAttribute("rx", "0"); // Rounded corners - bg.classList.add("tooltip-background"); - tooltip.appendChild(bg); - - // Create foreignObject for the content - const contentContainer = document.createElementNS( - "http://www.w3.org/2000/svg", - "foreignObject" - ); - contentContainer.setAttribute("x", tooltipX.toString()); - contentContainer.setAttribute("y", tooltipY.toString()); - contentContainer.setAttribute("width", tooltipWidth.toString()); - contentContainer.setAttribute("height", tooltipHeight.toString()); - - // Create a div to contain the content - const div = document.createElement("div"); - div.classList.add("tooltip-content"); - - // Add title if available - if (dot.title) { - const title = document.createElement("div"); - title.textContent = dot.title; - title.classList.add("tooltip-title"); - div.appendChild(title); - } - - // Add description if available - if (dot.description) { - const desc = document.createElement("div"); - desc.textContent = dot.description; - desc.classList.add("tooltip-description"); - div.appendChild(desc); - } - - // Add image if available - // Create a container div - const imageContainer = document.createElement("div"); - imageContainer.classList.add("image_container"); // Add image_container class - - // Define a variable for handling case with or without link - let imgWrapper: HTMLElement; - - // if (dot.imageUrl) { - if (dot.link || dot.onClick) { - const link = document.createElement("a"); - if (dot.link) { - link.href = dot.link; - } else { - link.href = "#"; // Prevent default href for onClick - } - link.target = "_self"; // Opens in the same window - - const imgElement = document.createElement("img"); - imgElement.src = dot.imageUrl; - imgElement.classList.add("tooltip-image"); - - // Append the image element to the link - link.appendChild(imgElement); - imgWrapper = link; // Use the link as the wrapper - - // Add the event listener to the link - link.addEventListener("click", (e) => { - if (dot.onClick) { - e.preventDefault(); // Prevent default navigation - dot.onClick(); - } else if (dot.link) { - window.location.href = dot.link; - } else { - console.error("Dot has no link or onClick handler"); - throw new Error("Dot has no link or onClick handler"); - } - }); - } else { - const img = document.createElement("img"); - img.src = dot.imageUrl; - img.classList.add("tooltip-image"); - imgWrapper = img; // Use the image directly as the wrapper - } - // } else { - // console.error("Dot has no image URL"); - // throw new Error("Dot has no image URL"); - // } - - // Append imageWrapper to the container - imageContainer.appendChild(imgWrapper); - - - // Append the image container to the main div - div.appendChild(imageContainer); - - const arrow = document.createElement("div"); - - arrow.classList.add("tooltip-arrow"); - - div.appendChild(arrow); // Append the arrow to the tooltip-content div - - contentContainer.appendChild(div); - tooltip.appendChild(contentContainer); - - return tooltip; - } - - private showTooltip(dot: DotConfig, x: number, y: number): void { - // Create tooltip - const tooltip = this.createTooltip(dot, x, y); - this.tooltipGroup.appendChild(tooltip); - this.activeTooltip = tooltip; - } - private hideTooltip(): void { - // This method is kept for compatibility but doesn't hide tooltips anymore - } - private drawCurve(): void { - const pathData = this.generateBezierPath(); - this.curvePath.setAttribute("d", pathData); - } - private calculateTooltipEdges(): TooltipEdges { - let leftmost = 0; - let rightmost = 0; - let firstTooltipFound = false; - // If no dots with tooltips, return default values - if (this.dots.length === 0) { - return { leftmost: 0, rightmost: this.config.totalWidth }; - } - // Calculate the leftmost and rightmost edges of all tooltips - for (const dot of this.dots) { - // Skip dots without tooltip content - if (!dot.imageUrl && !dot.title && !dot.description) { - continue; - } - const x = this.getDotX(dot.x); - const tooltipWidth = this.config.tooltipWidth; - const tooltipX = x - tooltipWidth / 2; - if (!firstTooltipFound) { - leftmost = tooltipX; - rightmost = tooltipX + tooltipWidth; - firstTooltipFound = true; - } else { - // Update leftmost and rightmost values - leftmost = Math.min(leftmost, tooltipX); - rightmost = Math.max(rightmost, tooltipX + tooltipWidth); - } - } - // If no dots with tooltips were found, use default values - if (!firstTooltipFound) { - return { leftmost: 0, rightmost: this.config.totalWidth }; - } - return { leftmost, rightmost }; - } - private drawDots(): void { - // Clear previous dots - while (this.dotsGroup.firstChild) { - this.dotsGroup.removeChild(this.dotsGroup.firstChild); - } - // Clear previous tooltips - while (this.tooltipGroup.firstChild) { - this.tooltipGroup.removeChild(this.tooltipGroup.firstChild); - } - for (const dot of this.dots) { - const x = this.getDotX(dot.x); - const y = this.getDotY(dot.value); - const circle = document.createElementNS( - "http://www.w3.org/2000/svg", - "circle" - ); - circle.setAttribute("cx", x.toString()); - circle.setAttribute("cy", y.toString()); - circle.setAttribute("r", this.config.dotRadius.toString()); - circle.setAttribute("fill", "white"); - circle.setAttribute("data-dot-id", dot.id.toString()); - circle.classList.add("dot"); - // Always show tooltip if it has content - if (dot.imageUrl || dot.title || dot.description) { - this.showTooltip(dot, x, y); - } - // Click event for navigation or custom function - if (dot.link || dot.onClick) { - circle.addEventListener("click", () => { - if (dot.onClick) { - dot.onClick(); - } else if (dot.link) { - window.location.href = dot.link; - } else { - console.error("Dot has no link or onClick handler"); - throw new Error("Dot has no link or onClick handler"); - } - }); - } - this.dotsGroup.appendChild(circle); - } - } - public render(): void { - this.drawGrid(); - this.drawCurve(); - this.drawDots(); - // Calculate tooltip edges and set SVG width - const { leftmost, rightmost } = this.calculateTooltipEdges(); - // Set the SVG width based on the rightmost tooltip edge - if (rightmost > 0) { - // Add some padding - const padding = 40; - this.config.totalWidth = rightmost + padding; - this.svg.setAttribute("width", `${this.config.totalWidth}`); - // Update grid width - this.drawGrid(); - } - } - // Public API methods for external use - public updateDots(newDots: DotConfig[]): void { - this.dots = newDots; - // Initial width calculation based on dot positions (for grid) - if (this.dots.length > 0) { - // Find the minimum and maximum x values - const minX = Math.min(...this.dots.map((dot) => dot.x)); - const maxX = Math.max(...this.dots.map((dot) => dot.x)); - // Calculate width based on the range of x values - // Add padding on both sides (3 units on each side) - this.config.totalWidth = (maxX - minX + 6) * this.config.xUnitSize; - } - // Render will calculate the tooltip edges and update the SVG width - this.render(); -} - public updateConfig(newConfig: Partial): void { - this.config = { ...this.config, ...newConfig }; - this.render(); - } - public resize(): void { - const containerHeight = this.scrollContainer.clientHeight || this.scrollContainer.offsetHeight || window.innerHeight; - this.config.height = containerHeight; - this.svg.setAttribute("height", `${this.config.height}`); - this.render(); - } -} diff --git a/frontend/src/legacy/utils/editFormOptions.js b/frontend/src/legacy/utils/editFormOptions.js deleted file mode 100644 index 0679b42..0000000 --- a/frontend/src/legacy/utils/editFormOptions.js +++ /dev/null @@ -1,238 +0,0 @@ -// Form options and data for EditPage component - -// Main categories with their subcategories -export const categoryStructure = [ - { - label: 'Career', - value: 'career', - subcategories: [ - { label: 'Promotion', value: 'career-promotion' }, - { label: 'Retirement', value: 'career-retirement' }, - { label: 'Career Changes', value: 'career-changes' } - ] - }, - { - label: 'Education', - value: 'education', - subcategories: [ - { label: 'Graduation', value: 'education-graduation' }, - { label: 'Schooling', value: 'education-schooling' } - ] - }, - { - label: 'Awards', - value: 'awards', - subcategories: [] - }, - { - label: 'Personal Celebrations', - value: 'personal-celebrations', - subcategories: [ - { label: 'Birthday', value: 'birthday' }, - { label: 'Anniversary', value: 'anniversary' } - ] - }, - { - label: 'Relationships', - value: 'relationships', - subcategories: [ - { label: 'Engagement', value: 'relationships-engagement' }, - { label: 'Marriage', value: 'relationships-marriage' }, - { label: 'Divorce', value: 'relationships-divorce' } - ] - }, - { - label: 'Parenthood', - value: 'parenthood', - subcategories: [ - { label: 'Pregnancy', value: 'parenthood-pregnancy' }, - { label: 'Birth', value: 'parenthood-birth' }, - { label: 'Adoption', value: 'parenthood-adoption' } - ] - }, - { - label: 'Loss & Passing', - value: 'passing', - subcategories: [ - { label: 'Funeral', value: 'passing-funeral' } - ] - }, - { - label: 'Festivities', - value: 'festivities', - subcategories: [ - { label: 'Christmas', value: 'festivities-christmas' }, - { label: 'Thanksgiving', value: 'festivities-thanksgiving' }, - { label: 'New Year', value: 'festivities-new-year' }, - { label: 'Easter', value: 'festivities-easter' }, - { label: 'Holidays', value: 'festivities-holidays' } - ] - }, - { - label: 'Social Events', - value: 'social-events', - subcategories: [ - { label: 'Reunions', value: 'reunions' }, - { label: 'Concerts', value: 'concerts' }, - { label: 'Sports', value: 'sports' }, - { label: 'Festivals', value: 'festivals' } - ] - }, - { - label: 'Community', - value: 'community', - subcategories: [ - { label: 'Charity', value: 'charity' }, - { label: 'Community Service', value: 'community-service' } - ] - }, - { - label: 'Health', - value: 'health', - subcategories: [ - { label: 'Surgery', value: 'health-surgery' }, - { label: 'Illness', value: 'health-illness' }, - { label: 'Recovery', value: 'health-recovery' }, - { label: 'Transplants', value: 'health-transplants' }, - { label: 'Mental Health', value: 'health-mental-health' } - ] - }, - { - label: 'Religious & Spiritual', - value: 'religious', - subcategories: [ - { label: 'Baptism', value: 'religious-baptism' }, - { label: 'Bar/Bat Mitzvah', value: 'religious-bar-bat-mitzvah' }, - { label: 'Communion', value: 'religious-communion' }, - { label: 'Confirmation', value: 'religious-confirmation' }, - { label: 'Pilgrimage', value: 'religious-pilgrimage' } - ] - }, - { - label: 'Travel & Adventure', - value: 'travel', - subcategories: [ - { label: 'Travel', value: 'travel-general' }, - { label: 'Vacation', value: 'vacation' }, - { label: 'Adventure', value: 'adventure' } - ] - }, - { - label: 'Life Changes', - value: 'life-changes', - subcategories: [ - { label: 'Moving', value: 'moving' }, - { label: 'License', value: 'license' }, - { label: 'Voting', value: 'voting' }, - { label: 'Citizenship', value: 'citizenship' } - ] - }, - { - label: 'Milestones', - value: 'milestones', - subcategories: [] - } -] - -// Flattened category options for backward compatibility and simple select usage -export const categoryOptions = categoryStructure.reduce((acc, category) => { - // Add main category if it has no subcategories - if (category.subcategories.length === 0) { - acc.push({ label: category.label, value: category.value }) - } else { - // Add subcategories with main category prefix - category.subcategories.forEach(sub => { - acc.push({ - label: `${category.label} - ${sub.label}`, - value: sub.value, - mainCategory: category.value, - subcategory: sub.value - }) - }) - } - return acc -}, []) - -// Helper functions for category management -export const getCategoryStructure = () => categoryStructure - -export const getMainCategories = () => { - return categoryStructure.map(cat => ({ - label: cat.label, - value: cat.value - })) -} - -export const getSubcategories = (mainCategoryValue) => { - const mainCategory = categoryStructure.find(cat => cat.value === mainCategoryValue) - return mainCategory ? mainCategory.subcategories : [] -} - -export const getCategoryByValue = (value) => { - return categoryOptions.find(cat => cat.value === value) -} - -export const getMainCategoryFromValue = (value) => { - const category = getCategoryByValue(value) - return category ? category.mainCategory : null -} - -export const tagOptions = [ - // Emotions - 'happy', 'sad', 'exciting', 'stressful', 'memorable', 'important', - 'fun', 'challenging', 'rewarding', 'disappointing', 'surprising', - 'life-changing', 'routine', 'special', 'difficult', 'joyful', - 'overwhelming', 'peaceful', 'anxious', 'proud', 'grateful', - 'emotional', 'touching', 'inspiring', 'motivating', 'healing', - - // Significance - 'milestone', 'achievement', 'breakthrough', 'turning-point', - 'first-time', 'last-time', 'once-in-a-lifetime', 'unexpected', - 'planned', 'spontaneous', 'tradition', 'new-experience', - - // Social - 'family', 'friends', 'colleagues', 'community', 'solo', - 'group', 'intimate', 'public', 'private', 'celebration', - - // Intensity - 'intense', 'mild', 'dramatic', 'subtle', 'overwhelming', - 'gradual', 'sudden', 'anticipated', 'shocking', 'gentle', - - // Time-related - 'brief', 'extended', 'momentary', 'lasting', 'temporary', - 'permanent', 'seasonal', 'annual', 'weekly', 'daily' -] - -export const personOptions = [ - 'Anna Mueller', - 'Max Schmidt', - 'Sarah Johnson', - 'Michael Weber', - 'Lisa Anderson', - 'Thomas Brown', - 'Julia Martinez', - 'David Wilson', - 'Emma Garcia', - 'Robert Davis' -] - -export const defaultFormData = { - keyImage: null, - keyImageUrl: '', - additionalImages: [], - additionalImageUrls: [], - level: 0, - categories: [], - headline: '', - subheadline: '', - text: '', - tags: [], - location: '', - date: '', - time: '', - audioFiles: [], - audioRecordings: [], - videoFiles: [], - videoRecordings: [], - relatedPersons: [] -} \ No newline at end of file diff --git a/frontend/src/pages/IndexPage.vue b/frontend/src/pages/IndexPage.vue deleted file mode 100644 index e74e263..0000000 --- a/frontend/src/pages/IndexPage.vue +++ /dev/null @@ -1,71 +0,0 @@ - - - diff --git a/frontend/src/pages/LoginPage.vue b/frontend/src/pages/LoginPage.vue index cacb15a..3ceb4f7 100644 --- a/frontend/src/pages/LoginPage.vue +++ b/frontend/src/pages/LoginPage.vue @@ -1,51 +1,56 @@ @@ -54,6 +59,7 @@ import { computed, ref, watch } from 'vue' import { useRoute, useRouter } from 'vue-router' import { useAuthStore } from 'stores/auth' +import BrandLogo from 'components/BrandLogo.vue' const authStore = useAuthStore() const router = useRouter() @@ -62,13 +68,12 @@ const route = useRoute() const email = ref(authStore.users[0]?.email ?? '') const password = ref('pass') const showPassword = ref(false) -const remember = ref(true) const isSubmitting = ref(false) const selectedUser = computed(() => authStore.users.find(user => user.email === email.value) ?? null) const isLocalDemo = computed(() => selectedUser.value?.mode === 'local') const submitLabel = computed(() => { - if (isSubmitting.value) return isLocalDemo.value ? 'Demo startet...' : 'Login läuft...' - return isLocalDemo.value ? 'Demo lokal starten' : 'Login' + if (isSubmitting.value) return isLocalDemo.value ? 'Demo startet …' : 'Anmeldung läuft …' + return isLocalDemo.value ? 'Demo lokal starten' : 'Anmelden' }) watch(isLocalDemo, (localDemo) => { @@ -89,6 +94,10 @@ async function onSubmit() { \ No newline at end of file + +.login-card__hint strong { + color: rgba(var(--tm-paper-rgb), 0.85); + font-weight: 600; +} + diff --git a/frontend/src/pages/PasswordResetPage.vue b/frontend/src/pages/PasswordResetPage.vue deleted file mode 100644 index 316abb7..0000000 --- a/frontend/src/pages/PasswordResetPage.vue +++ /dev/null @@ -1,95 +0,0 @@ - - - \ No newline at end of file diff --git a/frontend/src/pages/SignUpPage.vue b/frontend/src/pages/SignUpPage.vue deleted file mode 100644 index 9979b90..0000000 --- a/frontend/src/pages/SignUpPage.vue +++ /dev/null @@ -1,138 +0,0 @@ - - \ No newline at end of file diff --git a/frontend/src/services/apiClient.js b/frontend/src/services/apiClient.js index c989057..ecad55a 100644 --- a/frontend/src/services/apiClient.js +++ b/frontend/src/services/apiClient.js @@ -63,6 +63,33 @@ export async function clearToken() { await db.meta.delete('accessToken') } +// Listeners notified when the server rejects the stored token, so the auth +// store can end the session instead of leaving the UI logged in with a +// token that no longer works. +const unauthorizedHandlers = new Set() + +export function onUnauthorized(handler) { + unauthorizedHandlers.add(handler) + return () => unauthorizedHandlers.delete(handler) +} + +async function handleUnauthorized(usedToken) { + // A 401 from a request that belongs to an earlier session must not tear + // down the session that replaced it. + const storedToken = await getToken() + if (storedToken && storedToken !== usedToken) return + + await clearToken() + + for (const handler of unauthorizedHandlers) { + try { + handler() + } catch (e) { + console.warn('Unauthorized handler failed:', e) + } + } +} + async function readResponsePayload(response) { if (response.status === 204) return null @@ -81,6 +108,10 @@ export async function apiFetch(path, options = {}) { const token = auth ? await getToken() : null if (auth && !token) { + // The token is gone (logged out in another tab, storage cleared): end the + // session here too instead of leaving a logged-in UI whose requests all + // fail. + await handleUnauthorized(null) throw new ApiError('Nicht angemeldet.', { status: 401 }) } @@ -103,7 +134,7 @@ export async function apiFetch(path, options = {}) { }) if (response.status === 401 && auth) { - await clearToken() + await handleUnauthorized(token) } return response @@ -131,6 +162,21 @@ export async function loginRemote(email, password) { }) } -export async function logoutRemote() { - return apiJson('/logout', { method: 'POST' }) +/** + * Revoke a specific token. Used on logout so the revoke request carries the + * token being discarded, even if local state is cleared in parallel. + */ +export async function revokeToken(token) { + const response = await fetch(`${API_BASE}/logout`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + } + }) + + if (!response.ok && response.status !== 401) { + throw new ApiError('Der Token konnte nicht widerrufen werden.', { status: response.status }) + } } diff --git a/frontend/src/services/syncQueue.js b/frontend/src/services/syncQueue.js new file mode 100644 index 0000000..a3425a6 --- /dev/null +++ b/frontend/src/services/syncQueue.js @@ -0,0 +1,414 @@ +import { ref } from 'vue' +import { db } from 'src/db' +import { apiJson } from 'src/services/apiClient' + +/** + * Outbound write queue for remote users. + * + * Every change a remote user makes is written to IndexedDB first and sent + * from there, strictly in order, one item at a time. A write that fails for + * a reason that may pass (no network, server down, rate limit) stays in the + * queue and is retried when the browser reports it is online again, when the + * tab becomes visible, or after a growing pause. A write the server rejects + * for good (validation, missing permission) is dropped and reported to the + * caller, which decides what to do with the local copy. + * + * Actions and their payloads: + * create { ...event fields } + * update { ...event fields } + * delete null + * upload_media { localId, collection, name, mimeType, bytes, durationMs, caption } + * delete_media { mediaId } + * update_media { mediaId, caption } + * + * Media that has not reached the server yet carries a local id (see + * isLocalMediaId). Queue operations on such an id are folded into the + * pending upload instead of being sent on their own. + */ + +const LOCAL_ID_PREFIX = 'local-' +const RETRY_BASE_MS = 5000 +const RETRY_MAX_MS = 60000 + +export const isOnline = ref(typeof navigator === 'undefined' ? true : navigator.onLine !== false) +export const isSyncing = ref(false) +/** Queue rows waiting for the current user. */ +export const pendingCount = ref(0) + +if (typeof window !== 'undefined') { + window.addEventListener('online', () => { isOnline.value = true }) + window.addEventListener('offline', () => { isOnline.value = false }) +} + +export function isLocalMediaId(id) { + return typeof id === 'string' && id.startsWith(LOCAL_ID_PREFIX) +} + +export function newLocalMediaId() { + return `${LOCAL_ID_PREFIX}${crypto.randomUUID()}` +} + +/** + * Whether a failed request is worth retrying later. Anything without an HTTP + * status is a network failure; 408/429 and every 5xx are server-side and + * temporary by nature. Everything else is a verdict on the request itself. + */ +export function isTransientError(error) { + const status = error?.status + if (status === undefined || status === null || status === 0) return true + return status === 408 || status === 429 || status >= 500 +} + +function isNotFound(error) { + return error?.status === 404 +} + +/** + * Send one queue item. Resolves with the API payload (or null when there is + * none), rejects with the API error. + */ +async function send(item) { + const { action, eventId, payload } = item + + switch (action) { + case 'create': + return apiJson('/events', { method: 'POST', body: payload }) + + case 'update': + try { + return await apiJson(`/events/${eventId}`, { method: 'PUT', body: payload }) + } catch (error) { + // Gone on the server (another device, a failed earlier create): the + // local copy is what the user sees, so it is put back. + if (!isNotFound(error)) throw error + return apiJson('/events', { method: 'POST', body: { ...payload, id: eventId } }) + } + + case 'delete': + try { + await apiJson(`/events/${eventId}`, { method: 'DELETE' }) + } catch (error) { + if (!isNotFound(error)) throw error + } + return null + + case 'upload_media': { + const formData = new FormData() + formData.append('collection', payload.collection) + if (payload.caption) formData.append('caption', payload.caption) + const type = payload.mimeType || 'application/octet-stream' + const name = payload.name || 'upload' + // A File carries its name everywhere; the third argument of + // FormData.append is not honoured in every environment. + const file = typeof File !== 'undefined' + ? new File([payload.bytes], name, { type }) + : new Blob([payload.bytes], { type }) + formData.append('file', file, name) + return apiJson(`/events/${eventId}/media`, { method: 'POST', body: formData }) + } + + case 'delete_media': + try { + await apiJson(`/events/${eventId}/media/${payload.mediaId}`, { method: 'DELETE' }) + } catch (error) { + if (!isNotFound(error)) throw error + } + return null + + case 'update_media': + try { + return await apiJson(`/events/${eventId}/media/${payload.mediaId}`, { + method: 'PATCH', + body: { caption: payload.caption } + }) + } catch (error) { + if (!isNotFound(error)) throw error + return null + } + + default: + throw Object.assign(new Error(`Unknown sync action: ${action}`), { status: 400 }) + } +} + +/** + * @param {object} options + * @param {() => string|null} options.getUserId user whose queue this is; null disables the queue + * @param {(item, payload) => (void|Promise)} options.onApplied server accepted the item + * @param {(item, error) => (void|Promise)} options.onRejected server refused the item for good + * @param {() => void} [options.onBlocked] processing stopped on a transient failure + */ +export function createSyncQueue({ getUserId, onApplied, onRejected, onBlocked }) { + let processing = null + let rerunRequested = false + let inFlightId = null + let retryTimer = null + let retryAttempt = 0 + let started = false + + function currentUserId() { + return getUserId() ?? null + } + + async function rowsFor(userId) { + if (!userId) return [] + const rows = await db.syncQueue.where('userId').equals(userId).toArray() + return rows.sort((a, b) => a.queueId - b.queueId) + } + + async function refreshCount() { + try { + const userId = currentUserId() + pendingCount.value = userId ? await db.syncQueue.where('userId').equals(userId).count() : 0 + } catch (error) { + console.warn('Sync queue count failed:', error) + } + } + + /** + * Add a write. Related items already waiting are folded together so the + * queue never sends more than it has to: repeated updates collapse into + * one, a delete drops everything queued for the event, and changes to a + * not-yet-uploaded file go into the upload itself. + */ + async function enqueue({ eventId, action, payload = null }) { + const userId = currentUserId() + if (!userId) return false + + const row = { userId, eventId, action, payload, createdAt: Date.now(), attempts: 0 } + let added = true + + await db.transaction('rw', db.syncQueue, async () => { + const queued = (await rowsFor(userId)).filter(item => item.eventId === eventId && item.queueId !== inFlightId) + + switch (action) { + case 'update': { + const target = queued.find(item => item.action === 'create' || item.action === 'update') + if (target) { + await db.syncQueue.update(target.queueId, { payload }) + added = false + return + } + break + } + + case 'delete': { + const hadCreate = queued.some(item => item.action === 'create') + await db.syncQueue.bulkDelete(queued.map(item => item.queueId)) + // Never reached the server: there is nothing to delete there. + if (hadCreate) { + added = false + return + } + break + } + + case 'delete_media': { + if (isLocalMediaId(payload.mediaId)) { + const upload = queued.find(item => item.action === 'upload_media' && item.payload?.localId === payload.mediaId) + if (upload) await db.syncQueue.delete(upload.queueId) + // Still uploading: the caller re-queues the delete with the + // server id once the upload has answered. + added = false + return + } + const captions = queued.filter(item => item.action === 'update_media' && item.payload?.mediaId === payload.mediaId) + await db.syncQueue.bulkDelete(captions.map(item => item.queueId)) + break + } + + case 'update_media': { + if (isLocalMediaId(payload.mediaId)) { + const upload = queued.find(item => item.action === 'upload_media' && item.payload?.localId === payload.mediaId) + if (upload) { + await db.syncQueue.update(upload.queueId, { payload: { ...upload.payload, caption: payload.caption } }) + } + added = false + return + } + const previous = queued.find(item => item.action === 'update_media' && item.payload?.mediaId === payload.mediaId) + if (previous) { + await db.syncQueue.update(previous.queueId, { payload }) + added = false + return + } + break + } + + default: + break + } + + await db.syncQueue.add(row) + }) + + await refreshCount() + return added + } + + /** Whether an event still has a create or update waiting to be sent. */ + async function hasQueuedWrites(eventId) { + const rows = await rowsFor(currentUserId()) + return rows.some(item => item.eventId === eventId && (item.action === 'create' || item.action === 'update')) + } + + /** + * What the queue currently holds, grouped for merging a server response + * with the local state. + */ + async function snapshot() { + const rows = await rowsFor(currentUserId()) + const byEvent = new Map() + const deletedMediaIds = new Set() + + for (const item of rows) { + if (!byEvent.has(item.eventId)) byEvent.set(item.eventId, new Set()) + byEvent.get(item.eventId).add(item.action) + if (item.action === 'delete_media') deletedMediaIds.add(item.payload?.mediaId) + } + + return { byEvent, deletedMediaIds, isEmpty: rows.length === 0 } + } + + function clearRetry() { + if (retryTimer) { + clearTimeout(retryTimer) + retryTimer = null + } + } + + function scheduleRetry() { + clearRetry() + // Offline: the 'online' event is the trigger, a timer would only spin. + if (!isOnline.value) return + const delay = Math.min(RETRY_MAX_MS, RETRY_BASE_MS * 2 ** retryAttempt) + retryAttempt += 1 + retryTimer = setTimeout(() => { + retryTimer = null + process() + }, delay) + } + + /** + * Send queued items in order until the queue is empty or a transient + * failure stops it. Safe to call at any time: a call during a run makes + * the run start over once it has finished, so a request to send now is + * never swallowed by a run that was already winding down. + */ + function process() { + if (processing) { + rerunRequested = true + return processing + } + + processing = (async () => { + do { + rerunRequested = false + await runOnce() + } while (rerunRequested) + })().finally(() => { + processing = null + }) + + return processing + } + + async function runOnce() { + isSyncing.value = true + try { + for (;;) { + const userId = currentUserId() + if (!userId || !isOnline.value) break + + const item = await db.syncQueue.where('userId').equals(userId).first() + if (!item) { + retryAttempt = 0 + clearRetry() + break + } + + inFlightId = item.queueId + let payload + try { + payload = await send(item) + } catch (error) { + inFlightId = null + + if (error?.status === 401) { + // The session is over; the API client is ending it. The item + // stays for the next login of this user. + break + } + + if (isTransientError(error)) { + await db.syncQueue.update(item.queueId, { + attempts: (item.attempts ?? 0) + 1, + lastError: String(error?.message ?? error) + }).catch(() => {}) + onBlocked?.(item, error) + scheduleRetry() + break + } + + await db.syncQueue.delete(item.queueId) + if (userId === currentUserId()) { + try { + await onRejected(item, error) + } catch (handlerError) { + console.warn('Sync rejection handler failed:', handlerError) + } + } + continue + } + + inFlightId = null + await db.syncQueue.delete(item.queueId) + retryAttempt = 0 + + // The response belongs to the user that queued the item. After a + // user switch it is still acknowledged, but not applied. + if (userId === currentUserId()) { + try { + await onApplied(item, payload) + } catch (handlerError) { + console.warn('Sync apply handler failed:', handlerError) + } + } + } + } catch (error) { + console.warn('Sync queue processing failed:', error) + inFlightId = null + scheduleRetry() + } finally { + await refreshCount() + isSyncing.value = false + } + } + + function onOnline() { + retryAttempt = 0 + process() + } + + function onVisible() { + if (document.visibilityState === 'visible') process() + } + + /** Attach the triggers that resume sending. */ + function start() { + if (started || typeof window === 'undefined') return + started = true + window.addEventListener('online', onOnline) + document.addEventListener('visibilitychange', onVisible) + } + + function stop() { + if (!started) return + started = false + window.removeEventListener('online', onOnline) + document.removeEventListener('visibilitychange', onVisible) + clearRetry() + } + + return { enqueue, process, hasQueuedWrites, snapshot, refreshCount, start, stop } +} diff --git a/frontend/src/services/syncService.js b/frontend/src/services/syncService.js deleted file mode 100644 index 7620486..0000000 --- a/frontend/src/services/syncService.js +++ /dev/null @@ -1,212 +0,0 @@ -import { ref } from 'vue' -import { db } from 'src/db' -import { apiFetch, getToken, setToken } from 'src/services/apiClient' - -const isSyncing = ref(false) -const isOnline = ref(navigator.onLine) -const lastSyncAt = ref(null) - -// Track online status -window.addEventListener('online', () => { - isOnline.value = true - processSyncQueue() -}) -window.addEventListener('offline', () => { - isOnline.value = false -}) - -/** - * Process the outbound sync queue (FIFO). - * Called on app start, every 30s when online, and on reconnect. - */ -async function processSyncQueue() { - if (!isOnline.value || isSyncing.value) return - - const token = await getToken() - if (!token) return - - isSyncing.value = true - - try { - const queue = await db.syncQueue.orderBy('queueId').toArray() - if (queue.length === 0) { - isSyncing.value = false - return - } - - // Batch sync: send up to 100 mutations at once - const batch = queue.slice(0, 100) - const mutations = batch.map((item) => ({ - action: item.action, - eventId: item.eventId, - payload: item.payload, - })) - - const response = await apiFetch('/events/sync', { - method: 'POST', - body: JSON.stringify({ mutations }), - }) - - if (response.ok) { - const data = await response.json() - - // Remove successfully processed items from queue - const processedIds = [] - data.results.forEach((result, i) => { - if (result.status === 'ok') { - processedIds.push(batch[i].queueId) - } - }) - - if (processedIds.length > 0) { - await db.syncQueue.bulkDelete(processedIds) - } - - // Update syncStatus on local events - for (const result of data.results) { - if (result.status === 'ok') { - const event = await db.events.get(result.eventId) - if (event && event.syncStatus !== 'local') { - await db.events.update(result.eventId, { syncStatus: 'synced' }) - } - } - } - - lastSyncAt.value = Date.now() - - // If there are more items, process next batch - if (queue.length > 100) { - await processSyncQueue() - } - } - } catch (e) { - console.warn('Sync queue processing failed:', e) - } finally { - isSyncing.value = false - } -} - -/** - * Pull remote changes since last sync cursor. - * Merges with local data using "last write wins" on updatedAt. - */ -async function pullRemoteChanges() { - if (!isOnline.value) return - - const token = await getToken() - if (!token) return - - try { - const lastSync = await db.meta.get('lastSyncCursor') - const since = lastSync?.value || null - - let url = '/events?limit=200' - if (since) { - url += `&since=${since}` - } - - const response = await apiFetch(url) - if (!response.ok) return - - const data = await response.json() - const remoteEvents = data.data || [] - - for (const remote of remoteEvents) { - const local = await db.events.get(remote.id) - - if (!local) { - // New event from server - await db.events.put({ - id: remote.id, - title: remote.title, - date: remote.date, - location: remote.location ?? '', - emotion: remote.emotion, - customColor: remote.customColor, - gradientPreset: remote.gradientPreset, - gradientStartColor: remote.gradientStartColor ?? null, - gradientEndColor: remote.gradientEndColor ?? null, - image: remote.image, - note: remote.note, - syncStatus: 'synced', - createdAt: remote.createdAt, - updatedAt: remote.updatedAt, - }) - } else if (remote.updatedAt > local.updatedAt && local.syncStatus === 'synced') { - // Remote is newer and local hasn't been modified — update - await db.events.update(remote.id, { - title: remote.title, - date: remote.date, - location: remote.location ?? '', - emotion: remote.emotion, - customColor: remote.customColor, - gradientPreset: remote.gradientPreset, - gradientStartColor: remote.gradientStartColor ?? null, - gradientEndColor: remote.gradientEndColor ?? null, - image: remote.image, - note: remote.note, - syncStatus: 'synced', - updatedAt: remote.updatedAt, - }) - } - // If local is modified, skip — local changes will be pushed via sync queue - } - - // Update sync cursor - await db.meta.put({ key: 'lastSyncCursor', value: new Date().toISOString() }) - - // Handle pagination (cursor-based) - if (data.next_cursor) { - // There are more pages — but for now we only pull one batch - // Future: iterate through pages - } - - lastSyncAt.value = Date.now() - } catch (e) { - console.warn('Pull remote changes failed:', e) - } -} - -/** - * Full sync: push local changes, then pull remote. - */ -async function fullSync() { - await processSyncQueue() - await pullRemoteChanges() -} - -// Auto-sync interval (30s) -let syncInterval = null - -function startAutoSync() { - if (syncInterval) return - syncInterval = setInterval(() => { - if (isOnline.value) { - fullSync() - } - }, 30000) - - // Initial sync - fullSync() -} - -function stopAutoSync() { - if (syncInterval) { - clearInterval(syncInterval) - syncInterval = null - } -} - -export { - isOnline, - isSyncing, - lastSyncAt, - getToken, - setToken, - apiFetch, - processSyncQueue, - pullRemoteChanges, - fullSync, - startAutoSync, - stopAutoSync, -} diff --git a/frontend/src/stores/auth.js b/frontend/src/stores/auth.js index cd1e035..611d3ae 100644 --- a/frontend/src/stores/auth.js +++ b/frontend/src/stores/auth.js @@ -1,6 +1,7 @@ import { defineStore } from 'pinia' import { computed, ref } from 'vue' -import { clearToken, loginRemote, logoutRemote, setToken } from 'src/services/apiClient' +import { clearToken, getToken, loginRemote, onUnauthorized, revokeToken, setToken } from 'src/services/apiClient' +import { setImageCacheScope } from 'src/composables/useImageCache' export const AUTH_STORAGE_KEY = 'thatsme-auth' @@ -14,17 +15,39 @@ export const LOCAL_DEMO_USER = { seedDemoEvents: false } -export const REMOTE_USERS = Array.from({ length: 6 }, (_, index) => { - const number = index + 1 - return { - id: `remote-user-${number}`, - email: `user${number}@thats-me.app`, - name: `User ${number}`, - avatar: `U${number}`, +// Demo lifelines seeded by the Laravel LifelineSeeder (database/data/lifelines/*.json) +const LIFELINE_USERS = [ + { handle: 'jonas', name: 'Jonas', avatar: 'JO' }, + { handle: 'sophie', name: 'Sophie', avatar: 'SO' }, + { handle: 'nadja', name: 'Nadja', avatar: 'NA' }, + { handle: 'werner', name: 'Werner', avatar: 'WE' }, + { handle: 'bruno', name: 'Bruno (Hund)', avatar: 'BR' }, + { handle: 'benz', name: 'Der Benz (Auto)', avatar: 'BZ' }, + { handle: 'lukas', name: 'Lukas', avatar: 'LU' }, + { handle: 'nachtschicht', name: 'Nachtschicht (Band)', avatar: 'NS' } +] + +export const REMOTE_USERS = [ + ...LIFELINE_USERS.map(user => ({ + id: `remote-${user.handle}`, + email: `${user.handle}@thats-me.app`, + name: user.name, + avatar: user.avatar, mode: 'remote', seedDemoEvents: false - } -}) + })), + ...Array.from({ length: 6 }, (_, index) => { + const number = index + 1 + return { + id: `remote-user-${number}`, + email: `user${number}@thats-me.app`, + name: `User ${number}`, + avatar: `U${number}`, + mode: 'remote', + seedDemoEvents: false + } + }) +] function normalizeUser(user) { const mode = user.mode || 'local' @@ -89,6 +112,25 @@ export const useAuthStore = defineStore('auth', () => { ) const isAuthenticated = computed(() => currentUser.value !== null) + setImageCacheScope(currentUserId.value) + + function endSession() { + currentUserId.value = null + currentUserProfile.value = null + localStorage.removeItem(AUTH_STORAGE_KEY) + setImageCacheScope(null) + } + + // The API rejected the stored token. Clearing only the token left the UI + // logged in and the router bouncing /login back into the app, with every + // write silently failing. + onUnauthorized(() => { + if (currentUser.value?.mode !== 'remote') return + + endSession() + lastError.value = 'Die Sitzung ist abgelaufen. Bitte erneut anmelden.' + }) + async function login(email, password) { const normalizedEmail = String(email).trim().toLowerCase() const user = users.value.find(candidate => @@ -110,13 +152,16 @@ export const useAuthStore = defineStore('auth', () => { currentUserProfile.value = remoteUser lastError.value = '' persistAuth(remoteUser) + setImageCacheScope(remoteUser.id) return true } catch (error) { console.warn('Remote login failed:', error) - lastError.value = error?.status === 422 - ? 'E-Mail oder Passwort ist falsch.' - : 'Login über die API ist gerade nicht möglich.' + lastError.value = error?.status === 429 + ? 'Zu viele Fehlversuche. Bitte kurz warten und erneut versuchen.' + : error?.status === 422 + ? 'E-Mail oder Passwort ist falsch.' + : 'Login über die API ist gerade nicht möglich.' return false } } @@ -126,6 +171,7 @@ export const useAuthStore = defineStore('auth', () => { currentUserProfile.value = user lastError.value = '' persistAuth(user) + setImageCacheScope(user.id) return true } @@ -138,19 +184,36 @@ export const useAuthStore = defineStore('auth', () => { currentUserProfile.value = user lastError.value = '' persistAuth(user) + setImageCacheScope(user.id) return true } function logout() { - if (currentUser.value?.mode === 'remote') { - logoutRemote().catch(() => {}) - } + const wasRemote = currentUser.value?.mode === 'remote' - currentUserId.value = null - currentUserProfile.value = null + // Cleared synchronously: the caller navigates to /login straight after, + // and the router guard reads localStorage. Doing this behind an await + // would let the guard still see a logged-in session and bounce back. + endSession() lastError.value = '' - localStorage.removeItem(AUTH_STORAGE_KEY) - clearToken().catch(() => {}) + + return revokeRemoteSession(wasRemote) + } + + async function revokeRemoteSession(wasRemote) { + // Read before the token is dropped, so the revoke request still carries + // the token it is meant to invalidate. + const token = wasRemote ? await getToken() : null + + await clearToken().catch(() => {}) + + if (!token) return + + try { + await revokeToken(token) + } catch (error) { + console.warn('Token revoke failed:', error) + } } return { diff --git a/frontend/src/stores/events.js b/frontend/src/stores/events.js index 302b73b..e0f6537 100644 --- a/frontend/src/stores/events.js +++ b/frontend/src/stores/events.js @@ -1,11 +1,12 @@ import { defineStore } from 'pinia' -import { ref, computed, watch } from 'vue' +import { ref, computed, watch, nextTick, toRaw } from 'vue' import Dexie from 'dexie' import { db } from 'src/db' import { apiJson } from 'src/services/apiClient' -import { startAutoSync, getToken } from 'src/services/syncService' +import { createSyncQueue, isLocalMediaId, isOnline, isSyncing, newLocalMediaId, pendingCount } from 'src/services/syncQueue' import { useSettingsStore, DEFAULT_EMOTION_GRADIENT_START, DEFAULT_EMOTION_GRADIENT_END } from 'stores/settings' import { useAuthStore } from 'stores/auth' +import { compareCalendarDates } from 'src/utils/calendarDate' // Color interpolation function lerpColor(a, b, t) { @@ -162,22 +163,51 @@ export const useEventsStore = defineStore('events', () => { const editingEventId = ref(null) const AUTOSAVE_DELAY_MS = 300 let persistTimer = null - let skipNextPersist = false - const pendingRemoteCreates = new Map() + // While true, changes to the draft fields do not schedule an autosave. + // Set around programmatic writes to the draft (opening an event, resetting + // the draft) and cleared on the next tick, so it never depends on whether + // the watcher actually fired. + let suppressPersist = false + // Incremented on every user switch. A response that resolves after the + // switch belongs to a stale session and must not touch the new user's state. + let sessionGeneration = 0 + // Surfaced in the UI so a failed load or write is visible instead of + // looking like an empty account or a successful save. + const loadError = ref(null) + const lastError = ref(null) + // 'idle' | 'saving' | 'saved' | 'pending' | 'error' — drives the panel's + // save indicator. 'pending' means the change is stored locally and waits + // in the sync queue (offline, server unreachable); 'error' means the + // server refused it. + const saveState = ref('idle') + + function currentSession() { + return { generation: sessionGeneration, userId: authStore.currentUserId } + } + + function isStaleSession(session) { + return session.generation !== sessionGeneration || session.userId !== authStore.currentUserId + } const isRemoteUser = computed(() => authStore.currentUser?.mode === 'remote') - function normalizeRemoteEvent(event) { + function normalizeRemoteEvent(event, userId) { const media = Array.isArray(event.media) ? event.media : [] const keyImage = media.find(item => item.collection === 'key_image') return { id: event.id, - userId: authStore.currentUserId, + // Bound to the user the request was started for, never to whoever + // happens to be logged in once the response arrives. + userId, title: event.title, date: event.date, + endDate: event.endDate ?? null, location: event.location ?? '', + category: event.category ?? null, + people: Array.isArray(event.people) ? event.people : [], emotion: event.emotion, + milestone: Boolean(event.milestone), customColor: event.customColor ?? null, gradientPreset: event.gradientPreset ?? null, gradientStartColor: event.gradientStartColor ?? null, @@ -199,21 +229,44 @@ export const useEventsStore = defineStore('events', () => { id: event.id, title: event.title, date: event.date, + endDate: event.endDate || null, + location: event.location ?? '', + category: event.category || null, + people: Array.isArray(event.people) ? event.people : [], emotion: event.emotion, + milestone: Boolean(event.milestone), customColor: event.customColor, - gradientPreset: event.gradientPreset, - image: event.image, + gradientPreset: event.gradientPreset ?? null, + // A key image that has not been uploaded yet is a local preview (data + // URL). The server learns the real path from the upload itself. + ...(isLocalImageSrc(event.image) ? {} : { image: event.image }), + keyImageTitle: event.keyImageTitle ?? '', note: event.note } } - async function loadRemoteEvents() { + function isLocalImageSrc(src) { + return typeof src === 'string' && (src.startsWith('data:') || src.startsWith('blob:')) + } + + async function loadRemoteEvents(session) { + // Whatever waits in the queue goes out first, so the list that comes + // back already reflects it. Offline this returns at once. + await queue.process() + if (isStaleSession(session)) return false + + const cached = await readCachedEvents(session.userId) + const queued = await queue.snapshot() + if (isStaleSession(session)) return false + const remoteEvents = [] let nextUrl = '/events?limit=200' while (nextUrl) { const payload = await apiJson(nextUrl) - remoteEvents.push(...(payload.data || []).map(normalizeRemoteEvent)) + if (isStaleSession(session)) return false + + remoteEvents.push(...(payload.data || []).map(event => normalizeRemoteEvent(event, session.userId))) if (payload.links?.next) { const url = new URL(payload.links.next) nextUrl = `${url.pathname.replace(/^\/api/, '')}${url.search}` @@ -222,117 +275,331 @@ export const useEventsStore = defineStore('events', () => { } } - events.value = remoteEvents + if (isStaleSession(session)) return false + + // Local changes that have not reached the server yet are newer than + // anything the server returned for the same event, so they win. + const merged = remoteEvents + .filter(remote => !queued.byEvent.get(remote.id)?.has('delete')) + .map((remote) => { + const local = cached.find(event => event.id === remote.id) + if (!local || !queued.byEvent.has(remote.id)) { + return { ...remote, media: remote.media.filter(item => !queued.deletedMediaIds.has(item.id)) } + } + return { + ...local, + media: mergeMedia(remote.media, local.media, queued.deletedMediaIds), + syncStatus: 'pending' + } + }) + + for (const local of cached) { + const actions = queued.byEvent.get(local.id) + if (actions && !actions.has('delete') && !merged.some(event => event.id === local.id)) { + merged.push({ ...local, syncStatus: 'pending' }) + } + } + + events.value = merged await db.events .where('[userId+date]') - .between([authStore.currentUserId, Dexie.minKey], [authStore.currentUserId, Dexie.maxKey]) + .between([session.userId, Dexie.minKey], [session.userId, Dexie.maxKey]) .delete() - if (remoteEvents.length > 0) { - await db.events.bulkPut(remoteEvents) + if (merged.length > 0) { + await db.events.bulkPut(merged.map(event => JSON.parse(JSON.stringify(event)))) } + + return true } - function createRemoteEvent(event) { - const request = apiJson('/events', { - method: 'POST', - body: eventPayload(event) - }) - .then((payload) => { - const syncedEvent = normalizeRemoteEvent(payload.data) - const idx = events.value.findIndex(item => item.id === syncedEvent.id) - if (idx !== -1) { - events.value[idx] = { - ...events.value[idx], - ...syncedEvent - } - } - dbPut(syncedEvent) + // Server media plus what is still on its way up, minus what was deleted + // locally but not yet on the server. + function mergeMedia(serverMedia, localMedia, deletedMediaIds = new Set()) { + const server = (serverMedia || []).filter(item => !deletedMediaIds.has(item.id)) + const localPending = (localMedia || []).filter(item => isLocalMediaId(item.id)) + return [...server, ...localPending] + } + + // ---- Sync queue: every remote write goes through here ---- + + const queue = createSyncQueue({ + getUserId: () => (isRemoteUser.value ? authStore.currentUserId : null), + onApplied: applyQueueResult, + onRejected: applyQueueRejection, + onBlocked: () => { + // The change is safe in the queue; the panel says so instead of + // showing a spinner that never ends. + if (saveState.value === 'saving') saveState.value = 'pending' + } + }) + + function queueWrite(entry) { + if (!isRemoteUser.value) return Promise.resolve(false) + return queue.enqueue(entry) + .then((added) => { + queue.process() + return added }) .catch((error) => { - console.warn('Remote event create failed:', error) - const idx = events.value.findIndex(item => item.id === event.id) + console.warn('Sync queue write failed:', error) + lastError.value = 'Die Änderung konnte nicht zwischengespeichert werden.' + if (editingEventId.value === entry.eventId) saveState.value = 'error' + return false + }) + } + + function queueEventWrite(action, event) { + if (!isRemoteUser.value) return + if (editingEventId.value === event.id && action !== 'delete') { + saveState.value = isOnline.value ? 'saving' : 'pending' + } + queueWrite({ eventId: event.id, action, payload: action === 'delete' ? null : eventPayload(event) }) + } + + async function applyQueueResult(item, payload) { + switch (item.action) { + case 'create': + case 'update': + await applyServerEvent(item, payload) + break + case 'upload_media': + await applyUploadedMedia(item, payload) + break + default: + break + } + } + + async function applyServerEvent(item, payload) { + if (!payload?.data) return + const synced = normalizeRemoteEvent(payload.data, item.userId) + if (!events.value.some(event => event.id === item.eventId)) { + // Deleted locally in the meantime; the queued delete follows. + return + } + + const [moreWrites, queued] = await Promise.all([queue.hasQueuedWrites(item.eventId), queue.snapshot()]) + const idx = events.value.findIndex(event => event.id === item.eventId) + if (idx === -1) return + + const local = events.value[idx] + const localMedia = local.media || [] + const serverMedia = Array.isArray(payload.data.media) + ? synced.media + // Media have their own endpoints; a response without them must not + // wipe what the client knows. + : localMedia.filter(media => !isLocalMediaId(media.id)) + const media = mergeMedia(serverMedia, localMedia, queued.deletedMediaIds) + const pendingKeyImage = media.some(entry => entry.collection === 'key_image' && isLocalMediaId(entry.id)) + + if (moreWrites) { + // A newer edit is still queued: the fields it carries stay, only the + // media list is taken from the server. + events.value[idx] = { ...local, media, syncStatus: 'pending' } + } else { + events.value[idx] = { + ...local, + ...synced, + media, + ...(pendingKeyImage + ? { + image: local.image, + keyImagePreviewUrl: local.keyImagePreviewUrl ?? null, + keyImageOriginalUrl: local.keyImageOriginalUrl ?? null + } + : {}), + syncStatus: 'synced' + } + if (editingEventId.value === item.eventId && saveState.value !== 'error') { + saveState.value = 'saved' + } + } + dbPut(events.value[idx]) + } + + async function applyUploadedMedia(item, payload) { + const media = payload?.data + if (!media) return + const { localId, collection } = item.payload + const eventId = item.eventId + const merged = { + ...media, + durationMs: media.durationMs ?? item.payload.durationMs ?? null, + caption: media.caption || item.payload.caption || '' + } + + const idx = events.value.findIndex(event => event.id === eventId) + const existing = idx !== -1 ? (events.value[idx].media || []) : [] + const localItem = existing.find(entry => entry.id === localId) + ?? ghostMedia.value.find(entry => entry.id === localId) + ?? ghostVoiceNotes.value.find(entry => entry.id === localId) + + // Removed locally while the upload was on its way: the file has to go + // again, this time from the server. + if (idx !== -1 && !localItem && collection !== 'key_image') { + db.eventMedia.delete(localId).catch(() => {}) + queueWrite({ eventId, action: 'delete_media', payload: { mediaId: merged.id } }) + return + } + + if (idx !== -1) { + const nextMedia = collection === 'key_image' + ? [...existing.filter(entry => entry.collection !== 'key_image'), merged] + : existing.map(entry => (entry.id === localId ? merged : entry)) + events.value[idx] = { + ...events.value[idx], + media: nextMedia, + ...(collection === 'key_image' + ? { + image: merged.thumbnailUrl, + keyImagePreviewUrl: merged.previewUrl, + keyImageOriginalUrl: merged.originalUrl + } + : {}), + updatedAt: Date.now() + } + dbPut(events.value[idx]) + } + + // The stored row is replaced one to one; other pending files of the + // event keep their blobs. + const userId = events.value[idx]?.userId ?? item.userId + db.eventMedia.delete(localId) + .then(() => db.eventMedia.put({ ...merged, eventId, userId })) + .catch(e => console.warn('Dexie media replace failed:', e)) + + if (editingEventId.value === eventId) { + withPersistSuppressed(() => { + ghostMedia.value = ghostMedia.value.map(entry => (entry.id === localId ? merged : entry)) + ghostVoiceNotes.value = ghostVoiceNotes.value.map(entry => (entry.id === localId ? merged : entry)) + if (collection === 'key_image') { + ghostImage.value = merged.previewUrl || merged.originalUrl + } + }) + } + + // A caption typed while the file was uploading was not part of it. + const typedCaption = localItem?.caption ?? '' + if (typedCaption && typedCaption !== merged.caption) { + queueWrite({ eventId, action: 'update_media', payload: { mediaId: merged.id, caption: typedCaption } }) + } + } + + async function applyQueueRejection(item, error) { + console.warn('Sync item rejected:', item.action, item.eventId, error) + + switch (item.action) { + case 'create': + case 'update': { + const idx = events.value.findIndex(event => event.id === item.eventId) if (idx !== -1) { + // Kept locally and flagged, rather than silently discarded. events.value[idx] = { ...events.value[idx], syncStatus: 'error' } + dbPut(events.value[idx]) + } + if (editingEventId.value === item.eventId) saveState.value = 'error' + lastError.value = error?.status === 422 + ? 'Die Eingabe wurde vom Server abgelehnt.' + : 'Das Event konnte nicht gespeichert werden.' + break + } + + case 'delete': + lastError.value = 'Das Event konnte nicht gelöscht werden.' + break + + case 'upload_media': { + const { localId, collection } = item.payload + removeLocalMedia(item.eventId, localId, collection) + lastError.value = error?.status === 422 && error?.message + ? `Datei abgelehnt: ${error.message}` + : 'Die Datei wurde vom Server nicht angenommen.' + break + } + + case 'delete_media': + lastError.value = 'Die Datei konnte nicht gelöscht werden.' + break + + case 'update_media': + lastError.value = 'Die Notiz zur Sprachnotiz konnte nicht gespeichert werden.' + break + + default: + break + } + } + + // Drops a media item from the event, the draft and the local media table. + function removeLocalMedia(eventId, mediaId, collection = null) { + const idx = events.value.findIndex(event => event.id === eventId) + if (idx !== -1) { + const isKeyImage = collection === 'key_image' + events.value[idx] = { + ...events.value[idx], + media: (events.value[idx].media || []).filter(item => item.id !== mediaId), + ...(isKeyImage ? { image: null, keyImagePreviewUrl: null, keyImageOriginalUrl: null } : {}), + updatedAt: Date.now() + } + dbPut(events.value[idx]) + } + db.eventMedia.delete(mediaId).catch(() => {}) + + if (editingEventId.value === eventId) { + withPersistSuppressed(() => { + ghostMedia.value = ghostMedia.value.filter(item => item.id !== mediaId) + ghostVoiceNotes.value = ghostVoiceNotes.value.filter(item => item.id !== mediaId) + if (collection === 'key_image') { + ghostImage.value = null + ghostKeyImageTitle.value = '' } }) - .finally(() => { - pendingRemoteCreates.delete(event.id) - }) - - pendingRemoteCreates.set(event.id, request) - } - - async function updateRemoteEvent(event) { - const pendingCreate = pendingRemoteCreates.get(event.id) - if (pendingCreate) { - await pendingCreate - } - - try { - const payload = await apiJson(`/events/${event.id}`, { - method: 'PUT', - body: eventPayload(event) - }) - const syncedEvent = normalizeRemoteEvent(payload.data) - const idx = events.value.findIndex(item => item.id === syncedEvent.id) - if (idx !== -1) { - events.value[idx] = { - ...events.value[idx], - ...syncedEvent - } - } - dbPut(syncedEvent) - } catch (error) { - console.warn('Remote event update failed:', error) - const idx = events.value.findIndex(item => item.id === event.id) - if (idx !== -1) { - events.value[idx] = { ...events.value[idx], syncStatus: 'error' } - } } } - async function deleteRemoteEvent(id) { - const pendingCreate = pendingRemoteCreates.get(id) - if (pendingCreate) { - await pendingCreate - } - - try { - await apiJson(`/events/${id}`, { method: 'DELETE' }) - } catch (error) { - console.warn('Remote event delete failed:', error) - } + /** Send everything that waits in the queue now. */ + function syncNow() { + return queue.process() } - async function uploadRemoteMedia(file, collection = 'gallery') { - if (!editingEventId.value) return null + /** The panel's retry: write the draft again and push the queue. */ + function retrySave() { + if (editingEventId.value) persistToEvent() + return syncNow() + } - const pendingCreate = pendingRemoteCreates.get(editingEventId.value) - if (pendingCreate) { - await pendingCreate + /** + * Turn a file into a queue-safe upload and a local media item the UI can + * show right away. The bytes go into the queue row (an ArrayBuffer clones + * everywhere); the local item keeps the blob for playback and display. + */ + async function queueMediaUpload(eventId, file, collection, { durationMs = null, caption = '' } = {}) { + const localId = newLocalMediaId() + const type = collection === 'voice_note' + ? 'audio' + : (file.type || '').startsWith('video/') ? 'video' : 'image' + const item = { + id: localId, + type, + collection, + name: file.name || `${collection}.${(file.type || '').split('/')[1] || 'bin'}`, + mimeType: file.type || null, + blob: file, + durationMs, + caption, + pending: true, + createdAt: Date.now() } - const formData = new FormData() - formData.append('collection', collection) - formData.append('file', file) - - const payload = await apiJson(`/events/${editingEventId.value}/media`, { - method: 'POST', - body: formData + const bytes = await file.arrayBuffer() + queueWrite({ + eventId, + action: 'upload_media', + payload: { localId, collection, name: item.name, mimeType: item.mimeType, bytes, durationMs, caption } }) - return payload.data - } - - async function deleteRemoteMedia(mediaId) { - if (!isRemoteUser.value || !editingEventId.value || !mediaId) return - - try { - await apiJson(`/events/${editingEventId.value}/media/${mediaId}`, { method: 'DELETE' }) - } catch (error) { - console.warn('Remote media delete failed:', error) - } + return item } // Load local Demo events from IndexedDB; Remote users load from the API. @@ -344,10 +611,15 @@ export const useEventsStore = defineStore('events', () => { return } + const session = currentSession() + isLoaded.value = false + loadError.value = null try { if (isRemoteUser.value) { - await loadRemoteEvents() + queue.start() + await loadRemoteEvents(session) + if (isStaleSession(session)) return isLoaded.value = true return } @@ -356,22 +628,39 @@ export const useEventsStore = defineStore('events', () => { .where('[userId+date]') .between([userId, Dexie.minKey], [userId, Dexie.maxKey]) .toArray() + if (isStaleSession(session)) return events.value = stored } catch (e) { - console.warn('Dexie load failed:', e) - events.value = [] - } - isLoaded.value = true + console.warn('Event load failed:', e) + if (isStaleSession(session)) return - // Start auto-sync if authenticated - getToken().then((token) => { - if (token) startAutoSync() - }) + // A failed load is not an empty account: fall back to whatever is + // cached and say so, rather than rendering a blank timeline. + loadError.value = 'Die Events konnten nicht geladen werden. Angezeigt wird der zuletzt gespeicherte Stand.' + events.value = await readCachedEvents(userId) + } + if (isStaleSession(session)) return + isLoaded.value = true } - // Fire-and-forget DB write (UI already updated via ref) + async function readCachedEvents(userId) { + try { + return await db.events + .where('[userId+date]') + .between([userId, Dexie.minKey], [userId, Dexie.maxKey]) + .toArray() + } catch (e) { + console.warn('Dexie cache read failed:', e) + return [] + } + } + + // Fire-and-forget DB write (UI already updated via ref). The row is + // rebuilt as plain data first: a reactive proxy (or an array of proxies + // nested in `media`) cannot be structured-cloned by IndexedDB. function dbPut(event) { - db.events.put({ ...event, userId: event.userId ?? authStore.currentUserId }).catch(e => console.warn('Dexie put failed:', e)) + const row = JSON.parse(JSON.stringify({ ...toRaw(event), userId: event.userId ?? authStore.currentUserId })) + db.events.put(row).catch(e => console.warn('Dexie put failed:', e)) } function dbDelete(id) { @@ -382,26 +671,61 @@ export const useEventsStore = defineStore('events', () => { remove().catch(e => console.warn('Dexie delete failed:', e)) } + // Plain copies of media items. Blobs (local videos, recordings) are kept + // as they are; Vue never proxies them, and IndexedDB stores them fine. function cloneMedia(media) { return Array.isArray(media) - ? media.map(item => ({ ...item })) + ? media.map(item => Object.fromEntries( + Object.entries(toRaw(item)).map(([key, value]) => [key, toRaw(value)]) + )) : [] } + // Metadata only: the event row must stay small and structured-cloneable. + // Local blobs (videos, recordings of the demo user) live in the eventMedia + // table, see persistEventMedia. function mediaMeta(media) { return cloneMedia(media).map((item) => ({ id: item.id, uuid: item.uuid, - type: item.type, - collection: item.collection, + type: item.type || 'image', + collection: item.collection || 'gallery', name: item.name, + caption: item.caption ?? '', + mimeType: item.mimeType, + durationMs: item.durationMs ?? null, src: item.src, thumbnailUrl: item.thumbnailUrl, + previewUrl: item.previewUrl, originalUrl: item.originalUrl, - createdAt: item.createdAt + pending: Boolean(item.pending), + createdAt: item.createdAt, + updatedAt: item.updatedAt })) } + const isGalleryItem = (item) => item.collection !== 'key_image' && item.collection !== 'voice_note' + + // Everything the draft holds, in the shape the event row stores it. The + // key image is not part of the draft lists, so it is carried over from the + // stored event. + function draftMedia(storedMedia = []) { + return [ + ...(storedMedia || []).filter(item => item.collection === 'key_image'), + ...ghostMedia.value, + ...ghostVoiceNotes.value + ] + } + + // Splits an event's media into the draft fields. + function applyMediaToDraft(media) { + const items = cloneMedia(media) + ghostMedia.value = items.filter(isGalleryItem) + ghostVoiceNotes.value = items + .filter(item => item.collection === 'voice_note') + .sort((a, b) => (a.createdAt ?? 0) - (b.createdAt ?? 0)) + } + function persistEventMedia(eventId, userId, media) { const mediaItems = cloneMedia(media) const persist = async () => { @@ -418,8 +742,7 @@ export const useEventsStore = defineStore('events', () => { } async function loadEventMedia(event) { - const fallback = Array.isArray(event.media) ? cloneMedia(event.media) : [] - ghostMedia.value = fallback + applyMediaToDraft(Array.isArray(event.media) ? event.media : []) try { const stored = await db.eventMedia @@ -429,9 +752,20 @@ export const useEventsStore = defineStore('events', () => { if (editingEventId.value !== event.id || stored.length === 0) return - ghostMedia.value = stored + // The stored rows are the complete items: for the demo user they also + // carry the blob of a video or a recording, which the event row's + // metadata does not. They therefore always win. + const restored = stored .sort((a, b) => (a.createdAt ?? 0) - (b.createdAt ?? 0)) - .map(({ eventId, userId, ...item }) => item) + .map((item) => Object.fromEntries( + Object.entries(item).filter(([key]) => key !== 'eventId' && key !== 'userId') + )) + + // Restoring stored media is not an edit: without the guard this + // assignment scheduled an autosave (and a PUT) just for opening. + withPersistSuppressed(() => { + applyMediaToDraft(restored) + }) } catch (e) { console.warn('Dexie media load failed:', e) } @@ -443,47 +777,77 @@ export const useEventsStore = defineStore('events', () => { const ghostTitle = ref('') const ghostDate = ref(new Date().toISOString().slice(0, 10)) const ghostLocation = ref('') + const ghostEndDate = ref(null) + const ghostCategory = ref(null) + const ghostPeople = ref([]) + const ghostMilestone = ref(false) const ghostNote = ref('') const ghostImage = ref(null) const ghostKeyImageTitle = ref('') + // Gallery items only (images and videos). Key image and voice note are + // held separately. const ghostMedia = ref([]) + // The event's voice notes, oldest first. + const ghostVoiceNotes = ref([]) + + // Date, emotion and colour are defaults every draft has; only content + // makes a draft worth storing. + const ghostHasContent = computed(() => + ghostTitle.value.trim() !== '' || + ghostNote.value.trim() !== '' || + ghostLocation.value.trim() !== '' || + Boolean(ghostCategory.value) || + ghostPeople.value.length > 0 || + Boolean(ghostImage.value) || + ghostVoiceNotes.value.length > 0 || + ghostMedia.value.length > 0 + ) const ghostEvent = computed(() => ({ id: '__ghost__', - title: ghostTitle.value || 'New Event', + title: ghostTitle.value || 'Neues Event', date: ghostDate.value, + endDate: ghostEndDate.value, location: ghostLocation.value, + category: ghostCategory.value, + people: ghostPeople.value, emotion: ghostEmotion.value, + milestone: ghostMilestone.value, customColor: ghostCustomColor.value, image: ghostImage.value, keyImageTitle: ghostKeyImageTitle.value, - media: ghostMedia.value, + media: draftMedia(), note: ghostNote.value })) const sortedEvents = computed(() => { - return [...events.value].sort((a, b) => new Date(a.date) - new Date(b.date)) + return [...events.value].sort((a, b) => compareCalendarDates(a.date, b.date)) }) function selectEvent(id) { selectedEventId.value = id } - function createEvent() { + function createEvent(overrides = {}) { const now = Date.now() const newEvent = { id: crypto.randomUUID(), userId: authStore.currentUserId, - title: 'Neues Event', + title: '', date: todayLocalDate(), + endDate: null, location: '', + category: null, + people: [], emotion: 0, + milestone: false, customColor: null, gradientPreset: null, image: null, keyImageTitle: '', media: [], note: '', + ...overrides, syncStatus: 'local', createdAt: now, updatedAt: now @@ -491,34 +855,114 @@ export const useEventsStore = defineStore('events', () => { events.value.push(newEvent) dbPut(newEvent) - if (isRemoteUser.value) { - createRemoteEvent(newEvent) - } + queueEventWrite('create', newEvent) return newEvent } function openPanel(eventId = null) { - const panelEventId = eventId || createEvent().id + // The pending autosave still belongs to the event that is open right + // now. It has to be written before the panel switches, otherwise the + // timer fires against the newly selected event and the edit is lost. + flushPersistToEvent() + flushCaptionSaves() - if (panelEventId) { - editingEventId.value = panelEventId - const event = events.value.find((e) => e.id === panelEventId) - if (event) { - skipNextPersist = true + // A fresh panel starts without a verdict; otherwise it would show the + // previous event's "Gespeichert" before anything was even edited. + saveState.value = 'idle' + + const event = eventId ? events.value.find((e) => e.id === eventId) : null + + if (event) { + editingEventId.value = event.id + withPersistSuppressed(() => { ghostTitle.value = event.title ghostDate.value = event.date ghostLocation.value = event.location || '' + ghostEndDate.value = event.endDate || null + ghostCategory.value = event.category || null + ghostPeople.value = Array.isArray(event.people) ? [...event.people] : [] + ghostMilestone.value = Boolean(event.milestone) ghostEmotion.value = event.emotion ghostCustomColor.value = event.customColor ghostImage.value = event.keyImagePreviewUrl || event.image || null ghostKeyImageTitle.value = event.keyImageTitle || '' - loadEventMedia(event) ghostNote.value = event.note + loadEventMedia(event) + }) + if (isRemoteUser.value) { + queue.hasQueuedWrites(event.id).then((waiting) => { + if (waiting && editingEventId.value === event.id && saveState.value === 'idle') { + saveState.value = 'pending' + } + }) } + } else { + // A new event starts as a draft. Nothing is stored until the draft + // has content (see ghostHasContent), so opening and closing the panel + // no longer leaves an empty "Neues Event" behind. + editingEventId.value = null + selectedEventId.value = null + resetGhost() } panelOpen.value = true } + function withPersistSuppressed(run) { + suppressPersist = true + run() + nextTick(() => { + suppressPersist = false + }) + } + + function resetGhost() { + withPersistSuppressed(() => { + ghostTitle.value = '' + ghostDate.value = todayLocalDate() + ghostLocation.value = '' + ghostEndDate.value = null + ghostCategory.value = null + ghostPeople.value = [] + ghostMilestone.value = false + ghostEmotion.value = 0 + ghostCustomColor.value = null + ghostImage.value = null + ghostKeyImageTitle.value = '' + ghostMedia.value = [] + ghostVoiceNotes.value = [] + ghostNote.value = '' + }) + } + + // Turns the open draft into a stored event and returns its id. Safe to + // call repeatedly; it only creates once. + function ensureEditingEvent() { + if (editingEventId.value) return editingEventId.value + if (!panelOpen.value) return null + + const created = createEvent({ + title: ghostTitle.value, + date: ghostDate.value, + endDate: ghostEndDate.value, + location: ghostLocation.value, + category: ghostCategory.value, + people: [...ghostPeople.value], + emotion: ghostEmotion.value, + milestone: ghostMilestone.value, + customColor: ghostCustomColor.value, + image: ghostImage.value, + keyImageTitle: ghostKeyImageTitle.value, + media: mediaMeta(draftMedia()), + note: ghostNote.value + }) + editingEventId.value = created.id + selectedEventId.value = created.id + persistEventMedia(created.id, created.userId, draftMedia()) + // Remote users get their verdict from the sync queue. + if (!isRemoteUser.value) saveState.value = 'saved' + return created.id + } + // Auto-save: persist ghost → event in edit mode function persistToEvent() { if (!editingEventId.value) return @@ -528,13 +972,19 @@ export const useEventsStore = defineStore('events', () => { ...events.value[idx], title: ghostTitle.value, date: ghostDate.value, + endDate: ghostEndDate.value, location: ghostLocation.value, + category: ghostCategory.value, + people: [...ghostPeople.value], emotion: ghostEmotion.value, + milestone: ghostMilestone.value, customColor: ghostCustomColor.value, - gradientPreset: null, + // Carried over, not reset: hard-coding null here rewrote the stored + // value to null on every single autosave. + gradientPreset: events.value[idx].gradientPreset ?? null, image: ghostImage.value, keyImageTitle: ghostKeyImageTitle.value, - media: mediaMeta(ghostMedia.value), + media: mediaMeta(draftMedia(events.value[idx].media)), note: ghostNote.value, syncStatus: 'modified', userId: events.value[idx].userId ?? authStore.currentUserId, @@ -542,9 +992,11 @@ export const useEventsStore = defineStore('events', () => { } events.value[idx] = updated dbPut(updated) - persistEventMedia(updated.id, updated.userId, ghostMedia.value) + persistEventMedia(updated.id, updated.userId, draftMedia(events.value[idx].media)) if (isRemoteUser.value) { - updateRemoteEvent(updated) + queueEventWrite('update', updated) + } else { + saveState.value = 'saved' } } @@ -573,18 +1025,24 @@ export const useEventsStore = defineStore('events', () => { } watch( - [ghostTitle, ghostDate, ghostLocation, ghostEmotion, ghostCustomColor, ghostImage, ghostKeyImageTitle, ghostMedia, ghostNote], + [ghostTitle, ghostDate, ghostEndDate, ghostLocation, ghostCategory, ghostPeople, ghostMilestone, ghostEmotion, ghostCustomColor, ghostImage, ghostKeyImageTitle, ghostMedia, ghostVoiceNotes, ghostNote], () => { - if (skipNextPersist) { - skipNextPersist = false + if (suppressPersist || !panelOpen.value) return + + if (!editingEventId.value) { + // The draft is stored with its first real entry; the event created + // here already carries the current draft values. + if (ghostHasContent.value) ensureEditingEvent() return } + schedulePersistToEvent() } ) function closePanel() { flushPersistToEvent() + flushCaptionSaves() panelOpen.value = false editingEventId.value = null @@ -593,77 +1051,126 @@ export const useEventsStore = defineStore('events', () => { function deleteEvent(id) { if (editingEventId.value === id) { + // Drop the pending autosave: it would otherwise re-create the event + // that is being deleted. if (persistTimer) clearTimeout(persistTimer) persistTimer = null } + + const removed = events.value.find((e) => e.id === id) events.value = events.value.filter((e) => e.id !== id) dbDelete(id) - if (isRemoteUser.value) { - deleteRemoteEvent(id) - } + if (removed) queueEventWrite('delete', removed) closePanel() } - async function uploadGhostKeyImage(file) { + /** + * Remote users: the key image shows at once from its local preview and is + * uploaded through the queue. Returns the local media item, or null for + * the demo user, who keeps the image as a data URL (see EventPanel). + */ + async function uploadGhostKeyImage(file, { preview = null } = {}) { if (!isRemoteUser.value) return null - const media = await uploadRemoteMedia(file, 'key_image') - if (!media) return null + // An upload needs an event to attach to. + const targetEventId = ensureEditingEvent() + if (!targetEventId) return null - ghostImage.value = media.originalUrl - if (!ghostKeyImageTitle.value) { - ghostKeyImageTitle.value = 'Key Image' + const idx = events.value.findIndex((event) => event.id === targetEventId) + const previousKeyImage = idx !== -1 + ? (events.value[idx].media || []).find(item => item.collection === 'key_image') + : null + // The server replaces the key image on upload; a queued upload for the + // old one only needs to be dropped. + if (previousKeyImage && isLocalMediaId(previousKeyImage.id)) { + queueWrite({ eventId: targetEventId, action: 'delete_media', payload: { mediaId: previousKeyImage.id } }) + db.eventMedia.delete(previousKeyImage.id).catch(() => {}) } - const idx = events.value.findIndex((event) => event.id === editingEventId.value) + const item = await queueMediaUpload(targetEventId, file, 'key_image') + const localImage = preview || (item.blob ? URL.createObjectURL(item.blob) : null) + + if (editingEventId.value === targetEventId) { + withPersistSuppressed(() => { + ghostImage.value = localImage + if (!ghostKeyImageTitle.value) ghostKeyImageTitle.value = 'Key Image' + }) + } + + const current = events.value.findIndex((event) => event.id === targetEventId) + if (current !== -1) { + events.value[current] = { + ...events.value[current], + image: localImage, + keyImagePreviewUrl: null, + keyImageOriginalUrl: null, + keyImageTitle: events.value[current].keyImageTitle || 'Key Image', + media: [ + ...(events.value[current].media || []).filter(entry => entry.collection !== 'key_image'), + ...mediaMeta([item]) + ], + updatedAt: Date.now() + } + dbPut(events.value[current]) + db.eventMedia.put({ ...item, eventId: targetEventId, userId: events.value[current].userId }) + .catch(e => console.warn('Dexie media persist failed:', e)) + // The title may have been set just now; the image path itself comes + // from the upload. + queueEventWrite('update', events.value[current]) + } + + return item + } + + /** + * Remote users: add gallery files to the event. Each entry is + * `{ file, durationMs }` (a bare File works too). The items appear at once + * (marked pending) and are uploaded through the queue. Returns the local + * items, or [] for the demo user, whose files stay in the browser (see + * EventPanel). + */ + async function uploadGhostMedia(entries) { + if (!isRemoteUser.value) return [] + + const targetEventId = ensureEditingEvent() + if (!targetEventId) return [] + + const items = [] + for (const entry of entries) { + const file = entry?.file ?? entry + if (!file) continue + items.push(await queueMediaUpload(targetEventId, file, 'gallery', { durationMs: entry?.durationMs ?? null })) + } + if (items.length === 0) return [] + + if (editingEventId.value === targetEventId) { + withPersistSuppressed(() => { + ghostMedia.value = [...ghostMedia.value, ...items] + }) + } + + const idx = events.value.findIndex((event) => event.id === targetEventId) if (idx !== -1) { - const nextMedia = [ - ...(events.value[idx].media || []).filter(item => item.collection !== 'key_image'), - media - ] events.value[idx] = { ...events.value[idx], - image: media.thumbnailUrl, - keyImagePreviewUrl: media.previewUrl, - keyImageOriginalUrl: media.originalUrl, - media: nextMedia, + media: [...(events.value[idx].media || []), ...mediaMeta(items)], updatedAt: Date.now() } dbPut(events.value[idx]) + db.eventMedia.bulkPut(items.map(item => ({ ...item, eventId: targetEventId, userId: events.value[idx].userId }))) + .catch(e => console.warn('Dexie media persist failed:', e)) } - return media - } - - async function uploadGhostMedia(files) { - if (!isRemoteUser.value) return [] - - const uploaded = [] - for (const file of files) { - const media = await uploadRemoteMedia(file, 'gallery') - if (media) uploaded.push(media) - } - - if (uploaded.length > 0) { - ghostMedia.value = [...ghostMedia.value, ...uploaded] - const idx = events.value.findIndex((event) => event.id === editingEventId.value) - if (idx !== -1) { - events.value[idx] = { - ...events.value[idx], - media: [...(events.value[idx].media || []), ...uploaded], - updatedAt: Date.now() - } - dbPut(events.value[idx]) - } - } - - return uploaded + return items } async function deleteGhostMedia(mediaId) { - await deleteRemoteMedia(mediaId) + const targetEventId = editingEventId.value + if (!targetEventId) return - const idx = events.value.findIndex((event) => event.id === editingEventId.value) + queueWrite({ eventId: targetEventId, action: 'delete_media', payload: { mediaId } }) + + const idx = events.value.findIndex((event) => event.id === targetEventId) if (idx !== -1) { events.value[idx] = { ...events.value[idx], @@ -672,18 +1179,134 @@ export const useEventsStore = defineStore('events', () => { } dbPut(events.value[idx]) } + db.eventMedia.delete(mediaId).catch(() => {}) + } + + /** + * Remote users: add a recording as a further voice note. It appears at + * once (marked pending) and is uploaded through the queue. Returns the + * local item, or null for the demo user (see addGhostVoiceNote). + */ + async function uploadGhostVoiceNote(file, { durationMs = null } = {}) { + if (!isRemoteUser.value) return null + + const targetEventId = ensureEditingEvent() + if (!targetEventId) return null + + const item = await queueMediaUpload(targetEventId, file, 'voice_note', { durationMs }) + + if (editingEventId.value === targetEventId) { + withPersistSuppressed(() => { + ghostVoiceNotes.value = [...ghostVoiceNotes.value, item] + }) + } + + const idx = events.value.findIndex((event) => event.id === targetEventId) + if (idx !== -1) { + events.value[idx] = { + ...events.value[idx], + media: [...(events.value[idx].media || []), ...mediaMeta([item])], + updatedAt: Date.now() + } + dbPut(events.value[idx]) + db.eventMedia.put({ ...item, eventId: targetEventId, userId: events.value[idx].userId }) + .catch(e => console.warn('Dexie media persist failed:', e)) + } + + return item + } + + /** Demo user: keep a recording in the browser as a voice note of the draft. */ + function addGhostVoiceNote(item) { + ghostVoiceNotes.value = [...ghostVoiceNotes.value, { + collection: 'voice_note', + type: 'audio', + caption: '', + createdAt: Date.now(), + ...item + }] + } + + async function deleteGhostVoiceNote(id) { + const targetEventId = editingEventId.value + const note = ghostVoiceNotes.value.find(item => item.id === id) + if (!note) return + + const pendingCaption = captionSaves.get(id) + if (pendingCaption) { + clearTimeout(pendingCaption.timer) + captionSaves.delete(id) + } + + // Autosave picks the shortened list up and rewrites the event's media. + ghostVoiceNotes.value = ghostVoiceNotes.value.filter(item => item.id !== id) + if (!targetEventId) return + + queueWrite({ eventId: targetEventId, action: 'delete_media', payload: { mediaId: id } }) + + const idx = events.value.findIndex((event) => event.id === targetEventId) + if (idx !== -1) { + events.value[idx] = { + ...events.value[idx], + media: (events.value[idx].media || []).filter(item => item.id !== id), + updatedAt: Date.now() + } + dbPut(events.value[idx]) + } + db.eventMedia.delete(id).catch(() => {}) + } + + // Caption edits are sent per note, debounced while typing, and flushed + // when the panel switches or closes so the last keystrokes are not lost. + const CAPTION_SAVE_DELAY_MS = 600 + const captionSaves = new Map() + + function setGhostVoiceNoteCaption(id, caption) { + const text = String(caption ?? '').slice(0, 500) + const note = ghostVoiceNotes.value.find(item => item.id === id) + if (!note || (note.caption ?? '') === text) return + + // Triggers the autosave, which persists the caption with the event. + ghostVoiceNotes.value = ghostVoiceNotes.value.map(item => ( + item.id === id ? { ...toRaw(item), caption: text } : item + )) + + if (!isRemoteUser.value || !editingEventId.value) return + + const eventId = editingEventId.value + const pending = captionSaves.get(id) + if (pending) clearTimeout(pending.timer) + + const run = () => { + captionSaves.delete(id) + // For a note still uploading the queue folds the caption into the + // upload itself. + queueWrite({ eventId, action: 'update_media', payload: { mediaId: id, caption: text } }) + } + captionSaves.set(id, { run, timer: setTimeout(run, CAPTION_SAVE_DELAY_MS) }) + } + + function flushCaptionSaves() { + for (const { run, timer } of captionSaves.values()) { + clearTimeout(timer) + run() + } } async function deleteGhostKeyImage() { - const idx = events.value.findIndex((event) => event.id === editingEventId.value) - const keyImage = idx !== -1 - ? (events.value[idx].media || []).find(item => item.collection === 'key_image') + const targetEventId = editingEventId.value + const startIdx = events.value.findIndex((event) => event.id === targetEventId) + const keyImage = startIdx !== -1 + ? (events.value[startIdx].media || []).find(item => item.collection === 'key_image') : null - if (keyImage?.id) { - await deleteRemoteMedia(keyImage.id) + if (keyImage?.id && targetEventId) { + queueWrite({ eventId: targetEventId, action: 'delete_media', payload: { mediaId: keyImage.id } }) + db.eventMedia.delete(keyImage.id).catch(() => {}) } + const idx = events.value.findIndex((event) => event.id === targetEventId) + if (idx !== -1) { events.value[idx] = { ...events.value[idx], @@ -710,9 +1333,17 @@ export const useEventsStore = defineStore('events', () => { init() watch(() => authStore.currentUserId, () => { + // Invalidate everything still in flight for the previous user before + // anything for the new one starts. + sessionGeneration += 1 + loadError.value = null + lastError.value = null + saveState.value = 'idle' + panelOpen.value = false editingEventId.value = null selectedEventId.value = null + events.value = [] if (persistTimer) { clearTimeout(persistTimer) persistTimer = null @@ -723,6 +1354,15 @@ export const useEventsStore = defineStore('events', () => { return { events, isLoaded, + loadError, + lastError, + saveState, + isRemoteUser, + isOnline, + isSyncing, + pendingSyncCount: pendingCount, + syncNow, + retrySave, selectedEventId, panelOpen, editingEventId, @@ -731,21 +1371,32 @@ export const useEventsStore = defineStore('events', () => { ghostTitle, ghostDate, ghostLocation, + ghostEndDate, + ghostCategory, + ghostPeople, + ghostMilestone, ghostNote, ghostImage, ghostKeyImageTitle, ghostMedia, + ghostVoiceNotes, ghostEvent, + ghostHasContent, sortedEvents, selectEvent, openPanel, createEvent, + ensureEditingEvent, closePanel, deleteEvent, uploadGhostKeyImage, uploadGhostMedia, deleteGhostMedia, deleteGhostKeyImage, + uploadGhostVoiceNote, + addGhostVoiceNote, + deleteGhostVoiceNote, + setGhostVoiceNoteCaption, saveGhostNow, getGlowColor } diff --git a/frontend/src/stores/example-store.js b/frontend/src/stores/example-store.js deleted file mode 100644 index 041324e..0000000 --- a/frontend/src/stores/example-store.js +++ /dev/null @@ -1,21 +0,0 @@ -import { defineStore, acceptHMRUpdate } from 'pinia' - -export const useCounterStore = defineStore('counter', { - state: () => ({ - counter: 0 - }), - - getters: { - doubleCount: (state) => state.counter * 2 - }, - - actions: { - increment() { - this.counter++ - } - } -}) - -if (import.meta.hot) { - import.meta.hot.accept(acceptHMRUpdate(useCounterStore, import.meta.hot)) -} diff --git a/frontend/src/stores/images/photo-1506905925346-21bda4d32df4.jpeg b/frontend/src/stores/images/photo-1506905925346-21bda4d32df4.jpeg deleted file mode 100644 index de7378e..0000000 Binary files a/frontend/src/stores/images/photo-1506905925346-21bda4d32df4.jpeg and /dev/null differ diff --git a/frontend/src/stores/images/photo-1530103862676-de8c9debad1d.jpeg b/frontend/src/stores/images/photo-1530103862676-de8c9debad1d.jpeg deleted file mode 100644 index 1d998da..0000000 Binary files a/frontend/src/stores/images/photo-1530103862676-de8c9debad1d.jpeg and /dev/null differ diff --git a/frontend/src/stores/images/photo-1534067783941-51c9c23ecefd.jpeg b/frontend/src/stores/images/photo-1534067783941-51c9c23ecefd.jpeg deleted file mode 100644 index 1ac70a7..0000000 Binary files a/frontend/src/stores/images/photo-1534067783941-51c9c23ecefd.jpeg and /dev/null differ diff --git a/frontend/src/stores/settings.js b/frontend/src/stores/settings.js index f219222..4e9ac24 100644 --- a/frontend/src/stores/settings.js +++ b/frontend/src/stores/settings.js @@ -1,6 +1,7 @@ import { defineStore } from 'pinia' import { ref, watch } from 'vue' import { apiJson } from 'src/services/apiClient' +import { WAVE_PRESETS, findWavePreset, isBuiltInPresetId } from 'src/config/wavePresets' import { useAuthStore } from 'stores/auth' const STORAGE_KEY_PREFIX = 'thatsme-settings' @@ -11,24 +12,25 @@ export const DEFAULT_TIMELINE_ZOOM = 1 export const DEFAULT_TIMELINE_SCROLL_LEFT = null export const ACCENT_COLORS = [ - { label: 'Base', value: 'base', hex: '#737373' }, - { label: 'Red', value: 'red', hex: '#ef4444' }, + { label: 'Gold', value: 'gold', hex: '#f5c26b' }, + { label: 'Grau', value: 'grey', hex: '#737373' }, + { label: 'Rot', value: 'red', hex: '#ef4444' }, { label: 'Orange', value: 'orange', hex: '#f97316' }, - { label: 'Amber', value: 'amber', hex: '#f59e0b' }, - { label: 'Yellow', value: 'yellow', hex: '#eab308' }, - { label: 'Lime', value: 'lime', hex: '#84cc16' }, - { label: 'Green', value: 'green', hex: '#22c55e' }, - { label: 'Emerald', value: 'emerald', hex: '#10b981' }, - { label: 'Teal', value: 'teal', hex: '#14b8a6' }, + { label: 'Bernstein', value: 'amber', hex: '#f59e0b' }, + { label: 'Gelb', value: 'yellow', hex: '#eab308' }, + { label: 'Limette', value: 'lime', hex: '#84cc16' }, + { label: 'Grün', value: 'green', hex: '#22c55e' }, + { label: 'Smaragd', value: 'emerald', hex: '#10b981' }, + { label: 'Petrol', value: 'teal', hex: '#14b8a6' }, { label: 'Cyan', value: 'cyan', hex: '#06b6d4' }, - { label: 'Sky', value: 'sky', hex: '#0ea5e9' }, - { label: 'Blue', value: 'blue', hex: '#3b82f6' }, + { label: 'Himmelblau', value: 'sky', hex: '#0ea5e9' }, + { label: 'Blau', value: 'blue', hex: '#3b82f6' }, { label: 'Indigo', value: 'indigo', hex: '#6366f1' }, - { label: 'Violet', value: 'violet', hex: '#8b5cf6' }, - { label: 'Purple', value: 'purple', hex: '#a855f7' }, + { label: 'Violett', value: 'violet', hex: '#8b5cf6' }, + { label: 'Purpur', value: 'purple', hex: '#a855f7' }, { label: 'Fuchsia', value: 'fuchsia', hex: '#d946ef' }, { label: 'Pink', value: 'pink', hex: '#ec4899' }, - { label: 'Rose', value: 'rose', hex: '#f43f5e' } + { label: 'Rosé', value: 'rose', hex: '#f43f5e' } ] export const LANGUAGES = [ @@ -36,6 +38,7 @@ export const LANGUAGES = [ { label: 'English', value: 'en' } ] +// The defaults are the brand look ("Gold" preset): gold lines on ink. const FLOATING_LINES_DEFAULTS = { // Linien speed: 1.0, @@ -48,20 +51,20 @@ const FLOATING_LINES_DEFAULTS = { bezierCurvature: 0.2, circleRadius: 75, glowSize: 18, - glowStrength: 1.5, - lineBrightness: 1.0, + glowStrength: 1.6, + lineBrightness: 0.9, // Hintergrund - bgCenter: '#0a0514', - bgEdge: '#000000', - gradientStops: '#e947f5\n#2f4ba2\n#0a0a12', + bgCenter: '#2f3032', + bgEdge: '#1a1b1c', + gradientStops: '#f5c26b\n#d59040\n#3a2a14', backgroundImage: '', // Horizont - horizonMode: 'off', // 'off' | 'fog' | 'split' | 'glow' - horizonOpacity: 0.5, // Nebel + Glow: Helligkeit + horizonMode: 'glow', // 'off' | 'fog' | 'split' | 'glow' + horizonOpacity: 0.25, // Nebel + Glow: Helligkeit horizonBlend: 0.2, // Trennung: 0=scharf 1=weich // Labels labelSize: 'small', // 'small' | 'medium' | 'large' | 'xlarge' - labelColor: '#ffffff', + labelColor: '#f7f3ea', labelOpacity: 0.75, labelConnectorLength: 0.2, // Dot-Rahmen @@ -90,9 +93,12 @@ function clone(value) { return JSON.parse(JSON.stringify(value)) } +// 'default' and 'base' are what accounts from before the brand palette hold: +// nobody chose that grey, it was simply the old default. Both become gold; +// a deliberate grey is stored as 'grey' since 2026-09-25. function normalizeAccentColor(value) { - if (value === 'default') return 'base' - return ACCENT_COLORS.some(c => c.value === value) ? value : 'base' + if (value === 'default' || value === 'base') return 'gold' + return ACCENT_COLORS.some(c => c.value === value) ? value : 'gold' } function hexToRgb(hex) { @@ -128,6 +134,10 @@ export const useSettingsStore = defineStore('settings', () => { const initialSettings = initialActivePreset?.settings ?? stored let persistTimer = null let isApplyingSettings = false + // Incremented on every user switch so a late settings response can be + // recognised as belonging to a session that has already ended. + let settingsGeneration = 0 + const lastError = ref(null) const theme = ref(initialSettings?.theme ?? 'light') const floatingLines = ref({ @@ -137,7 +147,7 @@ export const useSettingsStore = defineStore('settings', () => { // App preferences const appearance = ref(initialSettings?.appearance ?? 'system') // 'system' | 'light' | 'dark' - const accentColor = ref(normalizeAccentColor(initialSettings?.accentColor ?? 'base')) + const accentColor = ref(normalizeAccentColor(initialSettings?.accentColor ?? 'gold')) const language = ref(initialSettings?.language ?? 'de') const emotionGradientStart = ref(initialSettings?.emotionGradientStart ?? DEFAULT_EMOTION_GRADIENT_START) const emotionGradientEnd = ref(initialSettings?.emotionGradientEnd ?? DEFAULT_EMOTION_GRADIENT_END) @@ -183,7 +193,7 @@ export const useSettingsStore = defineStore('settings', () => { ...(snapshot?.floatingLines ?? {}) } appearance.value = snapshot?.appearance ?? 'system' - accentColor.value = normalizeAccentColor(snapshot?.accentColor ?? 'base') + accentColor.value = normalizeAccentColor(snapshot?.accentColor ?? 'gold') language.value = snapshot?.language ?? 'de' emotionGradientStart.value = snapshot?.emotionGradientStart ?? DEFAULT_EMOTION_GRADIENT_START emotionGradientEnd.value = snapshot?.emotionGradientEnd ?? DEFAULT_EMOTION_GRADIENT_END @@ -201,13 +211,26 @@ export const useSettingsStore = defineStore('settings', () => { const storedSettings = createStoredSettings() - localStorage.setItem(getStorageKey(authStore.currentUserId), JSON.stringify(storedSettings)) + try { + localStorage.setItem(getStorageKey(authStore.currentUserId), JSON.stringify(storedSettings)) + } catch (error) { + // Private mode, full quota, or a torn-down environment: the remote + // copy below still happens, the local mirror is best effort. + console.warn('Settings could not be written to localStorage:', error) + } if (isRemoteUser()) { + const generation = settingsGeneration + apiJson('/settings', { method: 'PUT', body: { settings: storedSettings } - }).catch(error => console.warn('Remote settings persist failed:', error)) + }).catch((error) => { + console.warn('Remote settings persist failed:', error) + if (generation !== settingsGeneration) return + + lastError.value = 'Die Einstellungen konnten nicht gespeichert werden.' + }) } } @@ -228,28 +251,47 @@ export const useSettingsStore = defineStore('settings', () => { persistTimer = null } + // Everything below belongs to this user. A response arriving after the + // next switch must not overwrite the settings of whoever is active then. + settingsGeneration += 1 + const generation = settingsGeneration + isApplyingSettings = true + lastError.value = null - let nextStored = loadFromStorage(userId) - if (userId && isRemoteUser()) { - try { - nextStored = await loadRemoteSettings() - if (nextStored) { - localStorage.setItem(getStorageKey(userId), JSON.stringify(nextStored)) + try { + let nextStored = loadFromStorage(userId) + + if (userId && isRemoteUser()) { + try { + const remoteSettings = await loadRemoteSettings() + if (generation !== settingsGeneration) return + + nextStored = remoteSettings + if (nextStored) { + localStorage.setItem(getStorageKey(userId), JSON.stringify(nextStored)) + } + } catch (error) { + console.warn('Remote settings load failed:', error) + if (generation !== settingsGeneration) return + + // The locally cached settings from `loadFromStorage` stay in + // effect; the user is told the server copy could not be read. + lastError.value = 'Die Einstellungen konnten nicht geladen werden. Angezeigt wird der lokale Stand.' } - } catch (error) { - console.warn('Remote settings load failed:', error) } + + presets.value = nextStored?.presets ?? [] + activePresetId.value = nextStored?.activePresetId ?? null + + const activePreset = presets.value.find(preset => preset.id === activePresetId.value) + applySnapshot(activePreset?.settings ?? nextStored) + timelineScrollLeft.value = nextStored?.timelineScrollLeft ?? DEFAULT_TIMELINE_SCROLL_LEFT + } finally { + // Must run on every exit path: leaving this true would silently + // disable persisting for the rest of the session. + isApplyingSettings = false } - - presets.value = nextStored?.presets ?? [] - activePresetId.value = nextStored?.activePresetId ?? null - - const activePreset = presets.value.find(preset => preset.id === activePresetId.value) - applySnapshot(activePreset?.settings ?? nextStored) - timelineScrollLeft.value = nextStored?.timelineScrollLeft ?? DEFAULT_TIMELINE_SCROLL_LEFT - - isApplyingSettings = false } watch([theme, floatingLines, appearance, accentColor, language, emotionGradientStart, emotionGradientEnd, timelineZoom, timelineScrollLeft, showFps, presets, activePresetId], schedulePersist, { deep: true }) @@ -304,6 +346,7 @@ export const useSettingsStore = defineStore('settings', () => { function resetFloatingLines() { floatingLines.value = { ...FLOATING_LINES_DEFAULTS } + activePresetId.value = null } function resetEmotionGradient() { @@ -338,7 +381,21 @@ export const useSettingsStore = defineStore('settings', () => { return savedPreset } + /** + * Apply a built-in or a user preset. Built-in presets change the wave + * only; a user preset restores the full snapshot it was saved with. + */ function applyPreset(presetId) { + if (isBuiltInPresetId(presetId)) { + const builtIn = findWavePreset(presetId) + if (!builtIn) return false + + activePresetId.value = builtIn.id + floatingLines.value = { ...FLOATING_LINES_DEFAULTS, ...builtIn.floatingLines } + persist() + return true + } + const preset = presets.value.find(candidate => candidate.id === presetId) if (!preset) return false @@ -348,9 +405,20 @@ export const useSettingsStore = defineStore('settings', () => { return true } + function deletePreset(presetId) { + if (isBuiltInPresetId(presetId)) return false + const before = presets.value.length + presets.value = presets.value.filter(preset => preset.id !== presetId) + if (presets.value.length === before) return false + if (activePresetId.value === presetId) activePresetId.value = null + persist() + return true + } + applyStoredSettingsForUser(authStore.currentUserId) return { + lastError, theme, floatingLines, appearance, @@ -361,6 +429,7 @@ export const useSettingsStore = defineStore('settings', () => { timelineZoom, timelineScrollLeft, presets, + builtInPresets: WAVE_PRESETS, activePresetId, showFps, toggleTheme, @@ -371,6 +440,7 @@ export const useSettingsStore = defineStore('settings', () => { uploadBackgroundImage, clearBackgroundImage, savePreset, - applyPreset + applyPreset, + deletePreset } }) diff --git a/frontend/src/utils/audioWav.js b/frontend/src/utils/audioWav.js new file mode 100644 index 0000000..8d07b43 --- /dev/null +++ b/frontend/src/utils/audioWav.js @@ -0,0 +1,101 @@ +/** + * Re-encode a recording as 16-bit PCM WAV. + * + * Browsers record into different containers: Chrome and Firefox produce + * WebM/Opus, which Safari cannot play at all. Recordings the server keeps + * are transcoded to AAC there; recordings the demo user keeps in the browser + * have no server, so they are turned into WAV here. WAV plays everywhere. + * + * Mono at 24 kHz: voice needs no more, and a five-minute note stays under + * 15 MB. + */ + +export const WAV_SAMPLE_RATE = 24000 + +/** + * Decode `blob` and return a WAV blob, or null when the browser cannot + * decode the input (then the original is kept). + */ +export async function blobToWav(blob, { sampleRate = WAV_SAMPLE_RATE } = {}) { + if (typeof window === 'undefined') return null + const AudioContextCtor = window.AudioContext || window.webkitAudioContext + const OfflineCtor = window.OfflineAudioContext || window.webkitOfflineAudioContext + if (!AudioContextCtor) return null + + let context + try { + context = new AudioContextCtor() + const decoded = await context.decodeAudioData(await blob.arrayBuffer()) + const mono = await downmix(decoded, sampleRate, OfflineCtor) + return new Blob([encodeWav(mono, sampleRate)], { type: 'audio/wav' }) + } catch (error) { + console.warn('WAV conversion failed:', error) + return null + } finally { + context?.close?.().catch(() => {}) + } +} + +// Mixes to one channel and resamples through an OfflineAudioContext. +async function downmix(buffer, sampleRate, OfflineCtor) { + const length = Math.ceil(buffer.duration * sampleRate) + if (!OfflineCtor || length === 0) { + return mixChannels(buffer) + } + const offline = new OfflineCtor(1, length, sampleRate) + const source = offline.createBufferSource() + source.buffer = buffer + source.connect(offline.destination) + source.start(0) + const rendered = await offline.startRendering() + return rendered.getChannelData(0) +} + +function mixChannels(buffer) { + const channels = buffer.numberOfChannels + const out = new Float32Array(buffer.length) + for (let c = 0; c < channels; c++) { + const data = buffer.getChannelData(c) + for (let i = 0; i < data.length; i++) out[i] += data[i] / channels + } + return out +} + +/** Float samples (-1..1) to a complete RIFF/WAVE file, 16-bit mono. */ +export function encodeWav(samples, sampleRate) { + const bytesPerSample = 2 + const dataSize = samples.length * bytesPerSample + const buffer = new ArrayBuffer(44 + dataSize) + const view = new DataView(buffer) + + writeAscii(view, 0, 'RIFF') + view.setUint32(4, 36 + dataSize, true) + writeAscii(view, 8, 'WAVE') + writeAscii(view, 12, 'fmt ') + view.setUint32(16, 16, true) // fmt chunk size + view.setUint16(20, 1, true) // PCM + view.setUint16(22, 1, true) // mono + view.setUint32(24, sampleRate, true) + view.setUint32(28, sampleRate * bytesPerSample, true) // byte rate + view.setUint16(32, bytesPerSample, true) // block align + view.setUint16(34, 16, true) // bits per sample + writeAscii(view, 36, 'data') + view.setUint32(40, dataSize, true) + + let offset = 44 + for (let i = 0; i < samples.length; i++, offset += bytesPerSample) { + const clamped = Math.max(-1, Math.min(1, samples[i])) + view.setInt16(offset, clamped < 0 ? clamped * 0x8000 : clamped * 0x7fff, true) + } + return buffer +} + +function writeAscii(view, offset, text) { + for (let i = 0; i < text.length; i++) view.setUint8(offset + i, text.charCodeAt(i)) +} + +/** Whether Safari would refuse this recording as it is. */ +export function needsWavForPlayback(mimeType) { + const type = String(mimeType || '').toLowerCase() + return type.includes('webm') || type.includes('ogg') +} diff --git a/frontend/src/utils/brand.js b/frontend/src/utils/brand.js new file mode 100644 index 0000000..55c8667 --- /dev/null +++ b/frontend/src/utils/brand.js @@ -0,0 +1,58 @@ +/** + * Brand helpers: which logo variant reads well on the current background. + * + * The logo exists as "positiv" (ink lettering, for light ground) and + * "negativ" (paper lettering, for dark ground). The LifeWave background is + * user-configured, so the choice is made from the actual colours rather + * than from the UI theme alone. + */ + +/** Parse #rgb or #rrggbb into [r, g, b] (0..255); null when unparseable. */ +export function hexToRgb(hex) { + if (typeof hex !== 'string') return null + const value = hex.trim().replace(/^#/, '') + const full = value.length === 3 + ? value.split('').map(c => c + c).join('') + : value + if (!/^[0-9a-f]{6}$/i.test(full)) return null + return [0, 2, 4].map(i => parseInt(full.slice(i, i + 2), 16)) +} + +/** WCAG relative luminance, 0 (black) .. 1 (white). */ +export function relativeLuminance(hex) { + const rgb = hexToRgb(hex) + if (!rgb) return null + const [r, g, b] = rgb.map((channel) => { + const c = channel / 255 + return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4) + }) + return 0.2126 * r + 0.7152 * g + 0.0722 * b +} + +/** + * Pick the logo variant for the header. + * + * With a background photo the colours are unknown, so the UI theme decides. + * Otherwise the wave's edge colour counts most: it is what sits behind the + * header (in "split" horizon mode it is literally the top half), with the + * centre colour as a smaller influence. + * + * @param {{ bgEdge?: string, bgCenter?: string, backgroundImage?: string, isDark?: boolean }} input + * @returns {'positive' | 'negative'} + */ +export function logoVariantForBackground({ bgEdge, bgCenter, backgroundImage, isDark = true } = {}) { + if (backgroundImage) return isDark ? 'negative' : 'positive' + + const edge = relativeLuminance(bgEdge) + const centre = relativeLuminance(bgCenter) + if (edge === null && centre === null) return isDark ? 'negative' : 'positive' + + const luminance = edge === null + ? centre + : centre === null + ? edge + : edge * 0.8 + centre * 0.2 + + // Mid-grey is ~0.21 in linear luminance; anything brighter is "light". + return luminance > 0.25 ? 'positive' : 'negative' +} diff --git a/frontend/src/utils/calendarDate.js b/frontend/src/utils/calendarDate.js new file mode 100644 index 0000000..086c21d --- /dev/null +++ b/frontend/src/utils/calendarDate.js @@ -0,0 +1,62 @@ +/** + * Helpers for pure calendar dates in `YYYY-MM-DD` form. + * + * An event date carries no time and no timezone. `new Date('2026-01-01')` is + * nevertheless parsed as *UTC* midnight, so reading it back with the local + * getters shifts the day backwards everywhere west of UTC — in + * America/Los_Angeles that date renders as 31.12.2025, which also moves the + * event into the wrong month and year band on the timeline. + * + * Building the Date from its parts makes the constructor interpret them in + * local time, so day, month and year always match the stored string. + */ + +const CALENDAR_DATE = /^(\d{4})-(\d{2})-(\d{2})/ + +/** + * @param {string} value `YYYY-MM-DD` (a longer ISO string is accepted; the + * time portion is ignored on purpose) + * @returns {Date|null} local-midnight Date, or null when unparseable + */ +export function parseCalendarDate (value) { + const match = CALENDAR_DATE.exec(String(value ?? '')) + if (!match) return null + + const date = new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3])) + return Number.isNaN(date.getTime()) ? null : date +} + +/** + * Sort comparator for calendar date strings. Lexical order equals + * chronological order for `YYYY-MM-DD`, so no Date is allocated at all. + */ +export function compareCalendarDates (a, b) { + return String(a ?? '').localeCompare(String(b ?? '')) +} + +/** + * @returns {number|null} the calendar year, e.g. 2026 + */ +export function calendarYear (value) { + return parseCalendarDate(value)?.getFullYear() ?? null +} + +/** + * @returns {number|null} the zero-based calendar month, e.g. 0 for January + */ +export function calendarMonthIndex (value) { + const date = parseCalendarDate(value) + return date ? date.getMonth() : null +} + +/** + * @returns {string} localised date, or the input unchanged when unparseable + */ +export function formatCalendarDate (value, locale = 'de-DE', options = { + day: '2-digit', + month: 'long', + year: 'numeric' +}) { + const date = parseCalendarDate(value) + return date ? date.toLocaleDateString(locale, options) : String(value ?? '') +} diff --git a/frontend/src/utils/timelinePosition.js b/frontend/src/utils/timelinePosition.js new file mode 100644 index 0000000..c63521b --- /dev/null +++ b/frontend/src/utils/timelinePosition.js @@ -0,0 +1,35 @@ +/** + * Vertical placement of events on the LifeWave. + * + * Emotion runs from -1 (bottom) to +1 (top). The dots' container starts + * TIMELINE_TOP_PX below the viewport top and ends above the bottom bar, so + * the centre and the amplitude below are chosen to leave the same margin + * above the highest and below the lowest dot: the header above is taller + * than the bar below, which is why the centre sits a little under 50 %. + * + * GlowDot (DOM) and the shader points (LifeWaveLayout) both use this, so a + * change here moves both together. + */ + +/** CSS: .timeline-container { top } */ +export const TIMELINE_TOP_PX = 40 + +/** Percent of the container height where emotion 0 sits. */ +export const EMOTION_CENTER_PERCENT = 52.5 + +/** Percent of the container height that emotion ±1 moves away from the centre. */ +export const EMOTION_AMPLITUDE_PERCENT = 32 + +/** + * @param {number} emotion -1..1 + * @returns {number} percent of the container height, measured from the top + */ +export function emotionToYPercent(emotion) { + const clamped = Math.max(-1, Math.min(1, Number(emotion) || 0)) + return EMOTION_CENTER_PERCENT - clamped * EMOTION_AMPLITUDE_PERCENT +} + +/** True when a dot at this height should carry its label above itself. */ +export function isLowerHalf(yPercent) { + return yPercent > EMOTION_CENTER_PERCENT +} diff --git a/frontend/test/vitest/audio-wav.spec.js b/frontend/test/vitest/audio-wav.spec.js new file mode 100644 index 0000000..79518bf --- /dev/null +++ b/frontend/test/vitest/audio-wav.spec.js @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest' +import { WAV_SAMPLE_RATE, encodeWav, needsWavForPlayback } from 'src/utils/audioWav' + +function ascii(view, offset, length) { + return String.fromCharCode(...Array.from({ length }, (_, i) => view.getUint8(offset + i))) +} + +describe('audioWav — WAV for recordings kept in the browser', () => { + it('writes a valid 16-bit mono RIFF header', () => { + const samples = new Float32Array([0, 0.5, -0.5, 1, -1]) + const view = new DataView(encodeWav(samples, WAV_SAMPLE_RATE)) + + expect(ascii(view, 0, 4)).toBe('RIFF') + expect(ascii(view, 8, 4)).toBe('WAVE') + expect(ascii(view, 12, 4)).toBe('fmt ') + expect(view.getUint16(20, true)).toBe(1) // PCM + expect(view.getUint16(22, true)).toBe(1) // mono + expect(view.getUint32(24, true)).toBe(WAV_SAMPLE_RATE) + expect(view.getUint32(28, true)).toBe(WAV_SAMPLE_RATE * 2) + expect(view.getUint16(34, true)).toBe(16) + expect(ascii(view, 36, 4)).toBe('data') + expect(view.getUint32(40, true)).toBe(samples.length * 2) + expect(view.byteLength).toBe(44 + samples.length * 2) + expect(view.getUint32(4, true)).toBe(view.byteLength - 8) + }) + + it('scales and clamps samples to the 16-bit range', () => { + const view = new DataView(encodeWav(new Float32Array([0, 1, -1, 2, -2]), 8000)) + expect(view.getInt16(44, true)).toBe(0) + expect(view.getInt16(46, true)).toBe(32767) + expect(view.getInt16(48, true)).toBe(-32768) + expect(view.getInt16(50, true)).toBe(32767) + expect(view.getInt16(52, true)).toBe(-32768) + }) + + it('converts only the containers Safari refuses', () => { + expect(needsWavForPlayback('audio/webm')).toBe(true) + expect(needsWavForPlayback('audio/ogg;codecs=opus')).toBe(true) + expect(needsWavForPlayback('audio/mp4')).toBe(false) + expect(needsWavForPlayback('audio/wav')).toBe(false) + expect(needsWavForPlayback('')).toBe(false) + }) +}) diff --git a/frontend/test/vitest/auth-session.spec.js b/frontend/test/vitest/auth-session.spec.js new file mode 100644 index 0000000..61d0d9e --- /dev/null +++ b/frontend/test/vitest/auth-session.spec.js @@ -0,0 +1,239 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' +import { flush } from './helpers' + +// This suite runs against the *real* apiClient — the 401 handling and the +// token revoke are exactly what is under test — so only `fetch` is faked. + +function jsonResponse(status, body = null) { + return { + ok: status >= 200 && status < 300, + status, + text: async () => (body === null ? '' : JSON.stringify(body)) + } +} + +describe('session handling — an expired token ends the session (A11)', () => { + let apiClient + let useAuthStore + let fetchMock + + beforeEach(async () => { + setActivePinia(createPinia()) + vi.resetModules() + localStorage.clear() + + fetchMock = vi.fn() + vi.stubGlobal('fetch', fetchMock) + + apiClient = await import('src/services/apiClient') + useAuthStore = (await import('stores/auth')).useAuthStore + + const { db } = await import('src/db') + await db.meta.clear() + }) + + it('clears token, profile and stored auth when the API answers 401', async () => { + const authStore = useAuthStore() + + await apiClient.setToken('token-a') + authStore.currentUserId = 'remote-user-1' + localStorage.setItem('thatsme-auth', JSON.stringify({ userId: 'remote-user-1' })) + + fetchMock.mockResolvedValueOnce(jsonResponse(401, { message: 'Unauthenticated.' })) + await apiClient.apiFetch('/events') + await flush(2) + + expect(await apiClient.getToken()).toBeNull() + expect(authStore.currentUserId).toBeNull() + expect(authStore.isAuthenticated).toBe(false) + // The router guard reads this key; leaving it behind sent the user + // straight back into an app that could no longer save. + expect(localStorage.getItem('thatsme-auth')).toBeNull() + expect(authStore.lastError).toBeTruthy() + }) + + it('ends the session when no token is stored any more (logged out in another tab)', async () => { + const authStore = useAuthStore() + + // Remote user restored from localStorage, but the shared token is gone. + authStore.currentUserId = 'remote-user-1' + localStorage.setItem('thatsme-auth', JSON.stringify({ userId: 'remote-user-1' })) + + await expect(apiClient.apiFetch('/events')).rejects.toMatchObject({ status: 401 }) + await flush(2) + + expect(fetchMock).not.toHaveBeenCalled() + expect(authStore.currentUserId).toBeNull() + expect(localStorage.getItem('thatsme-auth')).toBeNull() + expect(authStore.lastError).toBeTruthy() + }) + + it('does not tear down a newer session because of a late 401 from the old one', async () => { + const authStore = useAuthStore() + + await apiClient.setToken('token-old') + + // The old session's request is answered late; by then a new session has + // already stored its own token. + let resolveRequest + fetchMock.mockImplementationOnce(() => new Promise((resolve) => { resolveRequest = resolve })) + const pending = apiClient.apiFetch('/events') + await flush(1) + + await apiClient.setToken('token-new') + authStore.currentUserId = 'remote-user-2' + localStorage.setItem('thatsme-auth', JSON.stringify({ userId: 'remote-user-2' })) + + resolveRequest(jsonResponse(401, { message: 'Unauthenticated.' })) + await pending + await flush(2) + + expect(await apiClient.getToken()).toBe('token-new') + expect(authStore.currentUserId).toBe('remote-user-2') + expect(localStorage.getItem('thatsme-auth')).not.toBeNull() + }) + + it('leaves a local demo session alone when a stray 401 arrives', async () => { + const authStore = useAuthStore() + + await apiClient.setToken('token-a') + authStore.currentUserId = 'local-demo' + localStorage.setItem('thatsme-auth', JSON.stringify({ userId: 'local-demo' })) + + fetchMock.mockResolvedValueOnce(jsonResponse(401)) + await apiClient.apiFetch('/events') + await flush(2) + + expect(authStore.currentUserId).toBe('local-demo') + }) +}) + +describe('session handling — logout (A11)', () => { + let apiClient + let useAuthStore + let fetchMock + + beforeEach(async () => { + setActivePinia(createPinia()) + vi.resetModules() + localStorage.clear() + + fetchMock = vi.fn().mockResolvedValue(jsonResponse(204)) + vi.stubGlobal('fetch', fetchMock) + + apiClient = await import('src/services/apiClient') + useAuthStore = (await import('stores/auth')).useAuthStore + + const { db } = await import('src/db') + await db.meta.clear() + }) + + it('clears the local session synchronously so the router guard sees it', async () => { + const authStore = useAuthStore() + await apiClient.setToken('token-a') + authStore.currentUserId = 'remote-user-1' + localStorage.setItem('thatsme-auth', JSON.stringify({ userId: 'remote-user-1' })) + + const done = authStore.logout() + + // No await yet: the caller navigates to /login right after this line. + expect(authStore.currentUserId).toBeNull() + expect(localStorage.getItem('thatsme-auth')).toBeNull() + + await done + }) + + it('revokes the token that is being discarded', async () => { + const authStore = useAuthStore() + await apiClient.setToken('token-a') + authStore.currentUserId = 'remote-user-1' + + await authStore.logout() + await flush(2) + + const revoke = fetchMock.mock.calls.find(([url]) => String(url).endsWith('/logout')) + expect(revoke).toBeDefined() + expect(revoke[1].headers.Authorization).toBe('Bearer token-a') + expect(await apiClient.getToken()).toBeNull() + }) + + it('still ends the session when the revoke request fails', async () => { + const authStore = useAuthStore() + await apiClient.setToken('token-a') + authStore.currentUserId = 'remote-user-1' + + fetchMock.mockRejectedValue(new Error('offline')) + + await authStore.logout() + await flush(2) + + expect(authStore.currentUserId).toBeNull() + expect(await apiClient.getToken()).toBeNull() + }) + + it('does not call the API when a local demo user logs out', async () => { + const authStore = useAuthStore() + authStore.currentUserId = 'local-demo' + + await authStore.logout() + await flush(2) + + expect(fetchMock.mock.calls.filter(([url]) => String(url).endsWith('/logout'))).toHaveLength(0) + }) +}) + +describe('image cache is scoped per user (A10)', () => { + let useImageCache + let db + + beforeEach(async () => { + setActivePinia(createPinia()) + vi.resetModules() + vi.stubGlobal('fetch', vi.fn()) + + useImageCache = await import('composables/useImageCache') + db = (await import('src/db')).db + await db.scopedImageCache.clear() + }) + + it('drops the previous user\'s cached images when the scope changes', async () => { + useImageCache.setImageCacheScope('remote-user-1') + + await db.scopedImageCache.put({ + key: 'http://api.test|remote-user-1|/event-media/1/thumb', + url: '/event-media/1/thumb', + userId: 'remote-user-1', + eventId: 'e1', + type: 'thumbnail', + blob: new Blob(['x']), + cachedAt: Date.now() + }) + + expect(await db.scopedImageCache.count()).toBe(1) + + useImageCache.setImageCacheScope('remote-user-2') + await flush(2) + + expect(await db.scopedImageCache.count()).toBe(0) + }) + + it('clears the cache on logout', async () => { + useImageCache.setImageCacheScope('remote-user-1') + + await db.scopedImageCache.put({ + key: 'http://api.test|remote-user-1|/event-media/2/thumb', + url: '/event-media/2/thumb', + userId: 'remote-user-1', + eventId: 'e2', + type: 'thumbnail', + blob: new Blob(['x']), + cachedAt: Date.now() + }) + + useImageCache.setImageCacheScope(null) + await flush(2) + + expect(await db.scopedImageCache.count()).toBe(0) + }) +}) diff --git a/frontend/test/vitest/brand.spec.js b/frontend/test/vitest/brand.spec.js new file mode 100644 index 0000000..859f7c5 --- /dev/null +++ b/frontend/test/vitest/brand.spec.js @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest' +import { hexToRgb, logoVariantForBackground, relativeLuminance } from 'src/utils/brand' + +describe('brand — logo variant follows the LifeWave background', () => { + it('parses short and long hex colours', () => { + expect(hexToRgb('#fff')).toEqual([255, 255, 255]) + expect(hexToRgb('#292a2b')).toEqual([41, 42, 43]) + expect(hexToRgb('nope')).toBeNull() + }) + + it('ranks the brand colours by luminance', () => { + expect(relativeLuminance('#f7f3ea')).toBeGreaterThan(relativeLuminance('#f5c26b')) + expect(relativeLuminance('#f5c26b')).toBeGreaterThan(relativeLuminance('#292a2b')) + }) + + it('uses the negative logo on the default dark wave', () => { + expect(logoVariantForBackground({ bgEdge: '#000000', bgCenter: '#0a0514' })).toBe('negative') + }) + + it('uses the positive logo on a light wave', () => { + expect(logoVariantForBackground({ bgEdge: '#f7f3ea', bgCenter: '#ffffff' })).toBe('positive') + }) + + it('weights the edge colour, which sits behind the header', () => { + // Light edge, dark centre: the header area is light. + expect(logoVariantForBackground({ bgEdge: '#f7f3ea', bgCenter: '#000000' })).toBe('positive') + // Dark edge, light centre: the header area is dark. + expect(logoVariantForBackground({ bgEdge: '#000000', bgCenter: '#f7f3ea' })).toBe('negative') + }) + + it('falls back to the UI theme when a background photo hides the colours', () => { + expect(logoVariantForBackground({ backgroundImage: '/images/bg-image-1.jpg', isDark: true })).toBe('negative') + expect(logoVariantForBackground({ backgroundImage: '/images/bg-image-1.jpg', isDark: false })).toBe('positive') + }) + + it('falls back to the UI theme when no colour can be read', () => { + expect(logoVariantForBackground({ isDark: false })).toBe('positive') + expect(logoVariantForBackground({ bgEdge: 'transparent', isDark: true })).toBe('negative') + }) +}) diff --git a/frontend/test/vitest/calendar-date.spec.js b/frontend/test/vitest/calendar-date.spec.js new file mode 100644 index 0000000..b20a926 --- /dev/null +++ b/frontend/test/vitest/calendar-date.spec.js @@ -0,0 +1,78 @@ +import { describe, expect, it } from 'vitest' +import { + calendarMonthIndex, + calendarYear, + compareCalendarDates, + formatCalendarDate, + parseCalendarDate +} from 'src/utils/calendarDate' + +/** + * The process timezone is fixed at start-up, so a single run cannot cover + * several zones. Instead these tests state the invariant directly: whatever + * zone the runner is in, the parsed day, month and year must equal the + * digits in the string. `npm run test:tz` runs the suite under the zones + * that used to shift the date. + */ +describe('calendar dates are parsed as calendar dates, not as UTC instants (A18)', () => { + it.each([ + ['2026-01-01', 2026, 0, 1], + ['2025-12-31', 2025, 11, 31], + ['2014-08-12', 2014, 7, 12], + ['2000-02-29', 2000, 1, 29] + ])('%s keeps its year, month and day in %s', (value, year, month, day) => { + const date = parseCalendarDate(value) + + expect(date.getFullYear()).toBe(year) + expect(date.getMonth()).toBe(month) + expect(date.getDate()).toBe(day) + }) + + it('exposes the same year and month index used by the timeline labels', () => { + expect(calendarYear('2026-01-01')).toBe(2026) + expect(calendarMonthIndex('2026-01-01')).toBe(0) + }) + + it('formats without shifting into the previous day', () => { + expect(formatCalendarDate('2026-01-01')).toBe('01. Januar 2026') + }) + + it('ignores a trailing time portion instead of letting it move the day', () => { + expect(calendarYear('2026-01-01T00:00:00.000Z')).toBe(2026) + expect(calendarMonthIndex('2026-01-01T00:00:00.000Z')).toBe(0) + }) + + it('returns null for values that are not calendar dates', () => { + expect(parseCalendarDate('')).toBeNull() + expect(parseCalendarDate(null)).toBeNull() + expect(parseCalendarDate('gestern')).toBeNull() + expect(calendarYear(undefined)).toBeNull() + }) + + it('leaves unparseable input untouched when formatting', () => { + expect(formatCalendarDate('gestern')).toBe('gestern') + }) + + it('sorts chronologically without allocating a Date', () => { + const dates = ['2026-01-01', '2014-08-12', '2025-12-31', '2025-12-30'] + + expect([...dates].sort(compareCalendarDates)).toEqual([ + '2014-08-12', '2025-12-30', '2025-12-31', '2026-01-01' + ]) + expect(compareCalendarDates('2025-12-31', '2026-01-01')).toBeLessThan(0) + expect(compareCalendarDates('2026-01-01', '2026-01-01')).toBe(0) + }) + + it('documents the old behaviour it replaces', () => { + // This is the bug: in any zone west of UTC, the local getters of a + // UTC-parsed instant point at the day before. + const offsetMinutes = new Date('2026-01-01').getTimezoneOffset() + const oldDay = new Date('2026-01-01').getDate() + + if (offsetMinutes > 0) { + expect(oldDay).toBe(31) + } + + expect(parseCalendarDate('2026-01-01').getDate()).toBe(1) + }) +}) diff --git a/frontend/test/vitest/components-feedback.spec.js b/frontend/test/vitest/components-feedback.spec.js new file mode 100644 index 0000000..18f803f --- /dev/null +++ b/frontend/test/vitest/components-feedback.spec.js @@ -0,0 +1,270 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' +import { defineComponent, h } from 'vue' +import { mount } from '@vue/test-utils' +import { routerKey } from 'vue-router' +import { createApiRouter, flush, page, remoteEvent } from './helpers' + +let api = createApiRouter() + +vi.mock('src/services/apiClient', () => ({ + API_BASE: 'http://api.test', + apiJson: (...args) => api.apiJson(...args), + apiFetch: vi.fn(), + getToken: vi.fn(async () => 'token'), + setToken: vi.fn(async () => {}), + clearToken: vi.fn(async () => {}), + onUnauthorized: vi.fn(() => () => {}), + revokeToken: vi.fn(async () => {}), + loginRemote: vi.fn(), + ApiError: class ApiError extends Error {} +})) + + +// Static imports on purpose: Quasar installs `$q` per mounted app, and a +// module reset between tests would leave the stores and the components with +// different copies of the same singletons. A fresh Pinia per test isolates +// the store state. +import { db } from 'src/db' +import { useEventsStore } from 'stores/events' +import { useSettingsStore } from 'stores/settings' +import { useAuthStore } from 'stores/auth' +import { useStoreFeedback } from 'composables/useStoreFeedback' +import EventPanel from 'components/EventPanel.vue' + +const EVENTS_LIST = /^\/events\?/ + +function fakeRouter(path = '/') { + return { + currentRoute: { value: { path } }, + replace: vi.fn() + } +} + +/** + * A component that does nothing but run the composable. It stands in for the + * layout, which owns a WebGL scene and cannot be mounted in a DOM-only + * environment. + */ +const FeedbackHost = defineComponent({ + setup() { + useStoreFeedback() + return () => h('div') + } +}) + +describe('useStoreFeedback — store failures become notifications', () => { + let notify + let eventsStore + let settingsStore + let authStore + let router + + beforeEach(async () => { + setActivePinia(createPinia()) + api = createApiRouter() + await db.events.clear() + + eventsStore = useEventsStore() + settingsStore = useSettingsStore() + authStore = useAuthStore() + router = fakeRouter('/') + + const wrapper = mount(FeedbackHost, { + global: { provide: { [routerKey]: router } } + }) + + // `$q.notify` is looked up on each call, so spying on the mounted app's + // `$q` intercepts what the composable sends. + notify = vi.spyOn(wrapper.vm.$q, 'notify').mockImplementation(() => () => {}) + }) + + it('shows a write failure as a negative notification', async () => { + eventsStore.lastError = 'Das Event konnte nicht gespeichert werden.' + await flush(1) + + expect(notify).toHaveBeenCalledTimes(1) + expect(notify.mock.calls[0][0]).toMatchObject({ + type: 'negative', + message: 'Das Event konnte nicht gespeichert werden.' + }) + }) + + it('shows a load failure as a warning', async () => { + eventsStore.loadError = 'Die Events konnten nicht geladen werden.' + await flush(1) + + expect(notify.mock.calls[0][0]).toMatchObject({ type: 'warning' }) + }) + + it('shows a settings failure as a warning', async () => { + settingsStore.lastError = 'Die Einstellungen konnten nicht gespeichert werden.' + await flush(1) + + expect(notify.mock.calls[0][0]).toMatchObject({ + type: 'warning', + message: 'Die Einstellungen konnten nicht gespeichert werden.' + }) + }) + + it('consumes the message so the identical error notifies again', async () => { + eventsStore.lastError = 'Gleiche Meldung' + await flush(1) + expect(eventsStore.lastError).toBeNull() + + eventsStore.lastError = 'Gleiche Meldung' + await flush(1) + + expect(notify).toHaveBeenCalledTimes(2) + }) + + it('does not notify when the error is cleared', async () => { + eventsStore.lastError = null + await flush(1) + + expect(notify).not.toHaveBeenCalled() + }) + + it('redirects to /login once the session has ended', async () => { + authStore.currentUserId = 'remote-user-1' + await flush(1) + expect(router.replace).not.toHaveBeenCalled() + + authStore.currentUserId = null + await flush(1) + + expect(router.replace).toHaveBeenCalledWith('/login') + }) + + it('does not redirect when already on /login', async () => { + router.currentRoute.value.path = '/login' + + authStore.currentUserId = 'remote-user-1' + await flush(1) + authStore.currentUserId = null + await flush(1) + + expect(router.replace).not.toHaveBeenCalled() + }) +}) + +describe('EventPanel — the save state is visible', () => { + let eventsStore + + beforeEach(async () => { + setActivePinia(createPinia()) + api = createApiRouter() + await db.events.clear() + + api.once(EVENTS_LIST, page([remoteEvent({ id: 'e1', title: 'Event A' })])) + api.always(/^\/events\/e1$/, (url, options) => Promise.resolve({ + data: remoteEvent({ id: 'e1', ...(options.body ?? {}) }) + })) + + eventsStore = useEventsStore() + useAuthStore().currentUserId = 'remote-user-1' + await flush() + }) + + function mountPanel() { + return mount(EventPanel, { + global: { + stubs: { + // Not under test, and they pull in portals and carousels. + QDialog: true, + QCarousel: true, + QCarouselSlide: true, + QPopupProxy: true, + QDate: true + } + } + }) + } + + it('renders nothing about saving on a freshly opened panel', async () => { + eventsStore.openPanel('e1') + const wrapper = mountPanel() + await flush(1) + + expect(wrapper.find('.event-panel__save-state').exists()).toBe(false) + }) + + it('shows "Gespeichert" after a successful autosave', async () => { + eventsStore.openPanel('e1') + const wrapper = mountPanel() + await flush(1) + + eventsStore.ghostTitle = 'geändert' + await flush(1) + eventsStore.saveGhostNow() + await flush() + + const state = wrapper.find('.event-panel__save-state') + expect(state.exists()).toBe(true) + expect(state.text()).toBe('Gespeichert') + expect(state.classes()).toContain('event-panel__save-state--saved') + }) + + it('shows "Nicht gespeichert" when the server rejects the write', async () => { + api = createApiRouter() + api.always(/^\/events\/e1$/, () => Promise.reject( + Object.assign(new Error('unprocessable'), { status: 422 }) + )) + + eventsStore.openPanel('e1') + const wrapper = mountPanel() + await flush(1) + + eventsStore.ghostTitle = 'abgelehnt' + await flush(1) + eventsStore.saveGhostNow() + await flush() + + const state = wrapper.find('.event-panel__save-state') + expect(state.find('span').text()).toBe('Nicht gespeichert') + expect(state.classes()).toContain('event-panel__save-state--error') + expect(state.attributes('role')).toBe('status') + // A failed write offers a way out. + expect(state.find('.event-panel__save-retry').text()).toBe('Erneut versuchen') + }) + + it('shows "Wird gespeichert …" while the request is in flight', async () => { + let resolveWrite + api = createApiRouter() + api.always(/^\/events\/e1$/, () => new Promise((resolve) => { resolveWrite = resolve })) + + eventsStore.openPanel('e1') + const wrapper = mountPanel() + await flush(1) + + eventsStore.ghostTitle = 'unterwegs' + await flush(1) + eventsStore.saveGhostNow() + // The write leaves through the queue, which takes a few IndexedDB turns. + await flush() + + expect(wrapper.find('.event-panel__save-state').text()).toBe('Wird gespeichert …') + expect(resolveWrite).toBeTypeOf('function') + + resolveWrite({ data: remoteEvent({ id: 'e1', title: 'unterwegs' }) }) + await flush() + expect(wrapper.find('.event-panel__save-state').text()).toBe('Gespeichert') + }) + + it('starts clean again when another event is opened', async () => { + eventsStore.openPanel('e1') + const wrapper = mountPanel() + await flush(1) + + eventsStore.ghostTitle = 'geändert' + await flush(1) + eventsStore.saveGhostNow() + await flush() + expect(wrapper.find('.event-panel__save-state').text()).toBe('Gespeichert') + + eventsStore.openPanel('e1') + await flush(1) + + expect(wrapper.find('.event-panel__save-state').exists()).toBe(false) + }) +}) diff --git a/frontend/test/vitest/components-timeline-nav.spec.js b/frontend/test/vitest/components-timeline-nav.spec.js new file mode 100644 index 0000000..1be7ff7 --- /dev/null +++ b/frontend/test/vitest/components-timeline-nav.spec.js @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest' +import { mount } from '@vue/test-utils' +import TimelineNav from 'components/TimelineNav.vue' + +// The menu is a portal; rendering it inline keeps the year list testable. +const inlineMenu = { + QMenu: { + props: ['modelValue'], + template: '' + } +} + +function mountNav(props = {}) { + return mount(TimelineNav, { + props: { + year: 2026, + month: 'Januar', + years: [2025, 2026, 2027], + canPrev: true, + canNext: true, + ...props + }, + global: { stubs: inlineMenu } + }) +} + +describe('TimelineNav — the position pill', () => { + it('shows month and year of the centred event', () => { + const wrapper = mountNav() + + expect(wrapper.find('.timeline-nav__month').text()).toBe('Januar') + expect(wrapper.find('.timeline-nav__year').text()).toBe('2026') + }) + + it('emits prev and next from the arrows', async () => { + const wrapper = mountNav() + const arrows = wrapper.findAll('.timeline-nav__arrow') + + await arrows[0].trigger('click') + await arrows[1].trigger('click') + + expect(wrapper.emitted('prev')).toHaveLength(1) + expect(wrapper.emitted('next')).toHaveLength(1) + }) + + it('disables the arrows at either end of the data', () => { + const wrapper = mountNav({ canPrev: false, canNext: false }) + const arrows = wrapper.findAll('.timeline-nav__arrow') + + expect(arrows[0].attributes('disabled')).toBeDefined() + expect(arrows[1].attributes('disabled')).toBeDefined() + }) + + it('opens the year list on tap and emits the chosen year', async () => { + const wrapper = mountNav() + + expect(wrapper.find('.menu-stub').exists()).toBe(false) + await wrapper.find('.timeline-nav__label').trigger('click') + expect(wrapper.find('.menu-stub').exists()).toBe(true) + + const items = wrapper.findAll('.timeline-nav__year-item') + expect(items.map(item => item.text())).toEqual(['2025', '2026', '2027']) + + await items[2].trigger('click') + expect(wrapper.emitted('jump')).toEqual([[2027]]) + // Choosing closes the list. + expect(wrapper.find('.menu-stub').exists()).toBe(false) + }) + + it('shows a dash and no month while there is no event', () => { + const wrapper = mountNav({ year: null, month: '', years: [], canPrev: false, canNext: false }) + + expect(wrapper.find('.timeline-nav__year').text()).toBe('–') + expect(wrapper.find('.timeline-nav__month').exists()).toBe(false) + expect(wrapper.find('.timeline-nav__label').attributes('disabled')).toBeDefined() + }) +}) diff --git a/frontend/test/vitest/components-timeline.spec.js b/frontend/test/vitest/components-timeline.spec.js new file mode 100644 index 0000000..8b158b8 --- /dev/null +++ b/frontend/test/vitest/components-timeline.spec.js @@ -0,0 +1,100 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' +import { mount } from '@vue/test-utils' +import { createApiRouter, flush, page, remoteEvent } from './helpers' + +let api = createApiRouter() + +vi.mock('src/services/apiClient', () => ({ + API_BASE: 'http://api.test', + apiJson: (...args) => api.apiJson(...args), + apiFetch: vi.fn(), + getToken: vi.fn(async () => 'token'), + setToken: vi.fn(async () => {}), + clearToken: vi.fn(async () => {}), + onUnauthorized: vi.fn(() => () => {}), + revokeToken: vi.fn(async () => {}), + loginRemote: vi.fn(), + ApiError: class ApiError extends Error {} +})) + + +import { db } from 'src/db' +import { useEventsStore } from 'stores/events' +import { useAuthStore } from 'stores/auth' +import TimelineView from 'components/TimelineView.vue' + +const EVENTS_LIST = /^\/events\?/ + +/** + * Dates chosen so that a UTC-parsed instant read with local getters would + * put the January events into December of the previous year in any zone + * west of UTC — the failure the audit reproduced (A18). `npm run test:tz` + * runs this file under such a zone. + */ +const EVENTS = [ + remoteEvent({ id: 'e-dec', title: 'Silvester', date: '2025-12-31' }), + remoteEvent({ id: 'e-jan', title: 'Neujahr', date: '2026-01-01' }), + remoteEvent({ id: 'e-feb', title: 'Februar', date: '2026-02-15' }), + remoteEvent({ id: 'e-next', title: 'Nächstes Jahr', date: '2027-01-01' }) +] + +const MONTHS_LONG = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'] + +/** Month and year read from the date string itself, never via Date parsing. */ +function positionFromDateString(date) { + return { year: Number(date.slice(0, 4)), month: MONTHS_LONG[Number(date.slice(5, 7)) - 1] } +} + +describe('TimelineView — the reported position follows the calendar date (A18)', () => { + let wrapper + + beforeEach(async () => { + setActivePinia(createPinia()) + api = createApiRouter() + await db.events.clear() + + api.once(EVENTS_LIST, page(EVENTS)) + + useEventsStore() + useAuthStore().currentUserId = 'remote-user-1' + await flush() + + // happy-dom reports no layout, so the component falls back to a 400 px + // viewport; the first five events are inside its virtualisation window. + wrapper = mount(TimelineView) + await flush(2) + }) + + it('lists exactly the years written in the dates, ascending', () => { + // A UTC-shifted parse would move both New Year events into the previous + // year and 2027 would go missing. + expect(wrapper.vm.years).toEqual([2025, 2026, 2027]) + }) + + it('reports month and year of the centred event as written in its date', () => { + const position = wrapper.vm.activePosition + + expect(position).not.toBeNull() + // Whichever event the timeline centres on mount, month and year must be + // the ones spelled out in that event's date string. + const matching = EVENTS.map(event => positionFromDateString(event.date)) + expect(matching).toContainEqual(position) + }) + + it('names months in full for the navigation pill', () => { + expect(MONTHS_LONG).toContain(wrapper.vm.activePosition.month) + }) + + it('no longer renders month or year rows inside the timeline', () => { + expect(wrapper.find('.timeline__month').exists()).toBe(false) + expect(wrapper.find('.timeline__sticky-year').exists()).toBe(false) + }) + + it('exposes a year jump for the navigation', () => { + expect(typeof wrapper.vm.scrollToYearCenter).toBe('function') + // Must not throw for a year that exists or for one that does not. + expect(() => wrapper.vm.scrollToYearCenter(2026)).not.toThrow() + expect(() => wrapper.vm.scrollToYearCenter(1999)).not.toThrow() + }) +}) diff --git a/frontend/test/vitest/events-autosave.spec.js b/frontend/test/vitest/events-autosave.spec.js new file mode 100644 index 0000000..b7ba8d5 --- /dev/null +++ b/frontend/test/vitest/events-autosave.spec.js @@ -0,0 +1,260 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' +import { createApiRouter, drainQueue, flush, page, remoteEvent, waitFor } from './helpers' + +let api = createApiRouter() + +vi.mock('src/services/apiClient', () => ({ + API_BASE: 'http://api.test', + apiJson: (...args) => api.apiJson(...args), + apiFetch: vi.fn(), + getToken: vi.fn(async () => 'token'), + setToken: vi.fn(async () => {}), + clearToken: vi.fn(async () => {}), + onUnauthorized: vi.fn(() => () => {}), + revokeToken: vi.fn(async () => {}), + loginRemote: vi.fn(), + ApiError: class ApiError extends Error {} +})) + + +const EVENTS_LIST = /^\/events\?/ + +describe('events store — switching events does not lose the pending edit (A06)', () => { + let eventsStore + + beforeEach(async () => { + setActivePinia(createPinia()) + api = createApiRouter() + vi.resetModules() + + const { db } = await import('src/db') + await db.events.clear() + + const { useEventsStore } = await import('stores/events') + const { useAuthStore } = await import('stores/auth') + + api.once(EVENTS_LIST, page([ + remoteEvent({ id: 'e1', title: 'original A', date: '2025-01-01' }), + remoteEvent({ id: 'e2', title: 'original B', date: '2025-02-01' }) + ])) + // Echo the request, like the real API does: a stub with fixed values + // would mask whether the right fields were sent. + api.always(/^\/events\/e\d$/, (url, options) => Promise.resolve({ + data: remoteEvent({ id: url.split('/').pop(), ...(options.body ?? {}) }) + })) + + const authStore = useAuthStore() + eventsStore = useEventsStore() + authStore.currentUserId = 'remote-user-1' + await flush() + }) + + // A queue still sending when the test ends would hit the next test's API + // mock; let it finish first. + afterEach(async () => { + eventsStore.closePanel() + await drainQueue(eventsStore) + }) + + it('keeps the edit when the next event is opened inside the debounce window', async () => { + eventsStore.openPanel('e1') + await flush(2) + + eventsStore.ghostTitle = 'bearbeitet A' + await flush(1) + + // Straight to the next event, well inside the 300 ms autosave delay. + eventsStore.openPanel('e2') + await flush(2) + + const eventA = eventsStore.events.find(event => event.id === 'e1') + expect(eventA.title).toBe('bearbeitet A') + }) + + it('sends the edit to the API under the event it belongs to', async () => { + eventsStore.openPanel('e1') + await flush(2) + + eventsStore.ghostTitle = 'bearbeitet A' + await flush(1) + + eventsStore.openPanel('e2') + // The write leaves through the queue; give it its IndexedDB turns. + await waitFor(() => api.callsTo('/events/e1').some(call => call.options?.method === 'PUT'), { label: 'PUT to e1' }) + + const writes = api.callsTo(/^\/events\/e\d$/).filter(call => call.options?.method === 'PUT') + const writeToA = writes.find(call => call.url === '/events/e1') + + expect(writeToA).toBeDefined() + expect(writeToA.options.body.title).toBe('bearbeitet A') + // The unrelated event must not be rewritten with A's title. + for (const write of writes.filter(call => call.url === '/events/e2')) { + expect(write.options.body.title).not.toBe('bearbeitet A') + } + }) + + it('leaves the other event untouched', async () => { + eventsStore.openPanel('e1') + await flush(2) + eventsStore.ghostTitle = 'bearbeitet A' + await flush(1) + eventsStore.openPanel('e2') + await flush(2) + + const eventB = eventsStore.events.find(event => event.id === 'e2') + expect(eventB.title).toBe('original B') + }) + + it('does not reset gradientPreset on every autosave', async () => { + const eventA = eventsStore.events.find(event => event.id === 'e1') + eventA.gradientPreset = 3 + + eventsStore.openPanel('e1') + await flush(2) + eventsStore.ghostTitle = 'neuer Titel' + await flush(1) + eventsStore.saveGhostNow() + await flush(2) + + expect(eventsStore.events.find(event => event.id === 'e1').gradientPreset).toBe(3) + }) +}) + +describe('events store — failed writes stay visible (A07)', () => { + let eventsStore + + beforeEach(async () => { + setActivePinia(createPinia()) + api = createApiRouter() + vi.resetModules() + + const { db } = await import('src/db') + await db.events.clear() + await db.syncQueue.clear() + + const { useEventsStore } = await import('stores/events') + const { useAuthStore } = await import('stores/auth') + + api.once(EVENTS_LIST, page([remoteEvent({ id: 'e1', title: 'bleibt' })])) + + const authStore = useAuthStore() + eventsStore = useEventsStore() + authStore.currentUserId = 'remote-user-1' + await flush() + }) + + // A queue still sending when the test ends would hit the next test's API + // mock; let it finish first. + afterEach(async () => { + eventsStore.closePanel() + await drainQueue(eventsStore) + }) + + it('keeps a delete the server could not process in the queue and retries it', async () => { + let failing = true + api.always(/^\/events\/e1$/, () => (failing + ? Promise.reject(Object.assign(new Error('server error'), { status: 500 })) + : Promise.resolve(null))) + + expect(eventsStore.events).toHaveLength(1) + + eventsStore.deleteEvent('e1') + await waitFor( + () => api.callsTo(/^\/events\/e1$/).length === 1 && eventsStore.pendingSyncCount === 1, + { label: `first attempt (calls=${api.callsTo(/^\/events\/e1$/).length}, pending=${eventsStore.pendingSyncCount})` } + ) + + // Gone locally, waiting in the queue, and not reported as a failure: + // the server will get the delete as soon as it answers again. + expect(eventsStore.events.find(event => event.id === 'e1')).toBeUndefined() + expect(eventsStore.lastError).toBeNull() + + const { db } = await import('src/db') + expect(await db.events.get('e1')).toBeUndefined() + + failing = false + await eventsStore.syncNow() + await waitFor(() => eventsStore.pendingSyncCount === 0, { label: 'queue drained after retry' }) + + expect(api.callsTo(/^\/events\/e1$/)).toHaveLength(2) + }) + + it('treats a 404 on delete as done instead of resurrecting the event', async () => { + api.always(/^\/events\/e1$/, () => Promise.reject( + Object.assign(new Error('not found'), { status: 404 }) + )) + + eventsStore.deleteEvent('e1') + await flush() + + expect(eventsStore.events.find(event => event.id === 'e1')).toBeUndefined() + expect(eventsStore.lastError).toBeNull() + + const { db } = await import('src/db') + expect(await db.events.get('e1')).toBeUndefined() + }) + + it('does not autosave just because the panel was opened on an event with stored media', async () => { + const { db } = await import('src/db') + await db.eventMedia.put({ + id: 'm1', eventId: 'e1', userId: 'remote-user-1', uuid: 'u1', type: 'image', + collection: 'gallery', name: 'a.jpg', createdAt: 1, updatedAt: 1 + }) + api.always(/^\/events\/e1$/, (url, options) => Promise.resolve({ data: remoteEvent({ id: 'e1', ...(options.body ?? {}) }) })) + + const before = eventsStore.events.find(event => event.id === 'e1').updatedAt + + eventsStore.openPanel('e1') + await flush(4) + // Past the autosave debounce: a scheduled persist would have fired by now. + await new Promise(resolve => setTimeout(resolve, 400)) + await flush(2) + + expect(api.callsTo(/^\/events\/e1$/)).toHaveLength(0) + expect(eventsStore.saveState).toBe('idle') + expect(eventsStore.events.find(event => event.id === 'e1').updatedAt).toBe(before) + expect(eventsStore.ghostMedia).toHaveLength(1) + }) + + it('flags an event whose update the server refused instead of showing it as saved', async () => { + api.always(/^\/events\/e1$/, () => Promise.reject( + Object.assign(new Error('unprocessable'), { status: 422 }) + )) + + eventsStore.openPanel('e1') + await flush(2) + eventsStore.ghostTitle = 'abgelehnt' + await flush(1) + eventsStore.saveGhostNow() + await flush() + + expect(eventsStore.saveState).toBe('error') + expect(eventsStore.lastError).toBeTruthy() + // The local draft survives so the user can retry. + expect(eventsStore.events.find(event => event.id === 'e1').title).toBe('abgelehnt') + }) + + it('falls back to the cached events when the initial load fails', async () => { + const { db } = await import('src/db') + const { useEventsStore } = await import('stores/events') + const { useAuthStore } = await import('stores/auth') + + await db.events.put({ + ...remoteEvent({ id: 'cached', title: 'aus dem Cache' }), + userId: 'remote-user-3' + }) + + setActivePinia(createPinia()) + api = createApiRouter() + api.always(EVENTS_LIST, () => Promise.reject(new Error('network down'))) + + const authStore = useAuthStore() + const store = useEventsStore() + authStore.currentUserId = 'remote-user-3' + await flush() + + expect(store.events.map(event => event.id)).toEqual(['cached']) + expect(store.loadError).toBeTruthy() + }) +}) diff --git a/frontend/test/vitest/events-draft.spec.js b/frontend/test/vitest/events-draft.spec.js new file mode 100644 index 0000000..fb78a19 --- /dev/null +++ b/frontend/test/vitest/events-draft.spec.js @@ -0,0 +1,312 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' +import { createApiRouter, drainQueue, flush, page, remoteEvent } from './helpers' + +let api = createApiRouter() + +vi.mock('src/services/apiClient', () => ({ + API_BASE: 'http://api.test', + apiJson: (...args) => api.apiJson(...args), + apiFetch: vi.fn(), + getToken: vi.fn(async () => 'token'), + setToken: vi.fn(async () => {}), + clearToken: vi.fn(async () => {}), + onUnauthorized: vi.fn(() => () => {}), + revokeToken: vi.fn(async () => {}), + loginRemote: vi.fn(), + ApiError: class ApiError extends Error {} +})) + + +const EVENTS_LIST = /^\/events\?/ +const EVENTS_CREATE = (url, options) => url === '/events' && options?.method === 'POST' +const EVENT_UPDATE = (url, options) => /^\/events\/[^/]+$/.test(url) && options?.method === 'PUT' + +// Past the 300 ms autosave debounce. +async function settle() { + await flush(2) + await new Promise(resolve => setTimeout(resolve, 400)) + await flush(2) +} + +describe('events store — a new event is stored with its first entry', () => { + let eventsStore + + beforeEach(async () => { + setActivePinia(createPinia()) + api = createApiRouter() + vi.resetModules() + + const { db } = await import('src/db') + await db.events.clear() + + const { useEventsStore } = await import('stores/events') + const { useAuthStore } = await import('stores/auth') + + api.once(EVENTS_LIST, page([remoteEvent({ id: 'e1', title: 'vorhanden' })])) + api.always(EVENTS_CREATE, (url, options) => Promise.resolve({ + data: remoteEvent({ ...(options.body ?? {}) }) + })) + api.always(EVENT_UPDATE, (url, options) => Promise.resolve({ + data: remoteEvent({ id: url.split('/').pop(), ...(options.body ?? {}) }) + })) + + const authStore = useAuthStore() + eventsStore = useEventsStore() + authStore.currentUserId = 'remote-user-1' + await flush() + }) + + // A queue still sending when the test ends would hit the next test's API + // mock; let it finish first. + afterEach(async () => { + eventsStore.closePanel() + await drainQueue(eventsStore) + }) + + it('opens the panel as a draft without creating an event', async () => { + eventsStore.openPanel() + await settle() + + expect(eventsStore.panelOpen).toBe(true) + expect(eventsStore.editingEventId).toBeNull() + expect(eventsStore.events).toHaveLength(1) + expect(api.callsTo(EVENTS_CREATE)).toHaveLength(0) + }) + + it('leaves nothing behind when the empty draft is closed', async () => { + eventsStore.openPanel() + await flush(2) + eventsStore.closePanel() + await settle() + + expect(eventsStore.events).toHaveLength(1) + expect(api.callsTo(EVENTS_CREATE)).toHaveLength(0) + + const { db } = await import('src/db') + expect(await db.events.count()).toBe(1) + }) + + it('does not treat date or emotion alone as an entry', async () => { + eventsStore.openPanel() + await flush(2) + + eventsStore.ghostDate = '2024-06-01' + eventsStore.ghostEmotion = 0.8 + await settle() + + expect(eventsStore.editingEventId).toBeNull() + expect(eventsStore.events).toHaveLength(1) + expect(api.callsTo(EVENTS_CREATE)).toHaveLength(0) + }) + + it('creates exactly one event with the first title and updates it afterwards', async () => { + eventsStore.openPanel() + await flush(2) + + eventsStore.ghostTitle = 'Erster Tag' + await flush(2) + + expect(eventsStore.editingEventId).not.toBeNull() + const createdId = eventsStore.editingEventId + const created = eventsStore.events.find(event => event.id === createdId) + expect(created).toBeDefined() + expect(created.title).toBe('Erster Tag') + expect(eventsStore.selectedEventId).toBe(createdId) + + eventsStore.ghostTitle = 'Erster Tag am Meer' + eventsStore.ghostNote = 'Sonne' + await settle() + + const creates = api.callsTo(EVENTS_CREATE) + expect(creates).toHaveLength(1) + expect(creates[0].options.body.title).toBe('Erster Tag') + + const updates = api.callsTo(EVENT_UPDATE).filter(call => call.url === `/events/${createdId}`) + expect(updates.length).toBeGreaterThan(0) + expect(updates.at(-1).options.body.title).toBe('Erster Tag am Meer') + expect(updates.at(-1).options.body.note).toBe('Sonne') + + expect(eventsStore.events).toHaveLength(2) + }) + + it('keeps the created event when the panel is closed', async () => { + eventsStore.openPanel() + await flush(2) + eventsStore.ghostNote = 'nur eine Notiz' + await flush(2) + const createdId = eventsStore.editingEventId + + eventsStore.closePanel() + await settle() + + expect(eventsStore.events.find(event => event.id === createdId)?.note).toBe('nur eine Notiz') + const { db } = await import('src/db') + expect(await db.events.get(createdId)).toBeDefined() + }) + + it('starts each new draft clean, even after editing another event', async () => { + eventsStore.openPanel('e1') + await flush(2) + expect(eventsStore.ghostTitle).toBe('vorhanden') + + eventsStore.openPanel() + await flush(2) + + expect(eventsStore.ghostTitle).toBe('') + expect(eventsStore.ghostNote).toBe('') + expect(eventsStore.ghostMedia).toEqual([]) + expect(eventsStore.ghostHasContent).toBe(false) + expect(eventsStore.editingEventId).toBeNull() + }) + + it('does not resurrect the existing event as a "Neues Event" copy', async () => { + eventsStore.openPanel() + await flush(2) + eventsStore.closePanel() + await settle() + + expect(eventsStore.events.map(event => event.title)).toEqual(['vorhanden']) + }) +}) + +describe('events store — drafts for the local demo user', () => { + let eventsStore + + beforeEach(async () => { + setActivePinia(createPinia()) + api = createApiRouter() + vi.resetModules() + + const { db } = await import('src/db') + await db.events.clear() + + const { useEventsStore } = await import('stores/events') + const { useAuthStore } = await import('stores/auth') + + const authStore = useAuthStore() + eventsStore = useEventsStore() + authStore.currentUserId = 'local-demo' + await flush() + }) + + // A queue still sending when the test ends would hit the next test's API + // mock; let it finish first. + afterEach(async () => { + eventsStore.closePanel() + await drainQueue(eventsStore) + }) + + it('stores the draft locally with its first entry and never calls the API', async () => { + eventsStore.openPanel() + await flush(2) + expect(eventsStore.events).toHaveLength(0) + + eventsStore.ghostLocation = 'Berlin' + await settle() + + expect(eventsStore.events).toHaveLength(1) + expect(eventsStore.events[0].location).toBe('Berlin') + expect(eventsStore.saveState).toBe('saved') + expect(api.callsTo(EVENTS_CREATE)).toHaveLength(0) + + const { db } = await import('src/db') + expect(await db.events.count()).toBe(1) + }) +}) + +describe('events store — category, people, span and milestone', () => { + let eventsStore + + beforeEach(async () => { + setActivePinia(createPinia()) + api = createApiRouter() + vi.resetModules() + + const { db } = await import('src/db') + await db.events.clear() + await db.syncQueue.clear() + + const { useEventsStore } = await import('stores/events') + const { useAuthStore } = await import('stores/auth') + + api.once(EVENTS_LIST, page([remoteEvent({ + id: 'e1', title: 'Umzug', endDate: '2019-04-14', category: 'home', people: ['Lena'], milestone: true + })])) + api.always(EVENTS_CREATE, (url, options) => Promise.resolve({ data: remoteEvent({ ...(options.body ?? {}) }) })) + api.always(EVENT_UPDATE, (url, options) => Promise.resolve({ + data: remoteEvent({ id: url.split('/').pop(), ...(options.body ?? {}) }) + })) + + const authStore = useAuthStore() + eventsStore = useEventsStore() + authStore.currentUserId = 'remote-user-1' + await flush() + }) + + afterEach(async () => { + eventsStore.closePanel() + await drainQueue(eventsStore) + }) + + it('loads the fields from the API and puts them into the draft', async () => { + const event = eventsStore.events.find(entry => entry.id === 'e1') + expect(event.endDate).toBe('2019-04-14') + expect(event.category).toBe('home') + expect(event.people).toEqual(['Lena']) + expect(event.milestone).toBe(true) + + eventsStore.openPanel('e1') + await flush(2) + expect(eventsStore.ghostEndDate).toBe('2019-04-14') + expect(eventsStore.ghostCategory).toBe('home') + expect(eventsStore.ghostPeople).toEqual(['Lena']) + expect(eventsStore.ghostMilestone).toBe(true) + }) + + it('a category or a person is an entry that stores the draft', async () => { + eventsStore.openPanel() + await flush(2) + + eventsStore.ghostMilestone = true + await settle() + // A flag alone is no content. + expect(eventsStore.editingEventId).toBeNull() + + eventsStore.ghostCategory = 'travel' + await settle() + expect(eventsStore.editingEventId).not.toBeNull() + + const creates = api.callsTo(EVENTS_CREATE) + expect(creates).toHaveLength(1) + expect(creates[0].options.body.category).toBe('travel') + expect(creates[0].options.body.milestone).toBe(true) + expect(creates[0].options.body.people).toEqual([]) + expect(creates[0].options.body.endDate).toBeNull() + }) + + it('sends people, span and milestone on update and keeps them on the event', async () => { + eventsStore.openPanel('e1') + await flush(2) + + eventsStore.ghostPeople = ['Lena', 'Papa'] + eventsStore.ghostEndDate = '2019-04-20' + eventsStore.ghostMilestone = false + await settle() + await drainQueue(eventsStore) + + const puts = api.callsTo(EVENT_UPDATE) + const last = puts[puts.length - 1].options.body + expect(last.people).toEqual(['Lena', 'Papa']) + expect(last.endDate).toBe('2019-04-20') + expect(last.milestone).toBe(false) + + const event = eventsStore.events.find(entry => entry.id === 'e1') + expect(event.people).toEqual(['Lena', 'Papa']) + expect(event.endDate).toBe('2019-04-20') + expect(event.milestone).toBe(false) + + const { db } = await import('src/db') + expect((await db.events.get('e1')).people).toEqual(['Lena', 'Papa']) + }) +}) diff --git a/frontend/test/vitest/events-media.spec.js b/frontend/test/vitest/events-media.spec.js new file mode 100644 index 0000000..2287fb6 --- /dev/null +++ b/frontend/test/vitest/events-media.spec.js @@ -0,0 +1,361 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' +import { createApiRouter, drainQueue, flush, page, remoteEvent, waitFor } from './helpers' + +let api = createApiRouter() + +vi.mock('src/services/apiClient', () => ({ + API_BASE: 'http://api.test', + apiJson: (...args) => api.apiJson(...args), + apiFetch: vi.fn(), + getToken: vi.fn(async () => 'token'), + setToken: vi.fn(async () => {}), + clearToken: vi.fn(async () => {}), + onUnauthorized: vi.fn(() => () => {}), + revokeToken: vi.fn(async () => {}), + loginRemote: vi.fn(), + ApiError: class ApiError extends Error {} +})) + + +const EVENTS_LIST = /^\/events\?/ +const EVENTS_CREATE = (url, options) => url === '/events' && options?.method === 'POST' +const EVENT_UPDATE = (url, options) => /^\/events\/[^/]+$/.test(url) && options?.method === 'PUT' +const MEDIA_UPLOAD = (url, options) => /^\/events\/[^/]+\/media$/.test(url) && options?.method === 'POST' +const MEDIA_DELETE = (url, options) => /^\/events\/[^/]+\/media\/[^/]+$/.test(url) && options?.method === 'DELETE' +const MEDIA_PATCH = (url, options) => /^\/events\/[^/]+\/media\/[^/]+$/.test(url) && options?.method === 'PATCH' + +function mediaItem(overrides = {}) { + const id = overrides.id ?? 'm1' + return { + id, + uuid: `uuid-${id}`, + type: 'image', + collection: 'gallery', + name: `${id}.jpg`, + mimeType: 'image/jpeg', + durationMs: null, + src: `/event-media/${id}/thumb`, + thumbnailUrl: `/event-media/${id}/thumb`, + previewUrl: `/event-media/${id}/preview`, + originalUrl: `/event-media/${id}/original`, + createdAt: 1, + ...overrides + } +} + +async function settle() { + await flush(2) + await new Promise(resolve => setTimeout(resolve, 400)) + await flush(2) +} + +describe('events store — media types and the voice note (remote user)', () => { + let eventsStore + let uploadCounter + + beforeEach(async () => { + setActivePinia(createPinia()) + api = createApiRouter() + uploadCounter = 0 + vi.resetModules() + + const { db } = await import('src/db') + await db.events.clear() + await db.eventMedia.clear() + await db.syncQueue.clear() + + const { useEventsStore } = await import('stores/events') + const { useAuthStore } = await import('stores/auth') + + api.once(EVENTS_LIST, page([ + remoteEvent({ + id: 'e1', + title: 'mit Medien', + media: [ + mediaItem({ id: 'k1', collection: 'key_image' }), + mediaItem({ id: 'g1' }), + mediaItem({ id: 'g2', type: 'video', name: 'clip.mp4', mimeType: 'video/mp4', durationMs: 4200, src: null, thumbnailUrl: null, previewUrl: null }), + mediaItem({ id: 'v1', type: 'audio', collection: 'voice_note', name: 'sprachnotiz.webm', mimeType: 'audio/webm', durationMs: 9000, src: null, thumbnailUrl: null, previewUrl: null }) + ] + }) + ])) + api.always(EVENTS_CREATE, (url, options) => Promise.resolve({ data: remoteEvent({ ...(options.body ?? {}) }) })) + // Like the real API, an update echoes the event with its media. + api.always(EVENT_UPDATE, (url, options) => { + const id = url.split('/').pop() + const media = eventsStore.events.find(entry => entry.id === id)?.media ?? [] + return Promise.resolve({ data: remoteEvent({ id, ...(options.body ?? {}), media }) }) + }) + api.always(MEDIA_UPLOAD, (url, options) => { + const collection = options.body.get('collection') + const id = `up${++uploadCounter}` + return Promise.resolve({ + data: mediaItem({ + id, + collection, + type: collection === 'voice_note' ? 'audio' : 'image', + name: options.body.get('file')?.name, + src: null, + thumbnailUrl: null, + previewUrl: null, + durationMs: collection === 'voice_note' ? 1500 : null + }) + }) + }) + api.always(MEDIA_DELETE, () => Promise.resolve(null)) + api.always(MEDIA_PATCH, (url, options) => Promise.resolve({ + data: mediaItem({ id: url.split('/').pop(), collection: 'voice_note', type: 'audio', caption: options.body.caption }) + })) + + const authStore = useAuthStore() + eventsStore = useEventsStore() + authStore.currentUserId = 'remote-user-1' + await flush() + }) + + // The 300 ms autosave of one test must not fire into the next test's API. + afterEach(async () => { + eventsStore.closePanel() + await flush(2) + // A queue still sending when the test ends would hit the next test's + // API mock; let it finish first. + await drainQueue(eventsStore) + }) + + it('splits stored media into gallery items and the voice note', async () => { + eventsStore.openPanel('e1') + await flush(3) + + expect(eventsStore.ghostMedia.map(item => item.id)).toEqual(['g1', 'g2']) + expect(eventsStore.ghostMedia[1].type).toBe('video') + expect(eventsStore.ghostVoiceNotes.map(note => note.id)).toEqual(['v1']) + expect(eventsStore.ghostVoiceNotes[0].type).toBe('audio') + }) + + it('keeps the key image and the voice note on the event when saving', async () => { + eventsStore.openPanel('e1') + await flush(3) + + eventsStore.ghostTitle = 'bearbeitet' + await settle() + + const event = eventsStore.events.find(entry => entry.id === 'e1') + const collections = event.media.map(item => `${item.collection}:${item.id}`) + expect(collections).toContain('key_image:k1') + expect(collections).toContain('voice_note:v1') + expect(collections).toContain('gallery:g1') + expect(collections).toContain('gallery:g2') + expect(event.media).toHaveLength(4) + }) + + it('uploads a recording as the voice note of a fresh draft', async () => { + eventsStore.openPanel() + await flush(2) + + const file = new File(['audio'], 'sprachnotiz.webm', { type: 'audio/webm' }) + const media = await eventsStore.uploadGhostVoiceNote(file, { durationMs: 2100 }) + + // The note is there at once, marked as still uploading. + expect(media.type).toBe('audio') + expect(media.pending).toBe(true) + expect(eventsStore.ghostVoiceNotes.map(note => note.id)).toEqual([media.id]) + await waitFor(() => eventsStore.ghostVoiceNotes.some(note => note.id === 'up1'), { label: 'upload applied' }) + + // The draft became an event first, then the upload went to it. + expect(api.callsTo(EVENTS_CREATE)).toHaveLength(1) + const eventId = eventsStore.editingEventId + expect(eventId).not.toBeNull() + + const uploads = api.callsTo(MEDIA_UPLOAD) + expect(uploads).toHaveLength(1) + expect(uploads[0].url).toBe(`/events/${eventId}/media`) + expect(uploads[0].options.body.get('collection')).toBe('voice_note') + + // Once the server has answered, the local item is the server's item. + expect(eventsStore.ghostVoiceNotes.map(note => note.id)).toEqual(['up1']) + expect(eventsStore.ghostVoiceNotes[0].pending).toBeUndefined() + + const event = eventsStore.events.find(entry => entry.id === eventId) + expect(event.media.filter(item => item.collection === 'voice_note').map(item => item.id)).toEqual(['up1']) + }) + + it('adds a second voice note and keeps the first', async () => { + eventsStore.openPanel('e1') + await flush(3) + + await eventsStore.uploadGhostVoiceNote(new File(['a'], 'neu.webm', { type: 'audio/webm' })) + await waitFor(() => eventsStore.ghostVoiceNotes.some(note => note.id === 'up1'), { label: 'upload applied' }) + + expect(eventsStore.ghostVoiceNotes.map(note => note.id)).toEqual(['v1', 'up1']) + const event = eventsStore.events.find(entry => entry.id === 'e1') + expect(event.media.filter(item => item.collection === 'voice_note').map(item => item.id)).toEqual(['v1', 'up1']) + expect(api.callsTo(MEDIA_DELETE)).toHaveLength(0) + }) + + it('saves a caption for one voice note, debounced, and keeps it on the event', async () => { + eventsStore.openPanel('e1') + await flush(3) + + eventsStore.setGhostVoiceNoteCaption('v1', 'Nachtrag: die') + eventsStore.setGhostVoiceNoteCaption('v1', 'Nachtrag: die Adresse') + await flush(1) + expect(api.callsTo(MEDIA_PATCH)).toHaveLength(0) + + await new Promise(resolve => setTimeout(resolve, 700)) + await flush(2) + + const patches = api.callsTo(MEDIA_PATCH) + expect(patches).toHaveLength(1) + expect(patches[0].url).toBe('/events/e1/media/v1') + expect(patches[0].options.body).toEqual({ caption: 'Nachtrag: die Adresse' }) + + expect(eventsStore.ghostVoiceNotes[0].caption).toBe('Nachtrag: die Adresse') + const event = eventsStore.events.find(entry => entry.id === 'e1') + expect(event.media.find(item => item.id === 'v1').caption).toBe('Nachtrag: die Adresse') + }) + + it('flushes a pending caption when the panel closes', async () => { + eventsStore.openPanel('e1') + await flush(3) + + eventsStore.setGhostVoiceNoteCaption('v1', 'schnell getippt') + eventsStore.closePanel() + await flush(2) + + const patches = api.callsTo(MEDIA_PATCH) + expect(patches).toHaveLength(1) + expect(patches[0].options.body.caption).toBe('schnell getippt') + }) + + it('deletes the voice note on the server and on the event', async () => { + eventsStore.openPanel('e1') + await flush(3) + + await eventsStore.deleteGhostVoiceNote('v1') + await flush(2) + + const deletes = api.callsTo(MEDIA_DELETE) + expect(deletes).toHaveLength(1) + expect(deletes[0].url).toBe('/events/e1/media/v1') + + expect(eventsStore.ghostVoiceNotes).toEqual([]) + const event = eventsStore.events.find(entry => entry.id === 'e1') + expect(event.media.some(item => item.collection === 'voice_note')).toBe(false) + // The rest stays. + expect(event.media.map(item => item.id).sort()).toEqual(['g1', 'g2', 'k1']) + }) + + it('does not autosave just because a stored voice note was loaded', async () => { + const before = eventsStore.events.find(entry => entry.id === 'e1').updatedAt + + eventsStore.openPanel('e1') + await settle() + + expect(api.callsTo(EVENT_UPDATE)).toHaveLength(0) + expect(eventsStore.events.find(entry => entry.id === 'e1').updatedAt).toBe(before) + }) +}) + +describe('events store — local media for the demo user', () => { + let eventsStore + + beforeEach(async () => { + setActivePinia(createPinia()) + api = createApiRouter() + vi.resetModules() + + const { db } = await import('src/db') + await db.events.clear() + await db.eventMedia.clear() + + const { useEventsStore } = await import('stores/events') + const { useAuthStore } = await import('stores/auth') + + const authStore = useAuthStore() + eventsStore = useEventsStore() + authStore.currentUserId = 'local-demo' + await flush() + }) + + afterEach(async () => { + eventsStore.closePanel() + await flush(2) + // A queue still sending when the test ends would hit the next test's + // API mock; let it finish first. + await drainQueue(eventsStore) + }) + + it('a recorded voice note counts as the first entry and survives reopening', async () => { + eventsStore.openPanel() + await flush(2) + expect(eventsStore.events).toHaveLength(0) + + eventsStore.addGhostVoiceNote({ + id: 'local-voice', + name: 'sprachnotiz.webm', + mimeType: 'audio/webm', + // fake-indexeddb cannot clone a real Blob; a plain stand-in exercises + // the same storage path. + blob: { bytes: 'audio' }, + durationMs: 2500 + }) + eventsStore.setGhostVoiceNoteCaption('local-voice', 'lokal beschriftet') + await settle() + + expect(eventsStore.events).toHaveLength(1) + const eventId = eventsStore.events[0].id + // The event row keeps metadata only; the blob lives in the media table. + expect(eventsStore.events[0].media).toEqual([ + expect.objectContaining({ id: 'local-voice', collection: 'voice_note', type: 'audio', durationMs: 2500, caption: 'lokal beschriftet' }) + ]) + expect(eventsStore.events[0].media[0]).not.toHaveProperty('blob') + + const { db } = await import('src/db') + const stored = await db.eventMedia.get('local-voice') + expect(stored).toBeDefined() + expect(stored.eventId).toBe(eventId) + expect(api.callsTo(() => true)).toHaveLength(0) + + eventsStore.closePanel() + await flush(2) + eventsStore.openPanel(eventId) + await flush(4) + + expect(eventsStore.ghostVoiceNotes.map(note => note.id)).toEqual(['local-voice']) + expect(eventsStore.ghostVoiceNotes[0].caption).toBe('lokal beschriftet') + expect(eventsStore.ghostVoiceNotes[0].blob).toEqual({ bytes: 'audio' }) + expect(eventsStore.ghostMedia).toEqual([]) + }) + + it('keeps a local video in the gallery list with its type', async () => { + eventsStore.openPanel() + await flush(2) + + eventsStore.ghostMedia = [{ + id: 'local-video', + type: 'video', + collection: 'gallery', + name: 'clip.mp4', + mimeType: 'video/mp4', + blob: { bytes: 'video' }, + durationMs: 8000, + createdAt: 1 + }] + await settle() + + const event = eventsStore.events[0] + expect(event.media).toEqual([ + expect.objectContaining({ id: 'local-video', type: 'video', collection: 'gallery', durationMs: 8000 }) + ]) + + eventsStore.closePanel() + await flush(2) + eventsStore.openPanel(event.id) + await flush(4) + + expect(eventsStore.ghostMedia).toHaveLength(1) + expect(eventsStore.ghostMedia[0].type).toBe('video') + expect(eventsStore.ghostVoiceNotes).toEqual([]) + }) +}) diff --git a/frontend/test/vitest/events-offline.spec.js b/frontend/test/vitest/events-offline.spec.js new file mode 100644 index 0000000..ce7075a --- /dev/null +++ b/frontend/test/vitest/events-offline.spec.js @@ -0,0 +1,307 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' +import { createApiRouter, drainQueue, flush, page, remoteEvent, waitFor } from './helpers' + +let api = createApiRouter() + +vi.mock('src/services/apiClient', () => ({ + API_BASE: 'http://api.test', + apiJson: (...args) => api.apiJson(...args), + apiFetch: vi.fn(), + getToken: vi.fn(async () => 'token'), + setToken: vi.fn(async () => {}), + clearToken: vi.fn(async () => {}), + onUnauthorized: vi.fn(() => () => {}), + revokeToken: vi.fn(async () => {}), + loginRemote: vi.fn(), + ApiError: class ApiError extends Error {} +})) + +const EVENTS_LIST = /^\/events\?/ +const EVENTS_CREATE = (url, options) => url === '/events' && options?.method === 'POST' +const EVENT_UPDATE = (url, options) => /^\/events\/[^/]+$/.test(url) && options?.method === 'PUT' +const EVENT_DELETE = (url, options) => /^\/events\/[^/]+$/.test(url) && options?.method === 'DELETE' +const MEDIA_UPLOAD = (url, options) => /^\/events\/[^/]+\/media$/.test(url) && options?.method === 'POST' +const MEDIA_PATCH = (url, options) => /^\/events\/[^/]+\/media\/[^/]+$/.test(url) && options?.method === 'PATCH' +const MEDIA_DELETE = (url, options) => /^\/events\/[^/]+\/media\/[^/]+$/.test(url) && options?.method === 'DELETE' + +// What fetch throws when there is no network: an error without a status. +const networkDown = () => Promise.reject(new TypeError('Failed to fetch')) + +// Past the 300 ms autosave debounce. +async function settle() { + await flush(2) + await new Promise(resolve => setTimeout(resolve, 400)) + await flush(2) +} + +/** + * A server that can be switched off. While down, every write fails like a + * lost connection; the reads keep working so the start-up load succeeds. + */ +function serverWithSwitch() { + const state = { down: false, uploads: 0 } + const guard = respond => (url, options) => (state.down ? networkDown() : respond(url, options)) + + api.always(EVENTS_CREATE, guard((url, options) => Promise.resolve({ data: remoteEvent({ ...(options.body ?? {}) }) }))) + api.always(EVENT_UPDATE, guard((url, options) => Promise.resolve({ + data: remoteEvent({ id: url.split('/').pop(), ...(options.body ?? {}) }) + }))) + api.always(EVENT_DELETE, guard(() => Promise.resolve(null))) + api.always(MEDIA_UPLOAD, guard((url, options) => Promise.resolve({ + data: { + id: `up${++state.uploads}`, + uuid: `uuid-${state.uploads}`, + type: options.body.get('collection') === 'voice_note' ? 'audio' : 'image', + collection: options.body.get('collection'), + name: options.body.get('file')?.name, + caption: options.body.get('caption') ?? '', + mimeType: 'audio/mp4', + durationMs: 1500, + src: null, + thumbnailUrl: null, + previewUrl: null, + originalUrl: `/event-media/up${state.uploads}/original`, + createdAt: 5 + } + }))) + api.always(MEDIA_PATCH, guard((url, options) => Promise.resolve({ + data: { id: url.split('/').pop(), caption: options.body.caption } + }))) + api.always(MEDIA_DELETE, guard(() => Promise.resolve(null))) + + return state +} + +describe('events store — changes made without a connection are kept and sent later', () => { + let eventsStore + let server + let db + + beforeEach(async () => { + setActivePinia(createPinia()) + api = createApiRouter() + vi.resetModules() + + db = (await import('src/db')).db + await db.events.clear() + await db.eventMedia.clear() + await db.syncQueue.clear() + + const { useEventsStore } = await import('stores/events') + const { useAuthStore } = await import('stores/auth') + + api.once(EVENTS_LIST, page([remoteEvent({ id: 'e1', title: 'vorhanden', note: '' })])) + server = serverWithSwitch() + + const authStore = useAuthStore() + eventsStore = useEventsStore() + authStore.currentUserId = 'remote-user-1' + await flush() + }) + + afterEach(async () => { + eventsStore.closePanel() + // A queue still sending when the test ends would hit the next test's + // API mock; let it finish first. + server.down = false + await drainQueue(eventsStore) + }) + + it('keeps an edit made while the server is unreachable and sends it once it is back', async () => { + server.down = true + + eventsStore.openPanel('e1') + await flush(2) + eventsStore.ghostTitle = 'im Zug geändert' + await settle() + await waitFor(() => eventsStore.saveState === 'pending' && eventsStore.pendingSyncCount === 1) + + // Stored locally, flagged as waiting, not as failed. + expect(eventsStore.saveState).toBe('pending') + expect(eventsStore.lastError).toBeNull() + expect(eventsStore.pendingSyncCount).toBe(1) + expect(eventsStore.events.find(event => event.id === 'e1').title).toBe('im Zug geändert') + expect((await db.events.get('e1')).title).toBe('im Zug geändert') + expect(api.callsTo(EVENT_UPDATE)).toHaveLength(1) + + server.down = false + await eventsStore.syncNow() + await waitFor(() => eventsStore.pendingSyncCount === 0 && eventsStore.saveState === 'saved') + + expect(api.callsTo(EVENT_UPDATE)).toHaveLength(2) + expect(api.callsTo(EVENT_UPDATE)[1].options.body.title).toBe('im Zug geändert') + expect(eventsStore.pendingSyncCount).toBe(0) + expect(eventsStore.saveState).toBe('saved') + expect(eventsStore.events.find(event => event.id === 'e1').syncStatus).toBe('synced') + }) + + it('collapses several offline edits of one event into a single request', async () => { + server.down = true + + eventsStore.openPanel('e1') + await flush(2) + eventsStore.ghostTitle = 'eins' + await settle() + eventsStore.ghostTitle = 'zwei' + await settle() + eventsStore.ghostNote = 'und eine Notiz' + await settle() + await waitFor(() => eventsStore.pendingSyncCount === 1) + + server.down = false + await eventsStore.syncNow() + await waitFor(() => eventsStore.pendingSyncCount === 0) + + const sent = api.callsTo(EVENT_UPDATE).filter(call => !call.failed) + const last = sent[sent.length - 1].options.body + expect(last.title).toBe('zwei') + expect(last.note).toBe('und eine Notiz') + expect(eventsStore.pendingSyncCount).toBe(0) + }) + + it('never sends an event that was created and deleted again while offline', async () => { + server.down = true + + eventsStore.openPanel() + await flush(2) + eventsStore.ghostTitle = 'kurzlebig' + await settle() + const id = eventsStore.editingEventId + expect(id).not.toBeNull() + await waitFor(() => eventsStore.pendingSyncCount === 1 && api.callsTo(EVENTS_CREATE).length === 1) + + eventsStore.deleteEvent(id) + await waitFor(() => eventsStore.pendingSyncCount === 0) + expect(eventsStore.events.find(event => event.id === id)).toBeUndefined() + + server.down = false + await eventsStore.syncNow() + await flush() + + // The failed attempt while offline is the only one there ever was. + expect(api.callsTo(EVENTS_CREATE)).toHaveLength(1) + expect(api.callsTo(EVENT_DELETE)).toHaveLength(0) + }) + + it('keeps a recording made offline, uploads it later with the caption typed meanwhile', async () => { + server.down = true + + eventsStore.openPanel('e1') + await flush(2) + + const file = new File(['audio'], 'sprachnotiz.webm', { type: 'audio/webm' }) + const note = await eventsStore.uploadGhostVoiceNote(file, { durationMs: 2100 }) + await waitFor(() => eventsStore.pendingSyncCount === 1) + + expect(note.pending).toBe(true) + expect(eventsStore.ghostVoiceNotes.map(entry => entry.id)).toEqual([note.id]) + // The recording itself is in the queue, so it survives a reload. + const rows = await db.syncQueue.toArray() + expect(rows.map(row => row.action)).toEqual(['upload_media']) + expect(rows[0].payload.bytes).toBeInstanceOf(ArrayBuffer) + + eventsStore.setGhostVoiceNoteCaption(note.id, 'ohne Netz beschriftet') + await new Promise(resolve => setTimeout(resolve, 700)) + await flush() + + // The caption went into the pending upload, not into a request of its own. + expect(api.callsTo(MEDIA_PATCH)).toHaveLength(0) + expect((await db.syncQueue.toArray())[0].payload.caption).toBe('ohne Netz beschriftet') + + server.down = false + await eventsStore.syncNow() + await waitFor(() => eventsStore.pendingSyncCount === 0 && eventsStore.ghostVoiceNotes.some(entry => entry.id === 'up1')) + + const uploads = api.callsTo(MEDIA_UPLOAD) + const sent = uploads[uploads.length - 1].options.body + expect(sent.get('collection')).toBe('voice_note') + expect(sent.get('caption')).toBe('ohne Netz beschriftet') + expect(sent.get('file')?.name).toBe('sprachnotiz.webm') + + // The local item became the server's item, caption included. + expect(eventsStore.ghostVoiceNotes.map(entry => entry.id)).toEqual(['up1']) + expect(eventsStore.ghostVoiceNotes[0].caption).toBe('ohne Netz beschriftet') + expect(eventsStore.pendingSyncCount).toBe(0) + expect(await db.eventMedia.get(note.id)).toBeUndefined() + expect((await db.eventMedia.get('up1'))?.eventId).toBe('e1') + }) + + it('drops a queued upload when the file is removed before it was sent', async () => { + server.down = true + + eventsStore.openPanel('e1') + await flush(2) + + const [item] = await eventsStore.uploadGhostMedia([{ file: new File(['img'], 'foto.jpg', { type: 'image/jpeg' }) }]) + await waitFor(() => eventsStore.pendingSyncCount === 1 && api.callsTo(MEDIA_UPLOAD).length === 1) + + await eventsStore.deleteGhostMedia(item.id) + await waitFor(() => eventsStore.pendingSyncCount === 0) + // Only the attempt that failed while the server was down was ever made. + const attemptsWhileDown = api.callsTo(MEDIA_UPLOAD).length + + server.down = false + await eventsStore.syncNow() + await flush() + expect(api.callsTo(MEDIA_UPLOAD)).toHaveLength(attemptsWhileDown) + expect(api.callsTo(MEDIA_DELETE)).toHaveLength(0) + }) + + it('does not lose queued changes when the app starts with a working connection', async () => { + // Left behind by an earlier, offline session: the edit in the cache and + // its write in the queue. + await db.events.put({ ...remoteEvent({ id: 'e2', title: 'offline bearbeitet', note: 'lokal' }), userId: 'remote-user-2', syncStatus: 'pending' }) + await db.syncQueue.add({ + userId: 'remote-user-2', + eventId: 'e2', + action: 'update', + payload: { id: 'e2', title: 'offline bearbeitet', note: 'lokal', date: '2025-01-01', emotion: 0 }, + createdAt: 1, + attempts: 1 + }) + + setActivePinia(createPinia()) + api = createApiRouter() + // The list reflects what the server holds at the time it is asked, so + // an update that arrives first shows up in it. + const serverCopy = remoteEvent({ id: 'e2', title: 'Serverstand', note: '' }) + api.always(EVENT_UPDATE, (url, options) => { + Object.assign(serverCopy, options.body ?? {}) + return Promise.resolve({ data: { ...serverCopy } }) + }) + api.once(EVENTS_LIST, () => Promise.resolve(page([{ ...serverCopy }]))) + + const { useEventsStore } = await import('stores/events') + const { useAuthStore } = await import('stores/auth') + const authStore = useAuthStore() + const store = useEventsStore() + authStore.currentUserId = 'remote-user-2' + // isLoaded is already true from the userless start; wait for the load + // that belongs to this user. + await waitFor(() => api.callsTo(EVENTS_LIST).length === 1 && store.isLoaded && store.pendingSyncCount === 0) + + // The queue went out before the list was read, so the server already + // knows the edit and the timeline shows it. + expect(api.callsTo(EVENT_UPDATE)).toHaveLength(1) + expect(api.callsTo(EVENT_UPDATE)[0].options.body.title).toBe('offline bearbeitet') + expect(store.pendingSyncCount).toBe(0) + expect(store.events).toHaveLength(1) + expect(store.events[0].title).toBe('offline bearbeitet') + }) + + it('a rejected edit is reported as an error and leaves the queue', async () => { + api = createApiRouter() + api.always(EVENT_UPDATE, () => Promise.reject(Object.assign(new Error('unprocessable'), { status: 422 }))) + + eventsStore.openPanel('e1') + await flush(2) + eventsStore.ghostTitle = 'abgelehnt' + await settle() + + expect(eventsStore.saveState).toBe('error') + expect(eventsStore.lastError).toBeTruthy() + expect(eventsStore.pendingSyncCount).toBe(0) + expect(eventsStore.events.find(event => event.id === 'e1').syncStatus).toBe('error') + }) +}) diff --git a/frontend/test/vitest/events-user-context.spec.js b/frontend/test/vitest/events-user-context.spec.js new file mode 100644 index 0000000..09f4875 --- /dev/null +++ b/frontend/test/vitest/events-user-context.spec.js @@ -0,0 +1,184 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' +import { createApiRouter, deferred, flush, page, remoteEvent } from './helpers' + +let api = createApiRouter() + +vi.mock('src/services/apiClient', () => ({ + API_BASE: 'http://api.test', + apiJson: (...args) => api.apiJson(...args), + apiFetch: vi.fn(), + getToken: vi.fn(async () => 'token'), + setToken: vi.fn(async () => {}), + clearToken: vi.fn(async () => {}), + onUnauthorized: vi.fn(() => () => {}), + revokeToken: vi.fn(async () => {}), + loginRemote: vi.fn(), + ApiError: class ApiError extends Error {} +})) + + +const EVENTS_LIST = /^\/events\?/ + +describe('events store — a response belongs to the user that asked for it (A04)', () => { + let useEventsStore + let useAuthStore + + beforeEach(async () => { + setActivePinia(createPinia()) + api = createApiRouter() + vi.resetModules() + + const { db } = await import('src/db') + await db.events.clear() + + useEventsStore = (await import('stores/events')).useEventsStore + useAuthStore = (await import('stores/auth')).useAuthStore + }) + + it('discards a late response from the previous user', async () => { + const authStore = useAuthStore() + const eventsStore = useEventsStore() + + const loadA = deferred() + const loadB = deferred() + api.once(EVENTS_LIST, () => loadA.promise) + api.once(EVENTS_LIST, () => loadB.promise) + + // User A starts loading. + authStore.currentUserId = 'remote-user-1' + await flush() + + // Switch to B while A's request is still open. + authStore.currentUserId = 'remote-user-2' + await flush() + + // B answers first; A's stale response arrives afterwards. + loadB.resolve(page([remoteEvent({ id: 'b1', title: 'Event von B' })])) + await flush() + loadA.resolve(page([remoteEvent({ id: 'a1', title: 'privat A' })])) + await flush() + + expect(eventsStore.events.map(event => event.id)).toEqual(['b1']) + expect(eventsStore.events.every(event => event.userId === 'remote-user-2')).toBe(true) + }) + + it('does not write the previous user\'s events into the new user\'s cache', async () => { + const authStore = useAuthStore() + useEventsStore() + const { db } = await import('src/db') + + const loadA = deferred() + api.once(EVENTS_LIST, () => loadA.promise) + api.once(EVENTS_LIST, () => Promise.resolve(page([remoteEvent({ id: 'b1' })]))) + + authStore.currentUserId = 'remote-user-1' + await flush() + authStore.currentUserId = 'remote-user-2' + await flush() + + loadA.resolve(page([remoteEvent({ id: 'a1', title: 'privat A' })])) + await flush() + + const cached = await db.events.toArray() + expect(cached.map(event => event.id)).toEqual(['b1']) + expect(cached.every(event => event.userId === 'remote-user-2')).toBe(true) + }) + + it('stamps loaded events with the user the request was started for', async () => { + const authStore = useAuthStore() + const eventsStore = useEventsStore() + + api.once(EVENTS_LIST, page([remoteEvent({ id: 'a1' })])) + + authStore.currentUserId = 'remote-user-1' + await flush() + + expect(eventsStore.events[0].userId).toBe('remote-user-1') + }) + + it('keeps the timeline empty for the new user until their own data arrives', async () => { + const authStore = useAuthStore() + const eventsStore = useEventsStore() + + api.once(EVENTS_LIST, page([remoteEvent({ id: 'a1', title: 'privat A' })])) + authStore.currentUserId = 'remote-user-1' + await flush() + expect(eventsStore.events).toHaveLength(1) + + const loadB = deferred() + api.once(EVENTS_LIST, () => loadB.promise) + authStore.currentUserId = 'remote-user-2' + await flush() + + // A's data must be gone the moment the user switches, not only once B + // has answered. + expect(eventsStore.events).toEqual([]) + + loadB.resolve(page([])) + await flush() + expect(eventsStore.events).toEqual([]) + }) +}) + +describe('events store — the API payload carries every edited field (A05)', () => { + let useEventsStore + let useAuthStore + + beforeEach(async () => { + setActivePinia(createPinia()) + api = createApiRouter() + vi.resetModules() + + const { db } = await import('src/db') + await db.events.clear() + + useEventsStore = (await import('stores/events')).useEventsStore + useAuthStore = (await import('stores/auth')).useAuthStore + }) + + it('sends location and keyImageTitle when creating an event', async () => { + const authStore = useAuthStore() + const eventsStore = useEventsStore() + + api.once(EVENTS_LIST, page([])) + api.always('/events', () => Promise.resolve({ data: remoteEvent() })) + + authStore.currentUserId = 'remote-user-1' + await flush() + + const created = eventsStore.createEvent() + await flush() + + const post = api.callsTo('/events').find(call => call.options?.method === 'POST') + expect(post).toBeDefined() + expect(post.options.body.id).toBe(created.id) + expect(post.options.body).toHaveProperty('location') + expect(post.options.body).toHaveProperty('keyImageTitle') + }) + + it('sends the edited location and key image title on update', async () => { + const authStore = useAuthStore() + const eventsStore = useEventsStore() + + api.once(EVENTS_LIST, page([remoteEvent({ id: 'e1', title: 'Original' })])) + api.always(/^\/events\/e1$/, () => Promise.resolve({ data: remoteEvent({ id: 'e1' }) })) + + authStore.currentUserId = 'remote-user-1' + await flush() + + eventsStore.openPanel('e1') + await flush() + + eventsStore.ghostLocation = 'Zugspitze' + eventsStore.ghostKeyImageTitle = 'Gipfelfoto' + await flush() + eventsStore.saveGhostNow() + await flush() + + const put = api.callsTo(/^\/events\/e1$/).find(call => call.options?.method === 'PUT') + expect(put).toBeDefined() + expect(put.options.body.location).toBe('Zugspitze') + expect(put.options.body.keyImageTitle).toBe('Gipfelfoto') + }) +}) diff --git a/frontend/test/vitest/helpers.js b/frontend/test/vitest/helpers.js new file mode 100644 index 0000000..561d1ab --- /dev/null +++ b/frontend/test/vitest/helpers.js @@ -0,0 +1,139 @@ +import { nextTick } from 'vue' + +/** + * A promise whose settling the test controls, used to hold an API response + * open across a user switch. + */ +export function deferred() { + let resolve + let reject + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + return { promise, resolve, reject } +} + +/** + * Lets Vue's watchers, the promise chains behind them and the IndexedDB + * writes settle. IndexedDB resolves on real event-loop turns, so flushing + * microtasks alone is not enough. + */ +export async function flush(times = 6) { + for (let i = 0; i < times; i++) { + await nextTick() + await new Promise(resolve => setTimeout(resolve, 0)) + } +} + +/** + * Waits until `predicate` holds, flushing between checks. The sync queue + * moves through several IndexedDB turns per item, and how many a run needs + * depends on machine load; polling keeps the tests from guessing. + */ +export async function waitFor(predicate, { rounds = 60, label = 'condition' } = {}) { + for (let i = 0; i < rounds; i++) { + if (predicate()) return + await flush(1) + } + if (!predicate()) throw new Error(`waitFor: ${label} not met in time`) +} + +/** + * Lets a store's sync queue finish what it is sending. Called after a test: + * a request still in flight when the test ends would land in the next + * test's API mock. The first flushes give a write that was just scheduled + * (closing the panel persists the draft) time to reach the queue at all. + */ +export async function drainQueue(store) { + await flush(3) + await waitFor( + () => store.pendingSyncCount === 0 && !store.isSyncing, + { rounds: 100, label: 'queue drained' } + ).catch(() => {}) +} + +/** + * Routes mocked `apiJson` calls by URL. + * + * Several stores talk to the API during start-up — the settings store fires + * `/settings` alongside the events store's `/events` — so responses have to + * be matched on the request, not on call order. + */ +export function createApiRouter() { + const handlers = [] + const calls = [] + + function apiJson(url, options = {}) { + calls.push({ url, options }) + + const handler = handlers.find(entry => entry.matches(url, options)) + if (!handler) { + return Promise.resolve({ data: [], links: { next: null } }) + } + + return handler.respond(url, options) + } + + return { + apiJson, + calls, + /** Queue a response for the next matching request. */ + once(matcher, respond) { + const entry = { + matches: (url, options) => toMatcher(matcher)(url, options) && !entry.used, + respond: (url, options) => { + entry.used = true + return typeof respond === 'function' ? respond(url, options) : Promise.resolve(respond) + } + } + handlers.push(entry) + return entry + }, + /** Queue a response for every matching request. */ + always(matcher, respond) { + handlers.push({ + matches: toMatcher(matcher), + respond: (url, options) => (typeof respond === 'function' ? respond(url, options) : Promise.resolve(respond)) + }) + }, + callsTo(matcher) { + const match = toMatcher(matcher) + return calls.filter(call => match(call.url, call.options)) + } + } +} + +function toMatcher(matcher) { + if (typeof matcher === 'function') return matcher + if (matcher instanceof RegExp) return url => matcher.test(url) + return url => url === matcher || url.startsWith(matcher) +} + +/** A remote event payload in the shape the API returns. */ +export function remoteEvent(overrides = {}) { + return { + id: 'e1', + title: 'Event', + date: '2025-01-01', + endDate: null, + location: '', + category: null, + people: [], + emotion: 0, + milestone: false, + customColor: null, + gradientPreset: null, + image: null, + keyImageTitle: '', + media: [], + note: '', + createdAt: 1, + updatedAt: 1, + ...overrides + } +} + +export function page(events) { + return { data: events, links: { next: null } } +} diff --git a/frontend/test/vitest/setup.js b/frontend/test/vitest/setup.js new file mode 100644 index 0000000..6583251 --- /dev/null +++ b/frontend/test/vitest/setup.js @@ -0,0 +1,28 @@ +// Dexie needs a real IndexedDB implementation; happy-dom does not ship one. +import 'fake-indexeddb/auto' +import { afterEach, vi } from 'vitest' +import { config } from '@vue/test-utils' +import { Notify, Quasar } from 'quasar' + +// Components read `$q` and use Quasar components in their templates. This +// mirrors what the Quasar boot does in the app, limited to what tests need. +config.global.plugins.unshift([Quasar, { plugins: { Notify } }]) + +// Stores read `navigator.onLine` at module load. +if (typeof navigator !== 'undefined' && navigator.onLine === undefined) { + Object.defineProperty(navigator, 'onLine', { value: true, configurable: true }) +} + +// The timeline observes its own size; happy-dom has no ResizeObserver. +if (typeof globalThis.ResizeObserver === 'undefined') { + globalThis.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + } +} + +afterEach(() => { + vi.useRealTimers() + localStorage.clear() +}) diff --git a/frontend/test/vitest/wave-presets.spec.js b/frontend/test/vitest/wave-presets.spec.js new file mode 100644 index 0000000..aa3b81c --- /dev/null +++ b/frontend/test/vitest/wave-presets.spec.js @@ -0,0 +1,188 @@ +import { existsSync } from 'node:fs' +import { resolve } from 'node:path' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' +import { createApiRouter } from './helpers' + +let api = createApiRouter() + +vi.mock('src/services/apiClient', () => ({ + API_BASE: 'http://api.test', + apiJson: (...args) => api.apiJson(...args), + apiFetch: vi.fn(), + getToken: vi.fn(async () => 'token'), + setToken: vi.fn(async () => {}), + clearToken: vi.fn(async () => {}), + onUnauthorized: vi.fn(() => () => {}), + revokeToken: vi.fn(async () => {}), + loginRemote: vi.fn(), + ApiError: class ApiError extends Error {} +})) + + +import { BRAND_PRESET_ID, WAVE_PRESETS, findWavePreset, isBuiltInPresetId, presetPreviewStyle } from 'src/config/wavePresets' +import { FLOATING_LINES_DEFAULTS, useSettingsStore } from 'stores/settings' +import { relativeLuminance } from 'src/utils/brand' + +// Vitest runs from the frontend directory. +const publicDir = resolve(process.cwd(), 'public') + +describe('built-in wave presets', () => { + it('offers ten presets with unique ids and names', () => { + expect(WAVE_PRESETS).toHaveLength(10) + expect(new Set(WAVE_PRESETS.map(preset => preset.id)).size).toBe(10) + expect(new Set(WAVE_PRESETS.map(preset => preset.name)).size).toBe(10) + for (const preset of WAVE_PRESETS) { + expect(isBuiltInPresetId(preset.id)).toBe(true) + expect(preset.description).toBeTruthy() + } + }) + + it('only sets keys the wave actually knows', () => { + const known = new Set(Object.keys(FLOATING_LINES_DEFAULTS)) + for (const preset of WAVE_PRESETS) { + for (const key of Object.keys(preset.floatingLines)) { + expect(known.has(key), `${preset.id} sets unknown key ${key}`).toBe(true) + } + } + }) + + it('references background images that ship with the app', () => { + for (const preset of WAVE_PRESETS) { + const image = preset.floatingLines.backgroundImage + if (!image) continue + expect(existsSync(`${publicDir}${image}`), `${preset.id}: ${image} missing`).toBe(true) + } + }) + + it('uses static lines on light ground, because glow only works on dark', () => { + for (const preset of WAVE_PRESETS) { + const { bgEdge, bgCenter, backgroundImage, lineMode } = preset.floatingLines + if (backgroundImage) continue + const luminance = relativeLuminance(bgEdge) * 0.8 + relativeLuminance(bgCenter) * 0.2 + if (luminance > 0.25) { + expect(lineMode, `${preset.id} is light but glows`).toBe('static') + } + } + }) + + it('keeps every numeric value inside the slider ranges of the panel', () => { + const ranges = { + speed: [0, 2], lineCount: [1, 10], spread: [0.01, 0.5], fanSpread: [0.01, 0.3], + lineSharpness: [5, 10], lineThickness: [0.5, 4], waveFrequency: [1, 10], + bezierCurvature: [-0.5, 0.5], circleRadius: [50, 200], glowSize: [0, 50], + glowStrength: [0, 10], lineBrightness: [0.05, 1], dotBorderWidth: [0, 8], + horizonOpacity: [0.05, 1], horizonBlend: [0, 1], labelOpacity: [0.5, 1], + labelConnectorLength: [0, 1], staticLineShadowStrength: [0, 1] + } + for (const preset of WAVE_PRESETS) { + for (const [key, [min, max]] of Object.entries(ranges)) { + const value = preset.floatingLines[key] + if (value === undefined) continue + expect(value, `${preset.id}.${key}`).toBeGreaterThanOrEqual(min) + expect(value, `${preset.id}.${key}`).toBeLessThanOrEqual(max) + } + } + }) + + it('has a brand preset that matches the defaults', () => { + const brand = findWavePreset(BRAND_PRESET_ID) + expect(brand).not.toBeNull() + for (const [key, value] of Object.entries(brand.floatingLines)) { + expect(FLOATING_LINES_DEFAULTS[key], key).toBe(value) + } + }) + + it('renders a preview without loading the background photo', () => { + for (const preset of WAVE_PRESETS) { + const style = presetPreviewStyle(preset) + expect(style.background).toContain('gradient') + expect(style.background).not.toContain('url(') + } + }) +}) + +describe('settings store — applying presets', () => { + let settingsStore + + beforeEach(() => { + setActivePinia(createPinia()) + api = createApiRouter() + localStorage.clear() + settingsStore = useSettingsStore() + }) + + it('applies a built-in preset on top of the defaults and marks it active', () => { + expect(settingsStore.applyPreset('builtin:aurora')).toBe(true) + + expect(settingsStore.activePresetId).toBe('builtin:aurora') + expect(settingsStore.floatingLines.gradientStops).toBe('#5cf2c2\n#2f8fff\n#0a0a12') + expect(settingsStore.floatingLines.horizonMode).toBe('fog') + // Untouched keys come from the defaults, not from a previous look. + expect(settingsStore.floatingLines.circleRadius).toBe(FLOATING_LINES_DEFAULTS.circleRadius) + }) + + it('switching presets does not carry values over', () => { + settingsStore.applyPreset('builtin:neon') + expect(settingsStore.floatingLines.dotBorderWidth).toBe(2) + + settingsStore.applyPreset('builtin:aurora') + expect(settingsStore.floatingLines.dotBorderWidth).toBe(FLOATING_LINES_DEFAULTS.dotBorderWidth) + expect(settingsStore.floatingLines.backgroundImage).toBe('') + }) + + it('rejects unknown ids and refuses to delete a built-in preset', () => { + expect(settingsStore.applyPreset('builtin:does-not-exist')).toBe(false) + expect(settingsStore.deletePreset('builtin:gold')).toBe(false) + expect(settingsStore.builtInPresets).toHaveLength(10) + }) + + it('saves, applies and deletes an own preset', () => { + settingsStore.updateFloatingLines({ lineCount: 3 }) + const saved = settingsStore.savePreset('Mein Look') + expect(saved).toBeTruthy() + expect(settingsStore.activePresetId).toBe(saved.id) + + settingsStore.applyPreset('builtin:paper') + expect(settingsStore.floatingLines.lineCount).toBe(FLOATING_LINES_DEFAULTS.lineCount) + + expect(settingsStore.applyPreset(saved.id)).toBe(true) + expect(settingsStore.floatingLines.lineCount).toBe(3) + + expect(settingsStore.deletePreset(saved.id)).toBe(true) + expect(settingsStore.presets).toHaveLength(0) + expect(settingsStore.activePresetId).toBeNull() + // The look itself stays. + expect(settingsStore.floatingLines.lineCount).toBe(3) + }) + + it('reset returns to the brand defaults', () => { + settingsStore.applyPreset('builtin:neon') + settingsStore.resetFloatingLines() + + expect(settingsStore.floatingLines).toEqual(FLOATING_LINES_DEFAULTS) + expect(settingsStore.activePresetId).toBeNull() + }) +}) + +describe('settings store — accent colour defaults', () => { + beforeEach(() => { + setActivePinia(createPinia()) + api = createApiRouter() + localStorage.clear() + }) + + it('treats the pre-brand grey default as gold and keeps a chosen colour', () => { + // Accounts from before the palette persisted 'base' without ever choosing it. + localStorage.setItem('thatsme-settings:guest', JSON.stringify({ accentColor: 'base' })) + expect(useSettingsStore().accentColor).toBe('gold') + + setActivePinia(createPinia()) + localStorage.setItem('thatsme-settings:guest', JSON.stringify({ accentColor: 'violet' })) + expect(useSettingsStore().accentColor).toBe('violet') + + setActivePinia(createPinia()) + localStorage.setItem('thatsme-settings:guest', JSON.stringify({ accentColor: 'grey' })) + expect(useSettingsStore().accentColor).toBe('grey') + }) +}) diff --git a/frontend/vitest.config.js b/frontend/vitest.config.js new file mode 100644 index 0000000..607f68b --- /dev/null +++ b/frontend/vitest.config.js @@ -0,0 +1,43 @@ +import { fileURLToPath } from 'node:url' +import vue from '@vitejs/plugin-vue' +import { defineConfig } from 'vitest/config' + +/** + * Standalone config for unit tests. + * + * Deliberately not built on top of `quasar.config.js`: these tests exercise + * the Pinia stores and plain modules, which need nothing from the Quasar + * build pipeline. The aliases mirror the ones Quasar injects, so imports in + * the source files resolve identically under test. + */ +const resolve = path => fileURLToPath(new URL(path, import.meta.url)) + +export default defineConfig({ + plugins: [vue()], + resolve: { + alias: [ + // Quasar's package exports hand Node the SSR build, whose `install` + // expects an SSR context. Tests run the client build, like the browser. + { find: /^quasar$/, replacement: resolve('./node_modules/quasar/dist/quasar.client.js') }, + { find: /^src\//, replacement: resolve('./src') + '/' }, + { find: /^app\//, replacement: resolve('.') + '/' }, + { find: /^stores\//, replacement: resolve('./src/stores') + '/' }, + { find: /^components\//, replacement: resolve('./src/components') + '/' }, + { find: /^layouts\//, replacement: resolve('./src/layouts') + '/' }, + { find: /^pages\//, replacement: resolve('./src/pages') + '/' }, + { find: /^boot\//, replacement: resolve('./src/boot') + '/' }, + { find: /^assets\//, replacement: resolve('./src/assets') + '/' }, + { find: /^composables\//, replacement: resolve('./src/composables') + '/' } + ] + }, + test: { + environment: 'happy-dom', + // Component styles are irrelevant to behaviour; skipping them keeps the + // SCSS in the single-file components out of the test transform. + css: false, + globals: true, + setupFiles: ['./test/vitest/setup.js'], + include: ['test/vitest/**/*.spec.js'], + restoreMocks: true + } +})