diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5325ff6c..20f95839 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -41,6 +41,9 @@ }, "devDependencies": { "@eslint/js": "^9.21.0", + "@testing-library/jest-dom": "^6.8.0", + "@testing-library/react": "^16.3.0", + "@testing-library/user-event": "^14.6.1", "@types/react": "^19.0.10", "@types/react-dom": "^19.0.4", "@vitejs/plugin-react-swc": "^3.8.0", @@ -48,10 +51,53 @@ "eslint-plugin-react-hooks": "^5.1.0", "eslint-plugin-react-refresh": "^0.4.19", "globals": "^15.15.0", + "jsdom": "^27.0.0", "prettier": "^3.5.3", - "vite": "^6.2.0" + "vite": "^6.2.0", + "vitest": "^3.2.4" } }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.0.4.tgz", + "integrity": "sha512-cKjSKvWGmAziQWbCouOsFwb14mp1betm8Y7Fn+yglDMUUu3r9DCbJ9iJbeFDenLMqFbIMC0pQP8K+B8LAxX3OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.4", + "@csstools/css-color-parser": "^3.0.10", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4", + "lru-cache": "^11.1.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "6.5.5", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.5.5.tgz", + "integrity": "sha512-kI2MX9pmImjxWT8nxDZY+MuN6r1jJGe7WxizEbsAEPB/zxfW5wYLIiPG1v3UKgEOOP8EsDkp0ZL99oRFAdPM8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.1.0", + "is-potential-custom-element-name": "^1.0.1" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.26.2", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", @@ -194,6 +240,144 @@ "node": ">=6.9.0" } }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.14.tgz", + "integrity": "sha512-zSlIxa20WvMojjpCSy8WrNpcZ61RqfTfX3XTaOeVlGJrt/8HF3YbzgFZa01yTbT4GWQLwfTcC3EB8i3XnB647Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@dnd-kit/accessibility": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", @@ -727,9 +911,9 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", - "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { @@ -1019,6 +1203,114 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.8.0", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.8.0.tgz", + "integrity": "sha512-WgXcWzVM6idy5JaftTVC8Vs83NKRmGJz4Hqs4oyOuO2J4r/y79vvKZsb+CaGyCSEbUPI6OsewfPd0G1A0/TUZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.0.tgz", + "integrity": "sha512-kFSyxiEDwv1WLl2fgsq6pPBbw5aWKrsY2/noi1Id0TK0UParSF62oFQFGHXIyaG4pp2tEub/Zlel+fjjZILDsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/chai": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.2.tgz", + "integrity": "sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*" + } + }, "node_modules/@types/d3-array": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.1.tgz", @@ -1082,6 +1374,13 @@ "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", "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.6", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", @@ -1192,6 +1491,121 @@ "vite": "^4 || ^5 || ^6" } }, + "node_modules/@vitest/expect": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.4", + "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/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.4", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@xmldom/xmldom": { "version": "0.8.10", "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.10.tgz", @@ -1236,6 +1650,16 @@ "pkcs7": "^1.0.4" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -1284,6 +1708,17 @@ "react-dom": "16.8.0 - 18" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -1307,6 +1742,26 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "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/attr-accept": { "version": "2.2.5", "resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.5.tgz", @@ -1338,6 +1793,16 @@ "dev": true, "license": "MIT" }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -1349,6 +1814,16 @@ "concat-map": "0.0.1" } }, + "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/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -1358,6 +1833,23 @@ "node": ">=6" } }, + "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", @@ -1375,6 +1867,16 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/check-error": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", + "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/classnames": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", @@ -1472,6 +1974,42 @@ "node": ">= 8" } }, + "node_modules/css-tree": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", + "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.12.2", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssstyle": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.1.tgz", + "integrity": "sha512-g5PC9Aiph9eiczFpcgUhd9S4UUO3F+LHGRIi5NUMZ+4xtoIYbHNZwZnWA2JsFGe8OU8nl4WyaEFiZuGuxlutJQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^4.0.3", + "@csstools/css-syntax-patches-for-csstree": "^1.0.14", + "css-tree": "^3.1.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", @@ -1599,6 +2137,20 @@ "node": ">=12" } }, + "node_modules/data-urls": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.0.tgz", + "integrity": "sha512-BnBS08aLUM+DKamupXs3w2tJJoqU+AkaE/+6vQxi/G/DPmIZFJJp9Dkb1kM03AZx8ADehDUZgsNxju3mPXZYIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^15.0.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/dayjs": { "version": "1.11.13", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz", @@ -1606,9 +2158,9 @@ "license": "MIT" }, "node_modules/debug": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", - "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -1622,12 +2174,29 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/decimal.js-light": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", "license": "MIT" }, + "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", @@ -1644,12 +2213,30 @@ "node": ">=0.10.0" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-node-es": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", "license": "MIT" }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/dom-helpers": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", @@ -1665,6 +2252,19 @@ "resolved": "https://registry.npmjs.org/dom-walk/-/dom-walk-0.1.2.tgz", "integrity": "sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w==" }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/error-ex": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", @@ -1674,6 +2274,13 @@ "is-arrayish": "^0.2.1" } }, + "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/es6-promise": { "version": "4.2.8", "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz", @@ -1901,6 +2508,16 @@ "node": ">=4.0" } }, + "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/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -1917,6 +2534,16 @@ "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", "license": "MIT" }, + "node_modules/expect-type": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.2.tgz", + "integrity": "sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -1947,11 +2574,14 @@ "license": "MIT" }, "node_modules/fdir": { - "version": "6.4.4", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.4.tgz", - "integrity": "sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==", + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "dev": true, "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, "peerDependencies": { "picomatch": "^3 || ^4" }, @@ -2167,6 +2797,60 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -2203,6 +2887,16 @@ "node": ">=0.8.19" } }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/internmap": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", @@ -2262,6 +2956,13 @@ "node": ">=0.10.0" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -2288,6 +2989,46 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "27.0.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.0.0.tgz", + "integrity": "sha512-lIHeR1qlIRrIN5VMccd8tI2Sgw6ieYXSVktcSHaNe3Z5nE/tcPQYQWOq00wxMvYOsz+73eAkNenVvmPC6bba9A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/dom-selector": "^6.5.4", + "cssstyle": "^5.3.0", + "data-urls": "^6.0.0", + "decimal.js": "^10.5.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "parse5": "^7.3.0", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^15.0.0", + "ws": "^8.18.2", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -2432,6 +3173,23 @@ "loose-envify": "cli.js" } }, + "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/lru-cache": { + "version": "11.2.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.1.tgz", + "integrity": "sha512-r8LA6i4LP4EeWOhqBaZZjDWwehd1xUJPCJd9Sv300H0ZmcUER4+JPh7bqqZeqs1o5pgtgvXm+d9UGrB5zZGDiQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/lucide-react": { "version": "0.511.0", "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.511.0.tgz", @@ -2441,6 +3199,17 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/m3u8-parser": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/m3u8-parser/-/m3u8-parser-7.2.0.tgz", @@ -2452,6 +3221,23 @@ "global": "^4.4.0" } }, + "node_modules/magic-string": { + "version": "0.30.19", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.19.tgz", + "integrity": "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mdn-data": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", + "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/memoize-one": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", @@ -2466,6 +3252,16 @@ "dom-walk": "^0.1.0" } }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -2642,6 +3438,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -2677,6 +3486,23 @@ "node": ">=8" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/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/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -2684,9 +3510,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", - "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", "engines": { @@ -2763,6 +3589,44 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/process": { "version": "0.11.10", "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", @@ -3157,12 +4021,36 @@ "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", "license": "MIT" }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/regenerator-runtime": { "version": "0.14.1", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", "license": "MIT" }, + "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.10", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", @@ -3231,6 +4119,33 @@ "fsevents": "~2.3.2" } }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.25.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0.tgz", @@ -3266,6 +4181,13 @@ "node": ">=8" } }, + "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/source-map": { "version": "0.5.7", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", @@ -3285,6 +4207,33 @@ "node": ">=0.10.0" } }, + "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/std-env": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.9.0.tgz", + "integrity": "sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -3298,6 +4247,26 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strip-literal": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.0.0.tgz", + "integrity": "sha512-TcccoMhJOM3OebGhSBEmp3UZ2SfDMZUEBdRA/9ynfLi8yYajyWX3JiXArcJt4Umh4vISpspkQIY8ZZoCqjbviA==", + "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/stylis": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", @@ -3329,6 +4298,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tabbable": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz", @@ -3353,15 +4329,29 @@ "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==", "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.13", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.13.tgz", - "integrity": "sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw==", + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", "dev": true, "license": "MIT", "dependencies": { - "fdir": "^6.4.4", - "picomatch": "^4.0.2" + "fdir": "^6.5.0", + "picomatch": "^4.0.3" }, "engines": { "node": ">=12.0.0" @@ -3370,12 +4360,88 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "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.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.15.tgz", + "integrity": "sha512-heYRCiGLhtI+U/D0V8YM3QRwPfsLJiP+HX+YwiHZTnWzjIKC+ZCxQRYlzvOoTEc6KIP62B1VeAN63diGCng2hg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.15" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.15.tgz", + "integrity": "sha512-YBkp2VfS9VTRMPNL2PA6PMESmxV1JEVoAr5iBlZnB5JG3KUrWzNCB3yNNkRa2FZkqClaBgfNYCp8PgpYmpjkZw==", + "dev": true, + "license": "MIT" + }, "node_modules/toposort": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz", "integrity": "sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==", "license": "MIT" }, + "node_modules/tough-cookie": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", + "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -3653,11 +4719,167 @@ } } }, + "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/vitest": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "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.4", + "@vitest/ui": "3.2.4", + "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/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.0.tgz", + "integrity": "sha512-n4W4YFyz5JzOfQeA8oN7dUYpR+MBP3PIUsn2jLjWXwK5ASUzt0Jc/A5sAUZoCYFJRGF0FBKJ+1JjN43rNdsQzA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, "node_modules/webworkify-webpack": { "version": "2.1.5", "resolved": "git+ssh://git@github.com/xqq/webworkify-webpack.git#24d1e719b4a6cac37a518b2bb10fe124527ef4ef", "license": "MIT" }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-15.1.0.tgz", + "integrity": "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -3674,6 +4896,23 @@ "node": ">= 8" } }, + "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/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -3684,6 +4923,45 @@ "node": ">=0.10.0" } }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "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/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 1f6c769d..960eaa64 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,7 +7,9 @@ "dev": "vite --host", "build": "vite build", "lint": "eslint .", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest --run", + "test:watch": "vitest" }, "dependencies": { "@dnd-kit/core": "^6.3.1", @@ -43,6 +45,9 @@ }, "devDependencies": { "@eslint/js": "^9.21.0", + "@testing-library/jest-dom": "^6.8.0", + "@testing-library/react": "^16.3.0", + "@testing-library/user-event": "^14.6.1", "@types/react": "^19.0.10", "@types/react-dom": "^19.0.4", "@vitejs/plugin-react-swc": "^3.8.0", @@ -50,7 +55,9 @@ "eslint-plugin-react-hooks": "^5.1.0", "eslint-plugin-react-refresh": "^0.4.19", "globals": "^15.15.0", + "jsdom": "^27.0.0", "prettier": "^3.5.3", - "vite": "^6.2.0" + "vite": "^6.2.0", + "vitest": "^3.2.4" } } diff --git a/frontend/src/pages/Guide.jsx b/frontend/src/pages/Guide.jsx index 7671fb57..9c90bb1d 100644 --- a/frontend/src/pages/Guide.jsx +++ b/frontend/src/pages/Guide.jsx @@ -1,5 +1,11 @@ // frontend/src/pages/Guide.js -import React, { useMemo, useState, useEffect, useRef } from 'react'; +import React, { + useMemo, + useState, + useEffect, + useRef, + useCallback, +} from 'react'; import dayjs from 'dayjs'; import API from '../api'; import useChannelsStore from '../store/channels'; @@ -23,23 +29,218 @@ import { Transition, Modal, Stack, - useMantineTheme, } from '@mantine/core'; import { Search, X, Clock, Video, Calendar, Play } from 'lucide-react'; import './guide.css'; import useEPGsStore from '../store/epgs'; import useLocalStorage from '../hooks/useLocalStorage'; +import { useElementSize } from '@mantine/hooks'; +import { VariableSizeList } from 'react-window'; +import { + PROGRAM_HEIGHT, + EXPANDED_PROGRAM_HEIGHT, + buildChannelIdMap, + mapProgramsByChannel, + computeRowHeights, +} from './guideUtils'; /** Layout constants */ const CHANNEL_WIDTH = 120; // Width of the channel/logo column -const PROGRAM_HEIGHT = 90; // Height of each channel row -const EXPANDED_PROGRAM_HEIGHT = 180; // Height for expanded program rows const HOUR_WIDTH = 450; // Increased from 300 to 450 to make each program wider const MINUTE_INCREMENT = 15; // For positioning programs every 15 min const MINUTE_BLOCK_WIDTH = HOUR_WIDTH / (60 / MINUTE_INCREMENT); +const GuideRow = React.memo(({ index, style, data }) => { + const { + filteredChannels, + programsByChannelId, + expandedProgramId, + rowHeights, + logos, + hoveredChannelId, + setHoveredChannelId, + renderProgram, + handleLogoClick, + contentWidth, + } = data; + + const channel = filteredChannels[index]; + if (!channel) { + return null; + } + + const channelPrograms = programsByChannelId.get(channel.id) || []; + const rowHeight = + rowHeights[index] ?? + (channelPrograms.some((program) => program.id === expandedProgramId) + ? EXPANDED_PROGRAM_HEIGHT + : PROGRAM_HEIGHT); + + return ( +
+ + handleLogoClick(channel, event)} + onMouseEnter={() => setHoveredChannelId(channel.id)} + onMouseLeave={() => setHoveredChannelId(null)} + > + {hoveredChannelId === channel.id && ( + + + + )} + + + + {channel.name} + + + + {channel.channel_number || '-'} + + + + + + {channelPrograms.length > 0 ? ( + channelPrograms.map((program) => renderProgram(program)) + ) : ( + <> + {Array.from({ length: Math.ceil(24 / 2) }).map( + (_, placeholderIndex) => ( + + No program data + + ) + )} + + )} + + +
+ ); +}); + export default function TVChannelGuide({ startDate, endDate }) { - const theme = useMantineTheme(); const channels = useChannelsStore((s) => s.channels); const recordings = useChannelsStore((s) => s.recordings); const channelGroups = useChannelsStore((s) => s.channelGroups); @@ -59,7 +260,6 @@ export default function TVChannelGuide({ startDate, endDate }) { const [existingRuleMode, setExistingRuleMode] = useState(null); const [rulesOpen, setRulesOpen] = useState(false); const [rules, setRules] = useState([]); - const [loading, setLoading] = useState(true); const [initialScrollComplete, setInitialScrollComplete] = useState(false); // New filter states @@ -71,6 +271,15 @@ export default function TVChannelGuide({ startDate, endDate }) { const guideRef = useRef(null); const timelineRef = useRef(null); // New ref for timeline scrolling + const listRef = useRef(null); + const isSyncingScroll = useRef(false); + const guideScrollLeftRef = useRef(0); + const { + ref: guideContainerRef, + width: guideWidth, + height: guideHeight, + } = useElementSize(); + const [guideScrollLeft, setGuideScrollLeft] = useState(0); // Add new state to track hovered logo const [hoveredChannelId, setHoveredChannelId] = useState(null); @@ -80,7 +289,6 @@ export default function TVChannelGuide({ startDate, endDate }) { if (!Object.keys(channels).length === 0) { console.warn('No channels provided or empty channels array'); notifications.show({ title: 'No channels available', color: 'red.5' }); - setLoading(false); return; } @@ -97,10 +305,19 @@ export default function TVChannelGuide({ startDate, endDate }) { console.log(`Using all ${sortedChannels.length} available channels`); + const processedPrograms = fetched.map((program) => { + const start = dayjs(program.start_time); + const end = dayjs(program.end_time); + return { + ...program, + startMs: start.valueOf(), + endMs: end.valueOf(), + }; + }); + setGuideChannels(sortedChannels); setFilteredChannels(sortedChannels); // Initialize filtered channels - setPrograms(fetched); - setLoading(false); + setPrograms(processedPrograms); }; fetchPrograms(); @@ -180,38 +397,79 @@ export default function TVChannelGuide({ startDate, endDate }) { ? latestProgramEnd : defaultEnd; - // Time increments in 15-min steps (for placing programs) - const programTimeline = useMemo(() => { - const times = []; - let current = start; - while (current.isBefore(end)) { - times.push(current); - current = current.add(MINUTE_INCREMENT, 'minute'); - } - return times; - }, [start, end]); + const channelIdByTvgId = useMemo( + () => buildChannelIdMap(guideChannels, tvgsById), + [guideChannels, tvgsById] + ); + + const channelById = useMemo(() => { + const map = new Map(); + guideChannels.forEach((channel) => { + map.set(channel.id, channel); + }); + return map; + }, [guideChannels]); + + const programsByChannelId = useMemo( + () => mapProgramsByChannel(programs, channelIdByTvgId), + [programs, channelIdByTvgId] + ); + + const recordingsByProgramId = useMemo(() => { + const map = new Map(); + (recordings || []).forEach((recording) => { + const programId = recording?.custom_properties?.program?.id; + if (programId != null) { + map.set(programId, recording); + } + }); + return map; + }, [recordings]); + + const rowHeights = useMemo( + () => + computeRowHeights( + filteredChannels, + programsByChannelId, + expandedProgramId + ), + [filteredChannels, programsByChannelId, expandedProgramId] + ); + + const getItemSize = useCallback( + (index) => rowHeights[index] ?? PROGRAM_HEIGHT, + [rowHeights] + ); + + const [timeFormatSetting] = useLocalStorage('time-format', '12h'); + const [dateFormatSetting] = useLocalStorage('date-format', 'mdy'); + // Use user preference for time format + const timeFormat = timeFormatSetting === '12h' ? 'h:mm A' : 'HH:mm'; + const dateFormat = dateFormatSetting === 'mdy' ? 'MMMM D' : 'D MMMM'; // Format day label using relative terms when possible (Today, Tomorrow, etc) - const formatDayLabel = (time) => { - const today = dayjs().startOf('day'); - const tomorrow = today.add(1, 'day'); - const dayAfterTomorrow = today.add(2, 'day'); - const weekLater = today.add(7, 'day'); + const formatDayLabel = useCallback( + (time) => { + const today = dayjs().startOf('day'); + const tomorrow = today.add(1, 'day'); + const weekLater = today.add(7, 'day'); - const day = time.startOf('day'); + const day = time.startOf('day'); - if (day.isSame(today, 'day')) { - return 'Today'; - } else if (day.isSame(tomorrow, 'day')) { - return 'Tomorrow'; - } else if (day.isBefore(weekLater)) { - // Within a week, show day name - return time.format('dddd'); - } else { - // Beyond a week, show month and day - return time.format(dateFormat); - } - }; + if (day.isSame(today, 'day')) { + return 'Today'; + } else if (day.isSame(tomorrow, 'day')) { + return 'Tomorrow'; + } else if (day.isBefore(weekLater)) { + // Within a week, show day name + return time.format('dddd'); + } else { + // Beyond a week, show month and day + return time.format(dateFormat); + } + }, + [dateFormat] + ); // Hourly marks with day labels const hourTimeline = useMemo(() => { @@ -238,34 +496,42 @@ export default function TVChannelGuide({ startDate, endDate }) { current = current.add(1, 'hour'); } return hours; - }, [start, end]); + }, [start, end, formatDayLabel]); - // Scroll to the nearest half-hour mark ONLY on initial load useEffect(() => { - if ( - guideRef.current && - timelineRef.current && - programs.length > 0 && - !initialScrollComplete - ) { - // Round the current time to the nearest half-hour mark - const roundedNow = - now.minute() < 30 - ? now.startOf('hour') - : now.startOf('hour').add(30, 'minute'); - const nowOffset = roundedNow.diff(start, 'minute'); - const scrollPosition = - (nowOffset / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH - - MINUTE_BLOCK_WIDTH; + const node = guideRef.current; + if (!node) return undefined; - const scrollPos = Math.max(scrollPosition, 0); - guideRef.current.scrollLeft = scrollPos; - timelineRef.current.scrollLeft = scrollPos; // Sync timeline scroll + const handleScroll = () => { + const { scrollLeft } = node; + if (scrollLeft === guideScrollLeftRef.current) { + return; + } - // Mark initial scroll as complete - setInitialScrollComplete(true); - } - }, [programs, start, now, initialScrollComplete]); + guideScrollLeftRef.current = scrollLeft; + setGuideScrollLeft(scrollLeft); + + if (isSyncingScroll.current) { + return; + } + + if ( + timelineRef.current && + timelineRef.current.scrollLeft !== scrollLeft + ) { + isSyncingScroll.current = true; + timelineRef.current.scrollLeft = scrollLeft; + requestAnimationFrame(() => { + isSyncingScroll.current = false; + }); + } + }; + + node.addEventListener('scroll', handleScroll, { passive: true }); + return () => { + node.removeEventListener('scroll', handleScroll); + }; + }, []); // Update “now” every second useEffect(() => { @@ -282,179 +548,39 @@ export default function TVChannelGuide({ startDate, endDate }) { return (minutesSinceStart / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH; }, [now, start, end]); - // Helper: find channel by tvg_id - function findChannelByTvgId(tvgId) { - return guideChannels.find( - (ch) => - tvgsById[ch.epg_data_id]?.tvg_id === tvgId || - (!ch.epg_data_id && ch.uuid === tvgId) - ); - } + const syncScrollLeft = useCallback((nextLeft, behavior = 'auto') => { + const guideNode = guideRef.current; + const timelineNode = timelineRef.current; - const openRecordChoice = async (program) => { - setRecordChoiceProgram(program); - setRecordChoiceOpen(true); - try { - const rules = await API.listSeriesRules(); - // Only treat as existing if the rule matches this specific show's title (or has no title constraint) - const rule = (rules || []).find( - (r) => String(r.tvg_id) === String(program.tvg_id) && (!r.title || r.title === program.title) - ); - setExistingRuleMode(rule ? rule.mode : null); - } catch {} - // Also detect if this program already has a scheduled recording - try { - const rec = (recordings || []).find((r) => r?.custom_properties?.program?.id == program.id); - setRecordingForProgram(rec || null); - } catch {} - }; + isSyncingScroll.current = true; - const recordOne = async (program) => { - const channel = findChannelByTvgId(program.tvg_id); - await API.createRecording({ - channel: `${channel.id}`, - start_time: program.start_time, - end_time: program.end_time, - custom_properties: { program }, - }); - notifications.show({ title: 'Recording scheduled' }); - }; - - const saveSeriesRule = async (program, mode) => { - await API.createSeriesRule({ tvg_id: program.tvg_id, mode, title: program.title }); - await API.evaluateSeriesRules(program.tvg_id); - // Refresh recordings so icons and DVR reflect new schedules - try { - await useChannelsStore.getState().fetchRecordings(); - } catch (e) { - console.warn('Failed to refresh recordings after saving series rule', e); - } - notifications.show({ title: mode === 'new' ? 'Record new episodes' : 'Record all episodes' }); - }; - - const openRules = async () => { - setRulesOpen(true); - try { - const r = await API.listSeriesRules(); - setRules(r); - } catch (e) { - // handled by API - } - }; - - const deleteAllUpcoming = async () => { - const ok = window.confirm('Delete ALL upcoming recordings?'); - if (!ok) return; - await API.deleteAllUpcomingRecordings(); - try { await useChannelsStore.getState().fetchRecordings(); } catch {} - }; - - // The “Watch Now” click => show floating video - const showVideo = useVideoStore((s) => s.showVideo); - function handleWatchStream(program) { - const matched = findChannelByTvgId(program.tvg_id); - if (!matched) { - console.warn(`No channel found for tvg_id=${program.tvg_id}`); - return; - } - // Build a playable stream URL for that channel - let vidUrl = `/proxy/ts/stream/${matched.uuid}`; - if (env_mode == 'dev') { - vidUrl = `${window.location.protocol}//${window.location.hostname}:5656${vidUrl}`; - } - - showVideo(vidUrl); - } - - // Function to handle logo click to play channel - function handleLogoClick(channel, event) { - // Prevent event from bubbling up - event.stopPropagation(); - - // Build a playable stream URL for the channel - let vidUrl = `/proxy/ts/stream/${channel.uuid}`; - if (env_mode === 'dev') { - vidUrl = `${window.location.protocol}//${window.location.hostname}:5656${vidUrl}`; - } - - // Use the existing showVideo function - showVideo(vidUrl); - } - - // On program click, toggle the expanded state - function handleProgramClick(program, event) { - // Prevent event from bubbling up to parent elements - event.stopPropagation(); - - // Get the program's start time and calculate its position - const programStart = dayjs(program.start_time); - const startOffsetMinutes = programStart.diff(start, 'minute'); - const leftPx = (startOffsetMinutes / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH; - - // Calculate desired scroll position (account for channel column width) - const desiredScrollPosition = Math.max(0, leftPx - 20); // 20px buffer - - // If already expanded, collapse it - if (expandedProgramId === program.id) { - setExpandedProgramId(null); - setRecordingForProgram(null); - return; - } - - // Otherwise expand this program - setExpandedProgramId(program.id); - - // Check if this program has a recording - const programRecording = recordings.find((recording) => { - if (recording.custom_properties) { - const customProps = recording.custom_properties || {}; - if (customProps.program && customProps.program.id == program.id) { - return true; - } - } - return false; - }); - - setRecordingForProgram(programRecording); - - // Scroll to show the start of the program if it's not already fully visible - if (guideRef.current && timelineRef.current) { - const currentScrollPosition = guideRef.current.scrollLeft; - - // Check if we need to scroll (if program start is before current view or too close to edge) - if ( - desiredScrollPosition < currentScrollPosition || - leftPx - currentScrollPosition < 100 - ) { - // 100px from left edge - - // Smooth scroll to the program's start - guideRef.current.scrollTo({ - left: desiredScrollPosition, - behavior: 'smooth', - }); - - // Also sync the timeline scroll - timelineRef.current.scrollTo({ - left: desiredScrollPosition, - behavior: 'smooth', - }); + if (guideNode) { + if (typeof guideNode.scrollTo === 'function') { + guideNode.scrollTo({ left: nextLeft, behavior }); + } else { + guideNode.scrollLeft = nextLeft; } } - } - // Close the expanded program when clicking elsewhere - const handleClickOutside = () => { - if (expandedProgramId) { - setExpandedProgramId(null); - setRecordingForProgram(null); + if (timelineNode) { + if (typeof timelineNode.scrollTo === 'function') { + timelineNode.scrollTo({ left: nextLeft, behavior }); + } else { + timelineNode.scrollLeft = nextLeft; + } } - }; - // Function to scroll to current time - matches initial loading position - const scrollToNow = () => { - if (guideRef.current && timelineRef.current && nowPosition >= 0) { - // Round the current time to the nearest half-hour mark + guideScrollLeftRef.current = nextLeft; + setGuideScrollLeft(nextLeft); + + requestAnimationFrame(() => { + isSyncingScroll.current = false; + }); + }, []); + + // Scroll to the nearest half-hour mark ONLY on initial load + useEffect(() => { + if (programs.length > 0 && !initialScrollComplete) { const roundedNow = now.minute() < 30 ? now.startOf('hour') @@ -465,61 +591,235 @@ export default function TVChannelGuide({ startDate, endDate }) { MINUTE_BLOCK_WIDTH; const scrollPos = Math.max(scrollPosition, 0); - guideRef.current.scrollLeft = scrollPos; - timelineRef.current.scrollLeft = scrollPos; // Sync timeline scroll + syncScrollLeft(scrollPos); + + setInitialScrollComplete(true); + } + }, [programs, start, now, initialScrollComplete, syncScrollLeft]); + + const findChannelByTvgId = useCallback( + (tvgId) => { + const channelId = channelIdByTvgId.get(String(tvgId)); + if (!channelId) { + return null; + } + return channelById.get(channelId) || null; + }, + [channelById, channelIdByTvgId] + ); + + const openRecordChoice = useCallback( + async (program) => { + setRecordChoiceProgram(program); + setRecordChoiceOpen(true); + try { + const rules = await API.listSeriesRules(); + const rule = (rules || []).find( + (r) => + String(r.tvg_id) === String(program.tvg_id) && + (!r.title || r.title === program.title) + ); + setExistingRuleMode(rule ? rule.mode : null); + } catch (error) { + console.warn('Failed to fetch series rules metadata', error); + } + + const existingRecording = recordingsByProgramId.get(program.id) || null; + setRecordingForProgram(existingRecording); + }, + [recordingsByProgramId] + ); + + const recordOne = useCallback( + async (program) => { + const channel = findChannelByTvgId(program.tvg_id); + if (!channel) { + notifications.show({ + title: 'Unable to schedule recording', + message: 'No channel found for this program.', + color: 'red.6', + }); + return; + } + + await API.createRecording({ + channel: `${channel.id}`, + start_time: program.start_time, + end_time: program.end_time, + custom_properties: { program }, + }); + notifications.show({ title: 'Recording scheduled' }); + }, + [findChannelByTvgId] + ); + + const saveSeriesRule = useCallback(async (program, mode) => { + await API.createSeriesRule({ + tvg_id: program.tvg_id, + mode, + title: program.title, + }); + await API.evaluateSeriesRules(program.tvg_id); + try { + await useChannelsStore.getState().fetchRecordings(); + } catch (error) { + console.warn( + 'Failed to refresh recordings after saving series rule', + error + ); + } + notifications.show({ + title: mode === 'new' ? 'Record new episodes' : 'Record all episodes', + }); + }, []); + + const openRules = useCallback(async () => { + setRulesOpen(true); + try { + const r = await API.listSeriesRules(); + setRules(r); + } catch (error) { + console.warn('Failed to load series rules', error); + } + }, []); + + // The “Watch Now” click => show floating video + const showVideo = useVideoStore((s) => s.showVideo); + const handleWatchStream = useCallback( + (program) => { + const matched = findChannelByTvgId(program.tvg_id); + if (!matched) { + console.warn(`No channel found for tvg_id=${program.tvg_id}`); + return; + } + + let vidUrl = `/proxy/ts/stream/${matched.uuid}`; + if (env_mode === 'dev') { + vidUrl = `${window.location.protocol}//${window.location.hostname}:5656${vidUrl}`; + } + + showVideo(vidUrl); + }, + [env_mode, findChannelByTvgId, showVideo] + ); + + const handleLogoClick = useCallback( + (channel, event) => { + event.stopPropagation(); + + let vidUrl = `/proxy/ts/stream/${channel.uuid}`; + if (env_mode === 'dev') { + vidUrl = `${window.location.protocol}//${window.location.hostname}:5656${vidUrl}`; + } + + showVideo(vidUrl); + }, + [env_mode, showVideo] + ); + + const handleProgramClick = useCallback( + (program, event) => { + event.stopPropagation(); + + const programStartMs = + program.startMs ?? dayjs(program.start_time).valueOf(); + const startOffsetMinutes = (programStartMs - start.valueOf()) / 60000; + const leftPx = + (startOffsetMinutes / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH; + const desiredScrollPosition = Math.max(0, leftPx - 20); + + if (expandedProgramId === program.id) { + setExpandedProgramId(null); + setRecordingForProgram(null); + } else { + setExpandedProgramId(program.id); + setRecordingForProgram(recordingsByProgramId.get(program.id) || null); + } + + const guideNode = guideRef.current; + if (guideNode) { + const currentScrollPosition = guideNode.scrollLeft; + if ( + desiredScrollPosition < currentScrollPosition || + leftPx - currentScrollPosition < 100 + ) { + syncScrollLeft(desiredScrollPosition, 'smooth'); + } + } + }, + [expandedProgramId, recordingsByProgramId, start, syncScrollLeft] + ); + + // Close the expanded program when clicking elsewhere + const handleClickOutside = () => { + if (expandedProgramId) { + setExpandedProgramId(null); + setRecordingForProgram(null); } }; - // Sync scrolling between timeline and main content - const handleTimelineScroll = () => { - if (timelineRef.current && guideRef.current) { - guideRef.current.scrollLeft = timelineRef.current.scrollLeft; + const scrollToNow = useCallback(() => { + if (nowPosition < 0) { + return; } - }; - // Sync scrolling between main content and timeline - const handleGuideScroll = () => { - if (guideRef.current && timelineRef.current) { - timelineRef.current.scrollLeft = guideRef.current.scrollLeft; + const roundedNow = + now.minute() < 30 + ? now.startOf('hour') + : now.startOf('hour').add(30, 'minute'); + const nowOffset = roundedNow.diff(start, 'minute'); + const scrollPosition = + (nowOffset / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH - MINUTE_BLOCK_WIDTH; + + const scrollPos = Math.max(scrollPosition, 0); + syncScrollLeft(scrollPos, 'smooth'); + }, [now, nowPosition, start, syncScrollLeft]); + + const handleTimelineScroll = useCallback(() => { + if (!timelineRef.current) { + return; } - }; - // Handle wheel events on the timeline for horizontal scrolling - const handleTimelineWheel = (e) => { - if (timelineRef.current) { - // Prevent the default vertical scroll - e.preventDefault(); + const nextLeft = timelineRef.current.scrollLeft; + guideScrollLeftRef.current = nextLeft; + setGuideScrollLeft(nextLeft); - // Determine scroll amount (with shift key for faster scrolling) - const scrollAmount = e.shiftKey ? 250 : 125; + if (isSyncingScroll.current) { + return; + } - // Scroll horizontally based on wheel direction - timelineRef.current.scrollLeft += - e.deltaY > 0 ? scrollAmount : -scrollAmount; - - // Sync the main content scroll position - if (guideRef.current) { - guideRef.current.scrollLeft = timelineRef.current.scrollLeft; + isSyncingScroll.current = true; + if (guideRef.current) { + if (typeof guideRef.current.scrollTo === 'function') { + guideRef.current.scrollTo({ left: nextLeft }); + } else { + guideRef.current.scrollLeft = nextLeft; } } - }; - // Function to handle timeline time clicks with 15-minute snapping - const handleTimeClick = (clickedTime, event) => { - if (timelineRef.current && guideRef.current) { - // Calculate where in the hour block the click happened - const hourBlockElement = event.currentTarget; - const rect = hourBlockElement.getBoundingClientRect(); - const clickPositionX = event.clientX - rect.left; // Position within the hour block - const percentageAcross = clickPositionX / rect.width; // 0 to 1 value + requestAnimationFrame(() => { + isSyncingScroll.current = false; + }); + }, []); - // Calculate the minute within the hour based on click position + const handleTimelineWheel = useCallback((event) => { + if (!timelineRef.current) { + return; + } + + event.preventDefault(); + const scrollAmount = event.shiftKey ? 250 : 125; + const delta = event.deltaY > 0 ? scrollAmount : -scrollAmount; + timelineRef.current.scrollBy({ left: delta, behavior: 'smooth' }); + }, []); + + const handleTimeClick = useCallback( + (clickedTime, event) => { + const rect = event.currentTarget.getBoundingClientRect(); + const clickPositionX = event.clientX - rect.left; + const percentageAcross = clickPositionX / rect.width; const minuteWithinHour = Math.floor(percentageAcross * 60); - // Create a new time object with the calculated minute - const exactTime = clickedTime.minute(minuteWithinHour); - - // Determine the nearest 15-minute interval (0, 15, 30, 45) let snappedMinute; if (minuteWithinHour < 7.5) { snappedMinute = 0; @@ -530,176 +830,106 @@ export default function TVChannelGuide({ startDate, endDate }) { } else if (minuteWithinHour < 52.5) { snappedMinute = 45; } else { - // If we're past 52.5 minutes, snap to the next hour snappedMinute = 0; clickedTime = clickedTime.add(1, 'hour'); } - // Create the snapped time const snappedTime = clickedTime.minute(snappedMinute); - - // Calculate the offset from the start of the timeline to the snapped time const snappedOffset = snappedTime.diff(start, 'minute'); - - // Convert to pixels const scrollPosition = (snappedOffset / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH; - // Scroll both containers to the snapped position - timelineRef.current.scrollLeft = scrollPosition; - guideRef.current.scrollLeft = scrollPosition; - } - }; - // Renders each program block - function renderProgram(program, channelStart) { - const programKey = `${program.tvg_id}-${program.start_time}`; - const programStart = dayjs(program.start_time); - const programEnd = dayjs(program.end_time); - const startOffsetMinutes = programStart.diff(channelStart, 'minute'); - const durationMinutes = programEnd.diff(programStart, 'minute'); - const leftPx = (startOffsetMinutes / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH; + syncScrollLeft(scrollPosition, 'smooth'); + }, + [start, syncScrollLeft] + ); + const renderProgram = useCallback( + (program, channelStart = start) => { + const programStartMs = + program.startMs ?? dayjs(program.start_time).valueOf(); + const programEndMs = program.endMs ?? dayjs(program.end_time).valueOf(); + const programStart = dayjs(programStartMs); + const programEnd = dayjs(programEndMs); - // Calculate width with a small gap (2px on each side) - const gapSize = 2; - const widthPx = - (durationMinutes / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH - gapSize * 2; + const startOffsetMinutes = + (programStartMs - channelStart.valueOf()) / 60000; + const durationMinutes = (programEndMs - programStartMs) / 60000; + const leftPx = + (startOffsetMinutes / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH; - // Check if we have a recording for this program - const recording = recordings.find((recording) => { - if (recording.custom_properties) { - const customProps = recording.custom_properties || {}; - if (customProps.program && customProps.program.id == program.id) { - return recording; - } + const gapSize = 2; + const widthPx = + (durationMinutes / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH - gapSize * 2; + + const recording = recordingsByProgramId.get(program.id); + + const isLive = now.isAfter(programStart) && now.isBefore(programEnd); + const isPast = now.isAfter(programEnd); + const isExpanded = expandedProgramId === program.id; + + const rowHeight = isExpanded ? EXPANDED_PROGRAM_HEIGHT : PROGRAM_HEIGHT; + const MIN_EXPANDED_WIDTH = 450; + const expandedWidthPx = Math.max(widthPx, MIN_EXPANDED_WIDTH); + + const programStartInView = leftPx + gapSize; + const programEndInView = leftPx + gapSize + widthPx; + const viewportLeft = guideScrollLeft; + const startsBeforeView = programStartInView < viewportLeft; + const extendsIntoView = programEndInView > viewportLeft; + + let textOffsetLeft = 0; + if (startsBeforeView && extendsIntoView) { + const visibleStart = Math.max(viewportLeft - programStartInView, 0); + const maxOffset = widthPx - 200; + textOffsetLeft = Math.min(visibleStart, maxOffset); } - return null; - }); - // Highlight if currently live - const isLive = now.isAfter(programStart) && now.isBefore(programEnd); - - // Determine if the program has ended - const isPast = now.isAfter(programEnd); // Check if this program is expanded - const isExpanded = expandedProgramId === program.id; - - // Set the height based on expanded state - const rowHeight = isExpanded ? EXPANDED_PROGRAM_HEIGHT : PROGRAM_HEIGHT; - - // Determine expanded width - if program is short, ensure it has a minimum expanded width - // This will allow it to overlap programs to the right - const MIN_EXPANDED_WIDTH = 450; // Minimum width in pixels when expanded - const expandedWidthPx = Math.max(widthPx, MIN_EXPANDED_WIDTH); - - // Calculate text positioning for long programs that start before the visible area - const currentScrollLeft = guideRef.current?.scrollLeft || 0; - const programStartInView = leftPx + gapSize; - const programEndInView = leftPx + gapSize + widthPx; - const viewportLeft = currentScrollLeft; - - // Check if program starts before viewport but extends into it - const startsBeforeView = programStartInView < viewportLeft; - const extendsIntoView = programEndInView > viewportLeft; - - // Calculate text offset to position it at the visible portion - let textOffsetLeft = 0; - if (startsBeforeView && extendsIntoView) { - // Position text at the start of the visible area, but not beyond the program end - const visibleStart = Math.max(viewportLeft - programStartInView, 0); - const maxOffset = widthPx - 200; // Leave some space for text, don't push to very end - textOffsetLeft = Math.min(visibleStart, maxOffset); - } - - return ( - handleProgramClick(program, e)} - > - handleProgramClick(program, event)} > - - - - {recording && ( -
- )} - {program.title} -
-
- - {programStart.format(timeFormat)} -{' '} - {programEnd.format(timeFormat)} - -
{' '} - {/* Description is always shown but expands when row is expanded */} - {program.description && ( - {program.description} + + {recording && ( +
+ )} + {program.title} +
+
+ + {programStart.format(timeFormat)} -{' '} + {programEnd.format(timeFormat)}
- )} - {/* Expanded content */} - {isExpanded && ( - - - {/* Always show Record for not-past; it opens options (schedule/remove) */} - {!isPast && ( - - )} - {isLive && ( - - )} - - - )} -
-
- ); - } + {program.description && ( + + + {program.description} + + + )} + + {isExpanded && ( + + + {!isPast && ( + + )} + + {isLive && ( + + )} + + + )} + + + ); + }, + [ + expandedProgramId, + guideScrollLeft, + handleProgramClick, + handleWatchStream, + now, + openRecordChoice, + recordingsByProgramId, + start, + timeFormat, + ] + ); + + const contentWidth = useMemo( + () => hourTimeline.length * HOUR_WIDTH + CHANNEL_WIDTH, + [hourTimeline] + ); + + const virtualizedHeight = useMemo(() => guideHeight || 600, [guideHeight]); + + const virtualizedWidth = useMemo(() => { + if (guideWidth) { + return guideWidth; + } + if (typeof window !== 'undefined') { + return Math.min(window.innerWidth, contentWidth); + } + return contentWidth; + }, [guideWidth, contentWidth]); + + const itemKey = useCallback( + (index) => filteredChannels[index]?.id ?? index, + [filteredChannels] + ); + + const listData = useMemo( + () => ({ + filteredChannels, + programsByChannelId, + expandedProgramId, + rowHeights, + logos, + hoveredChannelId, + setHoveredChannelId, + renderProgram, + handleLogoClick, + contentWidth, + }), + [ + filteredChannels, + programsByChannelId, + expandedProgramId, + rowHeights, + logos, + hoveredChannelId, + renderProgram, + handleLogoClick, + contentWidth, + setHoveredChannelId, + ] + ); + + useEffect(() => { + if (listRef.current) { + listRef.current.resetAfterIndex(0, true); + } + }, [rowHeights]); + + useEffect(() => { + if (listRef.current) { + listRef.current.scrollToItem(0); + } + }, [searchQuery, selectedGroupId, selectedProfileId]); // Create group options for dropdown - but only include groups used by guide channels const groupOptions = useMemo(() => { @@ -825,12 +1172,6 @@ export default function TVChannelGuide({ startDate, endDate }) { setSelectedProfileId(value || 'all'); }; - // Handle date-time formats - const [timeFormatSetting] = useLocalStorage('time-format', '12h'); - const [dateFormatSetting] = useLocalStorage('date-format', 'mdy'); - const timeFormat = timeFormatSetting === '12h' ? 'h:mm A' : 'HH:mm'; - const dateFormat = dateFormatSetting === 'mdy' ? 'MMMM D' : 'D MMMM'; - return ( - {/* Content wrapper with min-width to ensure scroll range */} - - {/* Now line - positioned absolutely within content */} - {nowPosition >= 0 && ( - - )} + {nowPosition >= 0 && ( + + )} - {/* Channel rows with logos and programs */} - {filteredChannels.length > 0 ? ( - filteredChannels.map((channel) => { - const channelPrograms = programs.filter( - (p) => - (channel.epg_data_id && - p.tvg_id === tvgsById[channel.epg_data_id].tvg_id) || - (!channel.epg_data_id && p.tvg_id === channel.uuid) - ); - // Check if any program in this channel is expanded - const hasExpandedProgram = channelPrograms.some( - (prog) => prog.id === expandedProgramId - ); - const rowHeight = hasExpandedProgram - ? EXPANDED_PROGRAM_HEIGHT - : PROGRAM_HEIGHT; - - return ( - - {/* Channel logo - sticky horizontally */} - handleLogoClick(channel, e)} - onMouseEnter={() => setHoveredChannelId(channel.id)} - onMouseLeave={() => setHoveredChannelId(null)} - > - {/* Play icon overlay - visible on hover (moved outside to cover entire box) */} - {hoveredChannelId === channel.id && ( - - {' '} - {/* Changed from Video to Play and increased size */} - - )} - - {/* Logo content - restructured for better positioning */} - - {/* Logo container with padding */} - - {channel.name} - - - {/* Channel number - fixed position at bottom with consistent height */} - - {channel.channel_number || '-'} - - - - - {/* Programs for this channel */} - - {channelPrograms.length > 0 ? ( - channelPrograms.map((program) => ( -
- {renderProgram(program, start)} -
- )) - ) : ( - // Simple placeholder for channels with no program data - 2 hour blocks - <> - {/* Generate repeating placeholder blocks every 2 hours across the timeline */} - {Array.from({ - length: Math.ceil(hourTimeline.length / 2), - }).map((_, index) => ( - - - - No Program Information Available - - - - ))} - - )} -
-
- ); - }) - ) : ( - - No channels match your filters - - - )} -
+ {filteredChannels.length > 0 ? ( + + {GuideRow} + + ) : ( + + No channels match your filters + + + )}
- {/* Record choice modal */} {recordChoiceOpen && recordChoiceProgram && ( - - - + + + {recordingForProgram && ( <> - - + + )} {existingRuleMode && ( - + )} @@ -1427,27 +1640,78 @@ export default function TVChannelGuide({ startDate, endDate }) { > {(!rules || rules.length === 0) && ( - No series rules configured + + No series rules configured + )} - {rules && rules.map((r) => ( - - {r.title || r.tvg_id} — {r.mode === 'new' ? 'New episodes' : 'Every episode'} - - - - - - ))} + {rules && + rules.map((r) => ( + + + {r.title || r.tvg_id} —{' '} + {r.mode === 'new' ? 'New episodes' : 'Every episode'} + + + + + + + ))} )} diff --git a/frontend/src/pages/__tests__/guideUtils.test.js b/frontend/src/pages/__tests__/guideUtils.test.js new file mode 100644 index 00000000..58a6d292 --- /dev/null +++ b/frontend/src/pages/__tests__/guideUtils.test.js @@ -0,0 +1,100 @@ +import { describe, it, expect } from 'vitest'; +import dayjs from 'dayjs'; +import { + PROGRAM_HEIGHT, + EXPANDED_PROGRAM_HEIGHT, + buildChannelIdMap, + mapProgramsByChannel, + computeRowHeights, +} from '../guideUtils.js'; + +describe('guideUtils', () => { + describe('buildChannelIdMap', () => { + it('maps tvg ids from epg records and falls back to channel uuid', () => { + const channels = [ + { id: 1, epg_data_id: 'epg-1', uuid: 'uuid-1' }, + { id: 2, epg_data_id: null, uuid: 'uuid-2' }, + ]; + const tvgsById = { + 'epg-1': { tvg_id: 'alpha' }, + }; + + const map = buildChannelIdMap(channels, tvgsById); + + expect(map.get('alpha')).toBe(1); + expect(map.get('uuid-2')).toBe(2); + }); + }); + + describe('mapProgramsByChannel', () => { + it('groups programs by channel and sorts them by start time', () => { + const programs = [ + { + id: 10, + tvg_id: 'alpha', + start_time: dayjs('2025-01-01T02:00:00Z').toISOString(), + end_time: dayjs('2025-01-01T03:00:00Z').toISOString(), + title: 'Late Show', + }, + { + id: 11, + tvg_id: 'alpha', + start_time: dayjs('2025-01-01T01:00:00Z').toISOString(), + end_time: dayjs('2025-01-01T02:00:00Z').toISOString(), + title: 'Evening News', + }, + { + id: 20, + tvg_id: 'beta', + start_time: dayjs('2025-01-01T00:00:00Z').toISOString(), + end_time: dayjs('2025-01-01T01:00:00Z').toISOString(), + title: 'Morning Show', + }, + ]; + + const channelIdByTvgId = new Map([ + ['alpha', 1], + ['beta', 2], + ]); + + const map = mapProgramsByChannel(programs, channelIdByTvgId); + + expect(map.get(1)).toHaveLength(2); + expect(map.get(1)?.map((item) => item.id)).toEqual([11, 10]); + expect(map.get(2)).toHaveLength(1); + expect(map.get(2)?.[0].startMs).toBeTypeOf('number'); + expect(map.get(2)?.[0].endMs).toBeTypeOf('number'); + }); + }); + + describe('computeRowHeights', () => { + it('returns program heights with expanded rows when needed', () => { + const filteredChannels = [ + { id: 1 }, + { id: 2 }, + ]; + + const programsByChannel = new Map([ + [1, [{ id: 10 }, { id: 11 }]], + [2, [{ id: 20 }]], + ]); + + const collapsed = computeRowHeights( + filteredChannels, + programsByChannel, + null + ); + expect(collapsed).toEqual([PROGRAM_HEIGHT, PROGRAM_HEIGHT]); + + const expanded = computeRowHeights( + filteredChannels, + programsByChannel, + 10 + ); + expect(expanded).toEqual([ + EXPANDED_PROGRAM_HEIGHT, + PROGRAM_HEIGHT, + ]); + }); + }); +}); diff --git a/frontend/src/pages/guideUtils.js b/frontend/src/pages/guideUtils.js new file mode 100644 index 00000000..9e5bcbc2 --- /dev/null +++ b/frontend/src/pages/guideUtils.js @@ -0,0 +1,71 @@ +import dayjs from 'dayjs'; + +export const PROGRAM_HEIGHT = 90; +export const EXPANDED_PROGRAM_HEIGHT = 180; + +export function buildChannelIdMap(channels, tvgsById) { + const map = new Map(); + channels.forEach((channel) => { + const tvgRecord = channel.epg_data_id + ? tvgsById[channel.epg_data_id] + : null; + const tvgId = tvgRecord?.tvg_id ?? channel.uuid; + if (tvgId) { + map.set(String(tvgId), channel.id); + } + }); + return map; +} + +export function mapProgramsByChannel(programs, channelIdByTvgId) { + if (!programs?.length || !channelIdByTvgId?.size) { + return new Map(); + } + + const map = new Map(); + programs.forEach((program) => { + const channelId = channelIdByTvgId.get(String(program.tvg_id)); + if (!channelId) { + return; + } + + if (!map.has(channelId)) { + map.set(channelId, []); + } + + const startMs = program.startMs ?? dayjs(program.start_time).valueOf(); + const endMs = program.endMs ?? dayjs(program.end_time).valueOf(); + + map.get(channelId).push({ + ...program, + startMs, + endMs, + }); + }); + + map.forEach((list) => { + list.sort((a, b) => a.startMs - b.startMs); + }); + + return map; +} + +export function computeRowHeights( + filteredChannels, + programsByChannelId, + expandedProgramId, + defaultHeight = PROGRAM_HEIGHT, + expandedHeight = EXPANDED_PROGRAM_HEIGHT +) { + if (!filteredChannels?.length) { + return []; + } + + return filteredChannels.map((channel) => { + const channelPrograms = programsByChannelId.get(channel.id) || []; + const expanded = channelPrograms.some( + (program) => program.id === expandedProgramId + ); + return expanded ? expandedHeight : defaultHeight; + }); +} diff --git a/frontend/src/test/setupTests.js b/frontend/src/test/setupTests.js new file mode 100644 index 00000000..b5f53af0 --- /dev/null +++ b/frontend/src/test/setupTests.js @@ -0,0 +1,42 @@ +import '@testing-library/jest-dom/vitest'; +import { afterEach, vi } from 'vitest'; +import { cleanup } from '@testing-library/react'; + +afterEach(() => { + cleanup(); +}); + +if (typeof window !== 'undefined' && !window.matchMedia) { + window.matchMedia = vi.fn().mockImplementation((query) => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })); +} + +if (typeof window !== 'undefined' && !window.ResizeObserver) { + class ResizeObserver { + constructor(callback) { + this.callback = callback; + } + observe() {} + unobserve() {} + disconnect() {} + } + + window.ResizeObserver = ResizeObserver; +} + +if (typeof window !== 'undefined') { + if (!window.requestAnimationFrame) { + window.requestAnimationFrame = (cb) => setTimeout(cb, 16); + } + if (!window.cancelAnimationFrame) { + window.cancelAnimationFrame = (id) => clearTimeout(id); + } +} diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 9ce8189b..1026e519 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -26,4 +26,10 @@ export default defineConfig({ // }, // }, }, + + test: { + environment: 'jsdom', + setupFiles: ['./src/test/setupTests.js'], + globals: true, + }, });