diff --git a/README.md b/README.md index 14f198b..c5ad221 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,8 @@ To Be Played is split into three repositories: - Vitest + React Testing Library + jsdom - Production image: nginx (Alpine), SPA fallback, long-term caching for hashed assets, Content Security Policy and security headers -- CI: Forgejo Actions (`npm ci`, `npm test`, `npm run build`, Docker image) +- ESLint with the React hooks rules (`npm run lint`) +- CI: Forgejo Actions (`npm ci`, `npm run lint`, `npm test`, `npm run build`, Docker image) ## Architecture @@ -127,8 +128,14 @@ flowchart LR - Calls for the logged-in user go through `src/utils/api.js`. `clientFetch` sends the HttpOnly cookies and the `X-TBP-Client` header. `apiFetch` answers a `401` with one `POST /api/auth/refresh` and sends the request again. Parallel requests and other tabs share this refresh (`navigator.locks`). When the second answer is still a - `401`, the page calls `useSessionExpired`, which opens the login page. Public pages (landing page, public - profile) and the public `GET /api/version` in the settings use plain `fetch`. + `401`, `apiFetch` fires `expired`; the pages only stop. `request()` wraps `apiFetch` as `{ok, status, data}` + and never throws. Public pages (landing page, public profile) and the public `GET /api/version` in the settings + use plain `fetch`. +- The pages that need a login sit under the `RequireSession` layout route in `App.jsx`. Without a session it opens + the login page, which comes back to the same page; on `expired` it ends the session (`useSessionExpired`). A + `401` on a public page ends nothing. +- `CurrentUserProvider` holds `GET /api/users/me` for the whole app (`useCurrentUserContext`, `useCurrentUser`). + Dashboard and settings load it again when they open. - `src/utils/brokenImages.js` reports pictures that fail to load (`GameCover`, achievement icons on the game detail page) to Core. - `useLibrarySync` polls Core's `/api/library/sync-status` while a Steam sync runs, then loads the data again. @@ -191,6 +198,7 @@ browser bundle, so they must never hold a secret. ## Tests and build ```bash +npm run lint npm test npm run build ``` diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..c9370d4 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,28 @@ +import js from '@eslint/js'; +import globals from 'globals'; +import reactHooks from 'eslint-plugin-react-hooks'; + +export default [ + {ignores: ['dist']}, + js.configs.recommended, + { + files: ['**/*.{js,jsx}'], + languageOptions: { + parserOptions: {ecmaFeatures: {jsx: true}}, + globals: { + ...globals.browser, + // set by `define` in vite.config.js + __TRANSLATIONS_VERSION__: 'readonly', + __BUILD_COMMIT__: 'readonly', + __BUILD_TIME__: 'readonly', + }, + }, + plugins: {'react-hooks': reactHooks}, + // Only the two classic rules: the React Compiler rules of the plugin's recommended set are a later step. + rules: { + 'react-hooks/rules-of-hooks': 'error', + 'react-hooks/exhaustive-deps': 'error', + }, + }, + {files: ['*.config.js'], languageOptions: {globals: globals.node}}, +]; diff --git a/package-lock.json b/package-lock.json index 2efd8fd..fbfd76a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,10 +27,14 @@ "zod": "^3.25.76" }, "devDependencies": { + "@eslint/js": "^10.0.1", "@testing-library/dom": "^10.4.2", "@testing-library/react": "^16.3.3", "@vitejs/plugin-react": "^6.1.1", "autoprefixer": "~10.6.1", + "eslint": "^10.11.0", + "eslint-plugin-react-hooks": "^7.1.1", + "globals": "^17.12.0", "jsdom": "^30.1.1", "postcss": "~8.5.28", "tailwindcss": "~4.3.3", @@ -87,6 +91,143 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-validator-identifier": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", @@ -97,6 +238,46 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@babel/runtime": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", @@ -106,6 +287,54 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@bramus/specificity": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", @@ -119,6 +348,30 @@ "specificity": "bin/cli.js" } }, + "node_modules/@cacheable/memory": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz", + "integrity": "sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/utils": "^2.5.0", + "@keyv/bigmap": "^1.3.1", + "hookified": "^1.15.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@cacheable/utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.5.0.tgz", + "integrity": "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.5.1", + "keyv": "^5.6.0" + } + }, "node_modules/@csstools/color-helpers": { "version": "6.1.1", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.1.tgz", @@ -290,6 +543,134 @@ "tslib": "^2.4.0" } }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.3.tgz", + "integrity": "sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, "node_modules/@exodus/bytes": { "version": "1.16.0", "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.16.0.tgz", @@ -364,6 +745,72 @@ "react-hook-form": "^7.0.0" } }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -409,6 +856,30 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@keyv/bigmap": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz", + "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.4.0", + "hookified": "^1.15.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "keyv": "^5.6.0" + } + }, + "node_modules/@keyv/serialize": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", + "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", + "dev": true, + "license": "MIT" + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", @@ -2518,6 +2989,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -2525,6 +3003,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, "node_modules/@vitejs/plugin-react": { "version": "6.1.1", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz", @@ -2603,6 +3088,46 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -2695,6 +3220,16 @@ "postcss": "^8.1.0" } }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.11.25", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.25.tgz", @@ -2718,6 +3253,19 @@ "require-from-string": "^2.0.2" } }, + "node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, "node_modules/browserslist": { "version": "4.29.0", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.0.tgz", @@ -2752,6 +3300,20 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/cacheable": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.5.0.tgz", + "integrity": "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/memory": "^2.2.0", + "@cacheable/utils": "^2.5.0", + "hookified": "^1.15.0", + "keyv": "^5.6.0", + "qified": "^0.10.1" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001810", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", @@ -2804,6 +3366,13 @@ "node": ">=6" } }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/cookie": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", @@ -2826,6 +3395,21 @@ "node-fetch": "^2.7.0" } }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/css-tree": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", @@ -2892,6 +3476,24 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, "node_modules/decimal.js": { "version": "10.6.0", "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", @@ -2899,6 +3501,13 @@ "dev": true, "license": "MIT" }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -2981,6 +3590,184 @@ "node": ">=6" } }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.11.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.11.0.tgz", + "integrity": "sha512-P7a6UEEqb9G95MYAtqkmsTbVXIYyzIfl6NGOIJk162PaahFxFyeGcrlXYFSiagECg4sEm8IseJdZBKR3rx6MsQ==", + "dev": true, + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.7.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.3", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "11.1.5 || >11.1.6 <12", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", + "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, "node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", @@ -2991,6 +3778,16 @@ "@types/estree": "^1.0.0" } }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/expect-type": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", @@ -3001,6 +3798,27 @@ "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", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -3018,6 +3836,52 @@ } } }, + "node_modules/file-entry-cache": { + "version": "11.1.5", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.5.tgz", + "integrity": "sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^6.1.23" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "6.1.23", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.23.tgz", + "integrity": "sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cacheable": "^2.5.0", + "flatted": "^3.4.2", + "hookified": "^1.15.0" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, "node_modules/fraction.js": { "version": "5.3.4", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", @@ -3046,6 +3910,16 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/get-nonce": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", @@ -3055,12 +3929,75 @@ "node": ">=6" } }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "17.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.12.0.tgz", + "integrity": "sha512-cezEd/DTyyht9cvSSURyygXPfy04GtWO/5e6ZPvH7fCtjKz9PYOmuawphw1Ctd1f6C+5JypXfGD7ahNMXvevBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, + "node_modules/hashery": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz", + "integrity": "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^1.15.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/hookified": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz", + "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==", + "dev": true, + "license": "MIT" + }, "node_modules/html-encoding-sniffer": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-7.0.0.tgz", @@ -3120,6 +4057,49 @@ "cross-fetch": "4.1.0" } }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "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", @@ -3127,6 +4107,13 @@ "dev": true, "license": "MIT" }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, "node_modules/jiti": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", @@ -3221,6 +4208,70 @@ "node": "^22.14.0 || >=24.0.0" } }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@keyv/serialize": "^1.1.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -3470,6 +4521,22 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/lru-cache": { "version": "11.5.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", @@ -3515,6 +4582,29 @@ "dev": true, "license": "CC0-1.0" }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, "node_modules/nanoid": { "version": "3.3.19", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", @@ -3533,6 +4623,13 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, "node_modules/node-fetch": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", @@ -3577,6 +4674,56 @@ "node": ">=12.20.0" } }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/parse5": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", @@ -3590,6 +4737,26 @@ "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", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -3643,6 +4810,16 @@ "dev": true, "license": "MIT" }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/pretty-format": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", @@ -3668,6 +4845,26 @@ "node": ">=6" } }, + "node_modules/qified": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/qified/-/qified-0.10.1.tgz", + "integrity": "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^2.1.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/qified/node_modules/hookified": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-2.2.0.tgz", + "integrity": "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==", + "dev": true, + "license": "MIT" + }, "node_modules/radix-ui": { "version": "1.6.7", "resolved": "https://registry.npmjs.org/radix-ui/-/radix-ui-1.6.7.tgz", @@ -3985,12 +5182,45 @@ "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", "license": "MIT" }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/set-cookie-parser": { "version": "2.7.2", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", "license": "MIT" }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -4157,6 +5387,19 @@ "url": "https://github.com/sponsors/Wombosvideo" } }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/undici": { "version": "8.11.2", "resolved": "https://registry.npmjs.org/undici/-/undici-8.11.2.tgz", @@ -4198,6 +5441,16 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, "node_modules/use-callback-ref": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", @@ -4708,6 +5961,22 @@ "webidl-conversions": "^3.0.0" } }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "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", @@ -4725,6 +5994,16 @@ "node": ">=8" } }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/xml-name-validator": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", @@ -4742,6 +6021,26 @@ "dev": true, "license": "MIT" }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/zod": { "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", @@ -4750,6 +6049,19 @@ "funding": { "url": "https://github.com/sponsors/colinhacks" } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } } } } diff --git a/package.json b/package.json index 8fe1680..5d4cf3e 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "dev": "vite", "build": "vite build", "preview": "vite preview", - "test": "vitest run" + "test": "vitest run", + "lint": "eslint ." }, "dependencies": { "@fontsource-variable/inter": "^5.3.0", @@ -29,10 +30,14 @@ "zod": "^3.25.76" }, "devDependencies": { + "@eslint/js": "^10.0.1", "@testing-library/dom": "^10.4.2", "@testing-library/react": "^16.3.3", "@vitejs/plugin-react": "^6.1.1", "autoprefixer": "~10.6.1", + "eslint": "^10.11.0", + "eslint-plugin-react-hooks": "^7.1.1", + "globals": "^17.12.0", "jsdom": "^30.1.1", "postcss": "~8.5.28", "tailwindcss": "~4.3.3", diff --git a/src/App.jsx b/src/App.jsx index 5b48410..03f25ad 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -18,10 +18,12 @@ import StatsPage from './pages/StatsPage.jsx'; import ImportPage from './pages/ImportPage.jsx'; import PublicProfilePage from './pages/PublicProfilePage.jsx'; import AppHeader from './components/AppHeader.jsx'; +import RequireSession from './components/RequireSession.jsx'; import {Toaster} from '@/components/ui/sonner'; import {TooltipProvider} from '@/components/ui/tooltip'; import PageBackground from './components/PageBackground.jsx'; import {usePreferences} from './context/PreferencesContext.jsx'; +import {useCurrentUserActions} from './context/CurrentUserContext.jsx'; import {clearSession, hasSession} from './utils/auth.js'; import {logout} from './utils/api.js'; @@ -29,6 +31,7 @@ function App() { const {t} = useTranslation(); const [isAuthenticated, setIsAuthenticated] = useState(false); const {appearance, syncPreferences, resetAppearance} = usePreferences(); + const {setUser} = useCurrentUserActions(); // Hooks for routing const navigate = useNavigate(); @@ -50,6 +53,7 @@ function App() { await logout(); clearSession(); resetAppearance(); + setUser(null); setIsAuthenticated(false); navigate('/login'); }; @@ -67,17 +71,19 @@ function App() { }/> }/> }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> + }> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> - }/> - }/> - }/> }/> diff --git a/src/components/AddGamePanel.jsx b/src/components/AddGamePanel.jsx index 62d5a00..72c03cb 100644 --- a/src/components/AddGamePanel.jsx +++ b/src/components/AddGamePanel.jsx @@ -11,11 +11,11 @@ import {Label} from '@/components/ui/label'; import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/components/ui/select'; import GameCover from './GameCover.jsx'; import GameSearch from './GameSearch.jsx'; -import useSessionExpired from '../hooks/useSessionExpired.js'; import useStartImport from '../hooks/useStartImport.js'; import {GAME_STATUSES, MAX_PLATFORM_LENGTH} from '../utils/limits.js'; import {addGame, hoursToMinutes} from '../utils/manualGames.js'; -import {loadFavoritePlatform, preselectPlatform} from '../utils/platforms.js'; +import {preselectPlatform} from '../utils/platforms.js'; +import {useCurrentUserContext} from '../context/CurrentUserContext.jsx'; /** * Search IGDB and add a game the user owns outside Steam, e.g. on PlayStation or Switch; or check several and review @@ -24,16 +24,12 @@ import {loadFavoritePlatform, preselectPlatform} from '../utils/platforms.js'; export default function AddGamePanel({onClose}) { const {t} = useTranslation(); const navigate = useNavigate(); - const expireSession = useSessionExpired(); const [query, setQuery] = useState(''); const [picked, setPicked] = useState(null); // igdbId -> hit, over several searches. const [selected, setSelected] = useState(() => new Map()); const [busy, setBusy] = useState(false); - const [favorite, setFavorite] = useState(null); - useEffect(() => { - loadFavoritePlatform().then(setFavorite); - }, []); + const favorite = useCurrentUserContext().user?.settings?.favoritePlatform ?? null; const startImport = useStartImport(); const toggle = (hit) => { @@ -64,8 +60,7 @@ export default function AddGamePanel({onClose}) { {picked ? ( - setPicked(null)} navigate={navigate} - expireSession={expireSession}/> + setPicked(null)} navigate={navigate}/> ) : ( <>

{t('MANUAL_GAME.ADD_HINT')}

@@ -85,7 +80,7 @@ export default function AddGamePanel({onClose}) { ); } -function AddForm({hit, favorite, onBack, navigate, expireSession}) { +function AddForm({hit, favorite, onBack, navigate}) { const {t} = useTranslation(); const [platform, setPlatform] = useState(() => preselectPlatform(null, hit.platforms, favorite)); // The favorite can arrive after the pick; it fills only a platform the user has not picked yet. @@ -103,10 +98,7 @@ function AddForm({hit, favorite, onBack, navigate, expireSession}) { const response = await addGame({ igdbId: hit.igdbId, platform: platform.trim(), status, playtimeMinutes: hoursToMinutes(hours), }); - if (response.status === 401) { - expireSession(); - return; - } + if (response.status === 401) return; if (response.status === 409) { toast.error(t('MANUAL_GAME.ALREADY_IN_LIBRARY')); return; diff --git a/src/components/AddGamePanel.test.jsx b/src/components/AddGamePanel.test.jsx index c70dcb5..82b7366 100644 --- a/src/components/AddGamePanel.test.jsx +++ b/src/components/AddGamePanel.test.jsx @@ -4,6 +4,8 @@ import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/reac import {MemoryRouter, Route, Routes} from 'react-router-dom'; import {toast} from 'sonner'; import AddGamePanel from './AddGamePanel.jsx'; +import {CurrentUserProvider} from '../context/CurrentUserContext.jsx'; +import {json} from '../test/fetchRoutes.js'; vi.mock('react-i18next', () => import('../test/i18n-mock.js')); vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}})); @@ -14,7 +16,6 @@ const zelda = { }; const portal = {...zelda, appid: 400, igdbId: 71, name: 'Portal', inLibrary: true}; -const json = (status, body) => ({ok: status < 400, status, json: async () => body}); function renderPanel(post = json(201, {appid: -1025}), favoritePlatform = 'Nintendo Switch') { globalThis.fetch = vi.fn(async (url, init) => { @@ -22,13 +23,13 @@ function renderPanel(post = json(201, {appid: -1025}), favoritePlatform = 'Ninte return init?.method === 'POST' ? post : json(200, {available: true, results: [zelda, portal]}); }); render( - + {}}/>}/> detail page

}/> import page

}/>
-
, +
, ); } diff --git a/src/components/AppHeader.jsx b/src/components/AppHeader.jsx index f2408b1..2622e54 100644 --- a/src/components/AppHeader.jsx +++ b/src/components/AppHeader.jsx @@ -16,6 +16,7 @@ import { DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import {usePreferences} from '@/context/PreferencesContext.jsx'; +import {useCurrentUserActions} from '@/context/CurrentUserContext.jsx'; import {cn} from '@/lib/utils'; import Logo from './Logo.jsx'; import PageContainer from './PageContainer.jsx'; @@ -47,6 +48,7 @@ export default function AppHeader({isAuthenticated, onLogout}) { const {pathname} = useLocation(); const steamProfile = useSteamProfile(isAuthenticated); const currentUser = useCurrentUser(isAuthenticated); + const {reload: reloadUser} = useCurrentUserActions(); const navigate = useNavigate(); const expireSession = useSessionExpired(); const isAdmin = currentUser?.role === 'PLATFORM_ADMIN'; @@ -56,8 +58,9 @@ export default function AppHeader({isAuthenticated, onLogout}) { try { const response = await viewAs(role === currentUser.role ? null : role); if (response.ok) { - // New cookies with the other role; the new stamp makes the header and pages load again. + // New cookies with the other role; the new stamp makes the Steam data load again. markSession(); + reloadUser(); navigate('/dashboard'); return; } diff --git a/src/components/AppHeader.test.jsx b/src/components/AppHeader.test.jsx index 353b6f7..805d406 100644 --- a/src/components/AppHeader.test.jsx +++ b/src/components/AppHeader.test.jsx @@ -6,6 +6,7 @@ import {toast} from 'sonner'; import {useTranslation} from 'react-i18next'; import AppHeader from './AppHeader.jsx'; import {PreferencesProvider} from '../context/PreferencesContext.jsx'; +import {CurrentUserProvider} from '../context/CurrentUserContext.jsx'; vi.mock('react-i18next', () => import('../test/i18n-mock.js')); vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}})); @@ -23,7 +24,7 @@ const loggedInAs = (me) => { })); render( - {}}/> + {}}/> , ); }; @@ -48,7 +49,7 @@ describe('AppHeader', () => { it('keeps a name on the home link when a narrow phone hides the app name', () => { render( - {}}/> + {}}/> , ); screen.getByText('ToBePlayed').style.display = 'none'; diff --git a/src/components/BacklogCard.jsx b/src/components/BacklogCard.jsx index 7d73e06..08abfc7 100644 --- a/src/components/BacklogCard.jsx +++ b/src/components/BacklogCard.jsx @@ -1,5 +1,4 @@ import {useEffect, useState} from 'react'; -import {useNavigate} from 'react-router-dom'; import {useTranslation} from 'react-i18next'; import {toast} from 'sonner'; import {ListTodo, Loader2, Star} from 'lucide-react'; @@ -13,8 +12,6 @@ import {cn} from '@/lib/utils'; import {STATUS_META} from './GameStatusBadge.jsx'; import {NO_PRIORITY, saveBacklogEntry, toBacklogRequest} from '../utils/backlog.js'; import {GAME_PRIORITIES, GAME_STATUSES, MAX_NOTES_LENGTH} from '../utils/limits.js'; -import useSessionExpired from '../hooks/useSessionExpired.js'; -import {hasSession} from '../utils/auth.js'; const toForm = (entry) => ({ status: entry.status ?? 'BACKLOG', @@ -25,8 +22,6 @@ const toForm = (entry) => ({ export default function BacklogCard({game, onSaved}) { const {t} = useTranslation(); - const navigate = useNavigate(); - const expireSession = useSessionExpired(); const [saved, setSaved] = useState(() => toForm(game)); const [form, setForm] = useState(saved); @@ -41,17 +36,10 @@ export default function BacklogCard({game, onSaved}) { const set = (field) => (value) => setForm((f) => ({...f, [field]: value})); const save = async () => { - if (!hasSession()) { - navigate('/login'); - return; - } setBusy(true); try { const response = await saveBacklogEntry(game.appid, toBacklogRequest(form)); - if (response.status === 401) { - expireSession(); - return; - } + if (response.status === 401) return; if (!response.ok) { toast.error(t('BACKLOG.SAVE_FAILED')); return; diff --git a/src/components/CompletedHint.jsx b/src/components/CompletedHint.jsx index 8653672..7b29260 100644 --- a/src/components/CompletedHint.jsx +++ b/src/components/CompletedHint.jsx @@ -5,7 +5,6 @@ import {Loader2, Trophy} from 'lucide-react'; import {Button} from '@/components/ui/button'; import {Card} from '@/components/ui/card'; -import useSessionExpired from '../hooks/useSessionExpired.js'; import {saveGameStatus} from '../utils/backlog.js'; import {allAchievementsUnlocked} from '../utils/confetti.js'; @@ -16,7 +15,6 @@ import {allAchievementsUnlocked} from '../utils/confetti.js'; */ export default function CompletedHint({game, onChanged}) { const {t} = useTranslation(); - const expireSession = useSessionExpired(); const [busy, setBusy] = useState(false); if (!allAchievementsUnlocked(game) || game.status === 'COMPLETED') return null; @@ -25,10 +23,7 @@ export default function CompletedHint({game, onChanged}) { setBusy(true); try { const response = await saveGameStatus(game.appid, 'COMPLETED'); - if (response.status === 401) { - expireSession(); - return; - } + if (response.status === 401) return; if (!response.ok) { toast.error(t('BACKLOG.SAVE_FAILED')); return; diff --git a/src/components/Confetti.jsx b/src/components/Confetti.jsx index 7b0cb3f..90ddc7b 100644 --- a/src/components/Confetti.jsx +++ b/src/components/Confetti.jsx @@ -49,6 +49,9 @@ function pieces(size, width, height) { export default function Confetti({size, onDone}) { const canvasRef = useRef(null); const [reduce] = useState(reducedMotion); + // In a ref: a new function from a re-render must not restart the animation, and the latest one runs at the end. + const onDoneRef = useRef(onDone); + onDoneRef.current = onDone; useEffect(() => { const canvas = canvasRef.current; @@ -67,7 +70,7 @@ export default function Confetti({size, onDone}) { context.setTransform(1, 0, 0, 1, 0, 0); context.clearRect(0, 0, canvas.width, canvas.height); if (elapsed >= DURATION_MS) { - onDone?.(); + onDoneRef.current?.(); return; } context.globalAlpha = 1 - elapsed / DURATION_MS; @@ -88,7 +91,6 @@ export default function Confetti({size, onDone}) { }; frame = requestAnimationFrame(draw); return () => cancelAnimationFrame(frame); - // Not on onDone: a new function from a re-render must not restart the animation. }, [size]); if (reduce) return null; diff --git a/src/components/DeleteAccountCard.jsx b/src/components/DeleteAccountCard.jsx index 1125d1d..70a4dc6 100644 --- a/src/components/DeleteAccountCard.jsx +++ b/src/components/DeleteAccountCard.jsx @@ -19,7 +19,7 @@ import { import {Input} from '@/components/ui/input'; import {Label} from '@/components/ui/label'; import {useResetAppearance} from '../context/PreferencesContext.jsx'; -import useSessionExpired from '../hooks/useSessionExpired.js'; +import {useCurrentUserActions} from '../context/CurrentUserContext.jsx'; import {deleteAccount} from '../utils/account.js'; import {clearSession} from '../utils/auth.js'; @@ -33,7 +33,7 @@ export default function DeleteAccountCard({email}) { const {t} = useTranslation(); const navigate = useNavigate(); const resetAppearance = useResetAppearance(); - const expireSession = useSessionExpired(); + const {setUser} = useCurrentUserActions(); const [typedEmail, setTypedEmail] = useState(''); const [password, setPassword] = useState(''); const [busy, setBusy] = useState(false); @@ -45,18 +45,17 @@ export default function DeleteAccountCard({email}) { if (response.status === 204) { clearSession(); resetAppearance(); + setUser(null); toast.success(t('SETTINGS.DELETE_ACCOUNT.DONE')); navigate('/', {replace: true}); return; } - if (response.status === 401) { - expireSession(); - } else if (response.status === 403) { + if (response.status === 403) { toast.error(t('SETTINGS.DELETE_ACCOUNT.WRONG_PASSWORD')); } else if (response.status === 429) { const minutes = Math.ceil(Number(response.headers.get('Retry-After') ?? 900) / 60); toast.error(t('SETTINGS.DELETE_ACCOUNT.TOO_MANY', {minutes})); - } else { + } else if (response.status !== 401) { toast.error(t('SETTINGS.DELETE_ACCOUNT.ERROR')); } } catch (err) { diff --git a/src/components/DeleteAccountCard.test.jsx b/src/components/DeleteAccountCard.test.jsx index d559187..50690a8 100644 --- a/src/components/DeleteAccountCard.test.jsx +++ b/src/components/DeleteAccountCard.test.jsx @@ -2,6 +2,7 @@ import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react'; import {MemoryRouter, Route, Routes} from 'react-router-dom'; +import RequireSession from './RequireSession.jsx'; import {toast} from 'sonner'; import DeleteAccountCard from './DeleteAccountCard.jsx'; @@ -15,7 +16,7 @@ const answer = (status, headers = {}) => ({ok: status < 300, status, headers: ne const renderCard = () => render( - }/> + }>}/> landing

}/> login

}/>
diff --git a/src/components/FinishedHint.jsx b/src/components/FinishedHint.jsx index 60e75ea..16c547c 100644 --- a/src/components/FinishedHint.jsx +++ b/src/components/FinishedHint.jsx @@ -5,7 +5,6 @@ import {Flag, Loader2} from 'lucide-react'; import {Button} from '@/components/ui/button'; import {Card} from '@/components/ui/card'; -import useSessionExpired from '../hooks/useSessionExpired.js'; import {dismissFinishedHint, saveGameStatus} from '../utils/backlog.js'; import {formatPlaytime} from '../utils/games.js'; @@ -17,7 +16,6 @@ import {formatPlaytime} from '../utils/games.js'; */ export default function FinishedHint({game, onChanged, onDismissed}) { const {t} = useTranslation(); - const expireSession = useSessionExpired(); const [busy, setBusy] = useState(false); const hint = game.finishedHint; @@ -28,10 +26,7 @@ export default function FinishedHint({game, onChanged, onDismissed}) { setBusy(true); try { const response = await request(); - if (response.status === 401) { - expireSession(); - return; - } + if (response.status === 401) return; if (!response.ok) { toast.error(t('BACKLOG.SAVE_FAILED')); return; diff --git a/src/components/FinishedHint.test.jsx b/src/components/FinishedHint.test.jsx index 23e9807..3135e1f 100644 --- a/src/components/FinishedHint.test.jsx +++ b/src/components/FinishedHint.test.jsx @@ -2,6 +2,7 @@ import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react'; import {MemoryRouter, Route, Routes} from 'react-router-dom'; +import RequireSession from './RequireSession.jsx'; import FinishedHint from './FinishedHint.jsx'; vi.mock('react-i18next', () => import('../test/i18n-mock.js')); @@ -12,7 +13,9 @@ const ending = {appid: 322110, status: 'PLAYING', playtimeMinutes: 600, timeToBe const renderHint = (props) => render( - {}} onDismissed={() => {}} {...props}/>}/> + }> + {}} onDismissed={() => {}} {...props}/>}/> + login page

}/>
); diff --git a/src/components/GameCorrectionDialog.jsx b/src/components/GameCorrectionDialog.jsx index c55603a..72676a1 100644 --- a/src/components/GameCorrectionDialog.jsx +++ b/src/components/GameCorrectionDialog.jsx @@ -8,7 +8,6 @@ import {Button} from '@/components/ui/button'; import {Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle} from '@/components/ui/dialog'; import {Input} from '@/components/ui/input'; import {Label} from '@/components/ui/label'; -import useSessionExpired from '../hooks/useSessionExpired.js'; import {CORRECTABLE_MOODS, IGDB_GENRES, resetCorrection, saveCorrection} from '../utils/corrections.js'; import {genreKey} from '../utils/stats.js'; import {MAX_CORRECTION_GENRES, MAX_CORRECTION_LENGTH} from '../utils/limits.js'; @@ -35,7 +34,6 @@ const toggled = (list, item) => (list.includes(item) ? list.filter((each) => eac */ export default function GameCorrectionDialog({game, open, onOpenChange, onChanged}) { const {t} = useTranslation(); - const expireSession = useSessionExpired(); const [values, setValues] = useState(() => initialValues(game)); const [busy, setBusy] = useState(null); // The field whose save or reset the next game from GameDetail answers; the other fields keep their edits. @@ -44,6 +42,7 @@ export default function GameCorrectionDialog({game, open, onOpenChange, onChange useEffect(() => { if (open) setValues(initialValues(game)); // Only when the dialog opens; a reload after a save is handled below. + // eslint-disable-next-line react-hooks/exhaustive-deps }, [open]); useEffect(() => { @@ -61,10 +60,7 @@ export default function GameCorrectionDialog({game, open, onOpenChange, onChange setBusy(field); try { const response = await request(); - if (response.status === 401) { - expireSession(); - return; - } + if (response.status === 401) return; if (response.ok) { toast.success(t('CORRECTION.SAVED')); sentField.current = field; diff --git a/src/components/GameMergeDialog.jsx b/src/components/GameMergeDialog.jsx index 617f28f..92c300d 100644 --- a/src/components/GameMergeDialog.jsx +++ b/src/components/GameMergeDialog.jsx @@ -6,7 +6,6 @@ import {toast} from 'sonner'; import {Button} from '@/components/ui/button'; import {Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle} from '@/components/ui/dialog'; import GameSearch from './GameSearch.jsx'; -import useSessionExpired from '../hooks/useSessionExpired.js'; import {mergeGame} from '../utils/corrections.js'; import {isSteamKey} from '../utils/manualGames.js'; @@ -18,7 +17,6 @@ const KNOWN_ERRORS = new Set(['SAME_GAME', 'STEAM_GAMES', 'GAME_NOT_FOUND']); */ export default function GameMergeDialog({game, open, onOpenChange, onMerged}) { const {t} = useTranslation(); - const expireSession = useSessionExpired(); const [query, setQuery] = useState(''); const [picked, setPicked] = useState(null); const [busy, setBusy] = useState(false); @@ -34,10 +32,7 @@ export default function GameMergeDialog({game, open, onOpenChange, onMerged}) { setBusy(true); try { const response = await mergeGame(game.appid, picked.appid); - if (response.status === 401) { - expireSession(); - return; - } + if (response.status === 401) return; const data = await response.json().catch(() => ({})); if (response.ok) { toast.success(t('MERGE.DONE')); diff --git a/src/components/GameSearch.jsx b/src/components/GameSearch.jsx index fdc07cd..2e14ba4 100644 --- a/src/components/GameSearch.jsx +++ b/src/components/GameSearch.jsx @@ -5,7 +5,6 @@ import {Loader2, Search} from 'lucide-react'; import {Badge} from '@/components/ui/badge'; import {Input} from '@/components/ui/input'; import GameCover from './GameCover.jsx'; -import useSessionExpired from '../hooks/useSessionExpired.js'; import {searchGames} from '../utils/manualGames.js'; import {MAX_GAME_SEARCH_LENGTH, MIN_GAME_SEARCH_LENGTH} from '../utils/limits.js'; @@ -81,7 +80,6 @@ export default function GameSearch({query, onQueryChange, onPick, hide = () => f // Asks Core 400 ms after the last key press; searchState is idle, loading, done, unavailable or failed. function useGameSearch(query) { - const expireSession = useSessionExpired(); const [searchState, setSearchState] = useState('idle'); const [results, setResults] = useState([]); @@ -97,10 +95,7 @@ function useGameSearch(query) { setSearchState('loading'); try { const response = await searchGames(trimmed, controller.signal); - if (response.status === 401) { - expireSession(); - return; - } + if (response.status === 401) return; if (!response.ok) { setSearchState('failed'); return; @@ -116,7 +111,7 @@ function useGameSearch(query) { clearTimeout(timer); controller.abort(); }; - }, [query, expireSession]); + }, [query]); return {searchState, results}; } diff --git a/src/components/ManualGameCard.jsx b/src/components/ManualGameCard.jsx index 8fc3c79..f3e59b6 100644 --- a/src/components/ManualGameCard.jsx +++ b/src/components/ManualGameCard.jsx @@ -8,7 +8,6 @@ import {Button} from '@/components/ui/button'; import {Card} from '@/components/ui/card'; import {Input} from '@/components/ui/input'; import {Label} from '@/components/ui/label'; -import useSessionExpired from '../hooks/useSessionExpired.js'; import {hoursToMinutes, removeGame, toHours, updateManualGame} from '../utils/manualGames.js'; import {MAX_PLATFORM_LENGTH} from '../utils/limits.js'; @@ -20,7 +19,6 @@ import {MAX_PLATFORM_LENGTH} from '../utils/limits.js'; export default function ManualGameCard({game, onSaved}) { const {t} = useTranslation(); const navigate = useNavigate(); - const expireSession = useSessionExpired(); const [platform, setPlatform] = useState(game.platform ?? ''); const [hours, setHours] = useState(toHours(game.playtimeMinutes)); const [busy, setBusy] = useState(false); @@ -33,10 +31,7 @@ export default function ManualGameCard({game, onSaved}) { setBusy(true); try { const response = await request(); - if (response.status === 401) { - expireSession(); - return; - } + if (response.status === 401) return; if (!response.ok) { toast.error(t('BACKLOG.SAVE_FAILED')); return; diff --git a/src/components/PlayingHint.jsx b/src/components/PlayingHint.jsx index c062d31..e4982d4 100644 --- a/src/components/PlayingHint.jsx +++ b/src/components/PlayingHint.jsx @@ -5,7 +5,6 @@ import {Gamepad2, Loader2} from 'lucide-react'; import {Button} from '@/components/ui/button'; import {Card} from '@/components/ui/card'; -import useSessionExpired from '../hooks/useSessionExpired.js'; import {saveBacklogEntry} from '../utils/backlog.js'; import {formatPlaytime} from '../utils/games.js'; @@ -16,7 +15,6 @@ import {formatPlaytime} from '../utils/games.js'; */ export default function PlayingHint({game, onChanged}) { const {t} = useTranslation(); - const expireSession = useSessionExpired(); const [busy, setBusy] = useState(false); if (game.status !== 'BACKLOG' || !(game.playtime2Weeks > 0)) return null; @@ -27,10 +25,7 @@ export default function PlayingHint({game, onChanged}) { const response = await saveBacklogEntry(game.appid, { status: 'PLAYING', rating: game.rating ?? null, notes: game.notes ?? null, priority: game.priority ?? null, }); - if (response.status === 401) { - expireSession(); - return; - } + if (response.status === 401) return; if (!response.ok) { toast.error(t('BACKLOG.SAVE_FAILED')); return; diff --git a/src/components/RequireSession.jsx b/src/components/RequireSession.jsx new file mode 100644 index 0000000..5d39b2f --- /dev/null +++ b/src/components/RequireSession.jsx @@ -0,0 +1,34 @@ +import {useEffect, useRef} from 'react'; +import {Navigate, Outlet, useLocation} from 'react-router-dom'; +import useSessionExpired from '../hooks/useSessionExpired.js'; +import {sessionEvents} from '../utils/api.js'; +import {hasSession} from '../utils/auth.js'; + +/** + * Layout route of the pages that need a login. Without a session it opens the login page, which comes back here; + * a 401 on one of these pages ends the session (see apiFetch). + */ +export default function RequireSession() { + const {pathname, search} = useLocation(); + const expireSession = useSessionExpired(); + // In a ref, so the listener is added once and not again on every navigation. + const expireRef = useRef(expireSession); + expireRef.current = expireSession; + // A render before the login page shows must not redirect again without the "session expired" state. + const expiring = useRef(false); + + useEffect(() => { + // Parallel requests each fire; only the first ends the session. Another tab may have removed the flag already. + const onExpired = () => { + if (expiring.current) return; + expiring.current = true; + expireRef.current(); + }; + sessionEvents.addEventListener('expired', onExpired); + return () => sessionEvents.removeEventListener('expired', onExpired); + }, []); + + return hasSession() || expiring.current + ? + : ; +} diff --git a/src/components/RequireSession.test.jsx b/src/components/RequireSession.test.jsx new file mode 100644 index 0000000..d559727 --- /dev/null +++ b/src/components/RequireSession.test.jsx @@ -0,0 +1,88 @@ +// @vitest-environment jsdom +import {afterEach, describe, expect, it} from 'vitest'; +import {act, cleanup, render, screen} from '@testing-library/react'; +import {MemoryRouter, Outlet, Route, Routes, useLocation} from 'react-router-dom'; +import RequireSession from './RequireSession.jsx'; +import {sessionEvents} from '../utils/api.js'; + +const visitedLogin = []; + +function LoginState() { + const location = useLocation(); + visitedLogin.push(location.key); + return
{JSON.stringify(location.state)}
; +} + +function renderAt(entry) { + render( + + + }> + }> + library

}/> +
+ profile

}/> + }/> + +
+
, + ); +} + +const loginState = () => JSON.parse(screen.getByTestId('state').textContent); +const expire = () => act(() => { + sessionEvents.dispatchEvent(new Event('expired')); +}); + +afterEach(() => { + cleanup(); + localStorage.clear(); + visitedLogin.length = 0; +}); + +describe('RequireSession', () => { + it('sends a deep link without a session to the login page, which comes back to it', () => { + renderAt('/library?status=PLAYING'); + + expect(loginState()).toEqual({from: '/library?status=PLAYING'}); + }); + + it('shows the page with a session', () => { + localStorage.setItem('tbp_session', '1'); + renderAt('/library'); + + expect(screen.getByText('library')).toBeTruthy(); + }); + + it('ends the session once when requests fail with a 401', () => { + localStorage.setItem('tbp_session', '1'); + renderAt('/library?status=PLAYING'); + + expire(); + expire(); + + expect(loginState()).toEqual({sessionExpired: true, from: '/library?status=PLAYING'}); + expect(new Set(visitedLogin).size).toBe(1); + expect(localStorage.getItem('tbp_session')).toBeNull(); + }); + + it('leaves a public page alone on a 401', () => { + localStorage.setItem('tbp_session', '1'); + renderAt('/u/someone'); + + expire(); + + expect(screen.getByText('profile')).toBeTruthy(); + expect(localStorage.getItem('tbp_session')).toBe('1'); + }); + it('ends the session when another tab already removed the session flag', () => { + localStorage.setItem('tbp_session', '1'); + renderAt('/library'); + + localStorage.removeItem('tbp_session'); + expire(); + + expect(screen.queryByText('library')).toBeNull(); + expect(loginState()).toEqual({sessionExpired: true, from: '/library'}); + }); +}); diff --git a/src/components/SteamAccountCard.jsx b/src/components/SteamAccountCard.jsx index 8b4c31e..7acf382 100644 --- a/src/components/SteamAccountCard.jsx +++ b/src/components/SteamAccountCard.jsx @@ -6,7 +6,6 @@ import {toast} from 'sonner'; import {Badge} from '@/components/ui/badge'; import {Button} from '@/components/ui/button'; import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card'; -import useSessionExpired from '../hooks/useSessionExpired.js'; import {markSession} from '../utils/auth.js'; import {startSteamLink, unlinkSteam} from '../utils/steamLink.js'; @@ -16,7 +15,6 @@ import {startSteamLink, unlinkSteam} from '../utils/steamLink.js'; */ export default function SteamAccountCard({steamId, verified, onUnlinked}) { const {t} = useTranslation(); - const expireSession = useSessionExpired(); const [busy, setBusy] = useState(false); const link = async () => { @@ -24,11 +22,7 @@ export default function SteamAccountCard({steamId, verified, onUnlinked}) { try { const response = await startSteamLink(); if (response.ok) return; // on the way to Steam: the button stays busy - if (response.status === 401) { - expireSession(); - } else { - toast.error(t('SETTINGS.STEAM_LINK.ERROR')); - } + if (response.status !== 401) toast.error(t('SETTINGS.STEAM_LINK.ERROR')); } catch (err) { console.error('Steam link error:', err); toast.error(t('SETTINGS.STEAM_LINK.ERROR')); @@ -40,10 +34,7 @@ export default function SteamAccountCard({steamId, verified, onUnlinked}) { setBusy(true); try { const response = await unlinkSteam(); - if (response.status === 401) { - expireSession(); - return; - } + if (response.status === 401) return; if (!response.ok) { toast.error(t('SETTINGS.STEAM_LINK.ERROR')); return; diff --git a/src/components/ui/alert.jsx b/src/components/ui/alert.jsx index 607c06c..0fda7c0 100644 --- a/src/components/ui/alert.jsx +++ b/src/components/ui/alert.jsx @@ -1,4 +1,3 @@ -import * as React from "react" import { cva } from "class-variance-authority"; import { cn } from "@/lib/utils" diff --git a/src/components/ui/badge.jsx b/src/components/ui/badge.jsx index 878ca5a..b2daa1c 100644 --- a/src/components/ui/badge.jsx +++ b/src/components/ui/badge.jsx @@ -1,4 +1,3 @@ -import * as React from "react" import { cva } from "class-variance-authority"; import { cn } from "@/lib/utils" import { Slot } from "radix-ui" diff --git a/src/components/ui/button.jsx b/src/components/ui/button.jsx index e744b1c..7b38fbd 100644 --- a/src/components/ui/button.jsx +++ b/src/components/ui/button.jsx @@ -1,4 +1,3 @@ -import * as React from "react" import { cva } from "class-variance-authority"; import { cn } from "@/lib/utils" import { Slot } from "radix-ui" diff --git a/src/components/ui/card.jsx b/src/components/ui/card.jsx index 526c1b5..d43a64b 100644 --- a/src/components/ui/card.jsx +++ b/src/components/ui/card.jsx @@ -1,4 +1,3 @@ -import * as React from "react" import { cn } from "@/lib/utils" function Card({ diff --git a/src/components/ui/dialog.jsx b/src/components/ui/dialog.jsx index 1352b46..8abd186 100644 --- a/src/components/ui/dialog.jsx +++ b/src/components/ui/dialog.jsx @@ -1,4 +1,3 @@ -import * as React from "react" import { XIcon } from "lucide-react" import { Dialog as DialogPrimitive } from "radix-ui" diff --git a/src/components/ui/dropdown-menu.jsx b/src/components/ui/dropdown-menu.jsx index daacdf6..94a6421 100644 --- a/src/components/ui/dropdown-menu.jsx +++ b/src/components/ui/dropdown-menu.jsx @@ -1,6 +1,5 @@ "use client" -import * as React from "react" import { cn } from "@/lib/utils" import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react" import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui" diff --git a/src/components/ui/input.jsx b/src/components/ui/input.jsx index 292ed7d..fd73a7b 100644 --- a/src/components/ui/input.jsx +++ b/src/components/ui/input.jsx @@ -1,4 +1,3 @@ -import * as React from "react" import { cn } from "@/lib/utils" function Input({ diff --git a/src/components/ui/label.jsx b/src/components/ui/label.jsx index e531796..12f4a62 100644 --- a/src/components/ui/label.jsx +++ b/src/components/ui/label.jsx @@ -1,4 +1,3 @@ -import * as React from "react" import { cn } from "@/lib/utils" import { Label as LabelPrimitive } from "radix-ui" diff --git a/src/components/ui/select.jsx b/src/components/ui/select.jsx index e0a5d56..942eebc 100644 --- a/src/components/ui/select.jsx +++ b/src/components/ui/select.jsx @@ -1,6 +1,5 @@ "use client" -import * as React from "react" import { cn } from "@/lib/utils" import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react" import { Select as SelectPrimitive } from "radix-ui" diff --git a/src/components/ui/separator.jsx b/src/components/ui/separator.jsx index 7effbfc..1512c41 100644 --- a/src/components/ui/separator.jsx +++ b/src/components/ui/separator.jsx @@ -1,4 +1,3 @@ -import * as React from "react" import { cn } from "@/lib/utils" import { Separator as SeparatorPrimitive } from "radix-ui" diff --git a/src/components/ui/switch.jsx b/src/components/ui/switch.jsx index 67f4af2..8d7ebaf 100644 --- a/src/components/ui/switch.jsx +++ b/src/components/ui/switch.jsx @@ -1,4 +1,3 @@ -import * as React from "react" import { cn } from "@/lib/utils" import { Switch as SwitchPrimitive } from "radix-ui" diff --git a/src/components/ui/textarea.jsx b/src/components/ui/textarea.jsx index 18ad4c4..c6f9a40 100644 --- a/src/components/ui/textarea.jsx +++ b/src/components/ui/textarea.jsx @@ -1,5 +1,3 @@ -import * as React from "react" - import { cn } from "@/lib/utils" function Textarea({ className, ...props }) { diff --git a/src/components/ui/tooltip.jsx b/src/components/ui/tooltip.jsx index 5d82fd5..e394624 100644 --- a/src/components/ui/tooltip.jsx +++ b/src/components/ui/tooltip.jsx @@ -1,4 +1,3 @@ -import * as React from "react" import { cn } from "@/lib/utils" import { Tooltip as TooltipPrimitive } from "radix-ui" diff --git a/src/context/CurrentUserContext.jsx b/src/context/CurrentUserContext.jsx new file mode 100644 index 0000000..b4502cf --- /dev/null +++ b/src/context/CurrentUserContext.jsx @@ -0,0 +1,74 @@ +import {createContext, useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react'; +import {request} from '../utils/api.js'; +import {SESSION_KEY, hasSession} from '../utils/auth.js'; +import {ME_URL, REQUEST_TIMEOUT_MS} from '../utils/settings.js'; + +const UserContext = createContext(null); +// Apart from the user, so a component that only reloads or clears it does not render again on each change. +const ActionsContext = createContext(null); + +/** + * GET /api/users/me for the whole app. `user` is undefined until the first answer and null without a session. + * `reload()` loads it again and resolves to the `request()` result (not ok for a 200 without a body). + * `updateUser(fn)` changes it locally after a save, and skips it when the user is gone by then; `setUser(null)` + * drops it. A save that replaces settings still loads /me itself (see saveSettings), as it must be fresh. + */ +export function CurrentUserProvider({children}) { + const [user, setUser] = useState(() => (hasSession() ? undefined : null)); + const latest = useRef(0); + + const reload = useCallback(async () => { + const call = ++latest.current; + // After a logout, "no user" becomes "loading" (e.g. the import waits for the favorite platform). + setUser((current) => current ?? undefined); + const response = await request(ME_URL(), {signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)}); + const result = {...response, ok: response.ok && response.data != null}; + if (call === latest.current) { + // Only a 401 means "no user"; another failure keeps the user shown so far, or ends "loading". + if (result.ok) setUser(result.data); + else if (result.status === 401) setUser(null); + else setUser((current) => current ?? null); + } + return result; + }, []); + + useEffect(() => { + if (hasSession()) reload(); + // A login, logout or "view as" in another tab. + const onStorage = (event) => { + if (event.key !== null && event.key !== SESSION_KEY) return; + if (hasSession()) reload(); + else setUser(null); + }; + window.addEventListener('storage', onStorage); + return () => window.removeEventListener('storage', onStorage); + }, [reload]); + + const updateUser = useCallback((change) => setUser((current) => current && change(current)), []); + const actions = useMemo(() => ({reload, setUser, updateUser}), [reload, updateUser]); + + return ( + + {children} + + ); +} + +const NO_ACTIONS = {reload: async () => ({ok: false, status: 0, data: null}), setUser: () => {}, updateUser: () => {}}; + +/** + * `{reload, setUser, updateUser}`, without the user. Outside a provider (e.g. a test of one component) they do + * nothing. + */ +export function useCurrentUserActions() { + return useContext(ActionsContext) ?? NO_ACTIONS; +} + +/** + * `{user, reload, setUser, updateUser}`; outside a provider there is no user. + */ +export function useCurrentUserContext() { + const user = useContext(UserContext); + const actions = useContext(ActionsContext); + return {user: actions ? user : null, ...(actions ?? NO_ACTIONS)}; +} diff --git a/src/context/CurrentUserContext.test.jsx b/src/context/CurrentUserContext.test.jsx new file mode 100644 index 0000000..7393159 --- /dev/null +++ b/src/context/CurrentUserContext.test.jsx @@ -0,0 +1,154 @@ +// @vitest-environment jsdom +import {afterEach, describe, expect, it, vi} from 'vitest'; +import {act, cleanup, render, screen, waitFor} from '@testing-library/react'; +import {CurrentUserProvider, useCurrentUserContext} from './CurrentUserContext.jsx'; +import useCurrentUser from '../hooks/useCurrentUser.js'; +import {fetchRoutes, json} from '../test/fetchRoutes.js'; + +const context = {current: null}; + +function Role({name}) { + const user = useCurrentUser(true); + return

{name}: {user?.activeRole ?? 'none'}

; +} + +function Spy() { + context.current = useCurrentUserContext(); + return null; +} + +const renderTwo = () => render( + , +); + +afterEach(() => { + cleanup(); + localStorage.clear(); +}); + +describe('CurrentUserProvider', () => { + it('loads /me once for every place that shows the user', async () => { + localStorage.setItem('tbp_session', '1'); + const fetch = fetchRoutes({'GET /api/users/me': json(200, {role: 'PLATFORM_ADMIN'})}); + renderTwo(); + + expect(await screen.findByText('header: PLATFORM_ADMIN')).toBeTruthy(); + expect(screen.getByText('page: PLATFORM_ADMIN')).toBeTruthy(); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it('asks nothing without a session', () => { + const fetch = fetchRoutes({}); + renderTwo(); + + expect(screen.getByText('header: none')).toBeTruthy(); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('shows the new role after a reload, and keeps it on a network error', async () => { + localStorage.setItem('tbp_session', '1'); + let answer = json(200, {role: 'PLATFORM_ADMIN'}); + fetchRoutes({'GET /api/users/me': () => answer}); + renderTwo(); + await screen.findByText('header: PLATFORM_ADMIN'); + + answer = json(200, {role: 'PLATFORM_ADMIN', activeRole: 'USER'}); + await act(() => context.current.reload()); + expect(screen.getByText('header: USER')).toBeTruthy(); + + vi.spyOn(console, 'error').mockImplementation(() => undefined); + answer = Promise.reject(new TypeError('Failed to fetch')); + answer.catch(() => undefined); + await act(() => context.current.reload()); + expect(screen.getByText('header: USER')).toBeTruthy(); + vi.restoreAllMocks(); + }); + + it('is loading again while the reload after a new login runs', async () => { + const answers = []; + fetchRoutes({'GET /api/users/me': () => new Promise((resolve) => answers.push(resolve))}); + renderTwo(); + expect(context.current.user).toBeNull(); + + localStorage.setItem('tbp_session', '1'); + act(() => { + context.current.reload(); + }); + + expect(context.current.user).toBeUndefined(); + await act(async () => answers[0](json(200, {role: 'USER'}))); + expect(screen.getByText('header: USER')).toBeTruthy(); + }); + + it('loads again after a login or "view as" in another tab, and drops the user after a logout there', async () => { + localStorage.setItem('tbp_session', '1'); + let answer = json(200, {role: 'PLATFORM_ADMIN'}); + fetchRoutes({'GET /api/users/me': () => answer}); + renderTwo(); + await screen.findByText('header: PLATFORM_ADMIN'); + + answer = json(200, {role: 'USER'}); + localStorage.setItem('tbp_session', '2'); + await act(async () => window.dispatchEvent(new StorageEvent('storage', {key: 'tbp_session'}))); + expect(screen.getByText('header: USER')).toBeTruthy(); + + localStorage.removeItem('tbp_session'); + act(() => window.dispatchEvent(new StorageEvent('storage', {key: 'tbp_session'}))); + expect(screen.getByText('header: none')).toBeTruthy(); + }); + + it('takes a 200 without a body as a failed load', async () => { + localStorage.setItem('tbp_session', '1'); + fetchRoutes({'GET /api/users/me': {ok: true, status: 200, json: async () => { throw new SyntaxError('empty'); }}}); + renderTwo(); + + let result; + await act(async () => { + result = await context.current.reload(); + }); + + expect(result.ok).toBe(false); + expect(context.current.user).toBeNull(); + }); + + it('changes nothing when the user is gone before a save answers', async () => { + fetchRoutes({}); + renderTwo(); + const change = vi.fn((user) => ({...user, displayName: 'x'})); + + act(() => context.current.updateUser(change)); + + expect(change).not.toHaveBeenCalled(); + expect(context.current.user).toBeNull(); + }); + + it('takes only the answer of the last reload', async () => { + localStorage.setItem('tbp_session', '1'); + const answers = []; + fetchRoutes({'GET /api/users/me': () => new Promise((resolve) => answers.push(resolve))}); + renderTwo(); + await waitFor(() => expect(answers).toHaveLength(1)); + + act(() => { + context.current.reload(); + }); + await act(async () => answers[1](json(200, {role: 'USER'}))); + expect(screen.getByText('header: USER')).toBeTruthy(); + // The first load answers last. + await act(async () => answers[0](json(200, {role: 'PLATFORM_ADMIN'}))); + + expect(screen.getByText('header: USER')).toBeTruthy(); + }); + it('keeps the loaded user when a reload gets a 5xx', async () => { + localStorage.setItem('tbp_session', '1'); + let answer = json(200, {role: 'PLATFORM_ADMIN'}); + fetchRoutes({'GET /api/users/me': () => answer}); + renderTwo(); + await screen.findByText('header: PLATFORM_ADMIN'); + + answer = json(503, {}); + await act(() => context.current.reload()); + + expect(screen.getByText('header: PLATFORM_ADMIN')).toBeTruthy(); + }); +}); diff --git a/src/hooks/useCurrentUser.js b/src/hooks/useCurrentUser.js index e7e1bbb..6f865f0 100644 --- a/src/hooks/useCurrentUser.js +++ b/src/hooks/useCurrentUser.js @@ -1,37 +1,18 @@ -import {useEffect, useState} from 'react'; -import {apiFetch} from '../utils/api.js'; -import {sessionStamp} from '../utils/auth.js'; +import {useMemo} from 'react'; +import {useCurrentUserContext} from '../context/CurrentUserContext.jsx'; import {normalizeProfileStyle} from '../utils/profileStyle.js'; /** * The stored role, the active role ("view as"), the display name and the public profile style of the logged-in - * user, or null while loading and on any error. - * Loads again after a new login or a role switch (see sessionStamp) on the next render. + * user, or null while loading, on any error and when not `enabled`. */ export default function useCurrentUser(enabled) { - const [user, setUser] = useState(null); - const stamp = sessionStamp(); + const {user} = useCurrentUserContext(); - useEffect(() => { - if (!enabled || !stamp) { - setUser(null); - return undefined; - } - let cancelled = false; - (async () => { - try { - const response = await apiFetch(`${import.meta.env.VITE_API_URL}/api/users/me?v=1`); - const data = response.ok ? await response.json() : null; - if (!cancelled) setUser(data && {role: data.role, activeRole: data.activeRole ?? data.role, displayName: data.displayName, - profileStyle: normalizeProfileStyle(data.settings?.profileStyle)}); - } catch { - if (!cancelled) setUser(null); - } - })(); - return () => { - cancelled = true; - }; - }, [enabled, stamp]); - - return user; + return useMemo(() => (enabled && user ? { + role: user.role, + activeRole: user.activeRole ?? user.role, + displayName: user.displayName, + profileStyle: normalizeProfileStyle(user.settings?.profileStyle), + } : null), [enabled, user]); } diff --git a/src/hooks/useForceSync.js b/src/hooks/useForceSync.js index 32445e8..28986d6 100644 --- a/src/hooks/useForceSync.js +++ b/src/hooks/useForceSync.js @@ -1,10 +1,7 @@ import {useState} from 'react'; -import {useNavigate} from 'react-router-dom'; import {useTranslation} from 'react-i18next'; import {toast} from 'sonner'; -import useSessionExpired from './useSessionExpired.js'; import {apiFetch} from '../utils/api.js'; -import {hasSession} from '../utils/auth.js'; const RELOAD_DELAY_MS = 4000; @@ -14,24 +11,15 @@ const RELOAD_DELAY_MS = 4000; */ export default function useForceSync(onStarted) { const {t} = useTranslation(); - const navigate = useNavigate(); - const expireSession = useSessionExpired(); const [busy, setBusy] = useState(false); const trigger = async () => { - if (!hasSession()) { - navigate('/login'); - return; - } setBusy(true); try { const response = await apiFetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/sync`, { method: 'POST', }); - if (response.status === 401) { - expireSession(); - return; - } + if (response.status === 401) return; if (response.status === 429) { const json = await response.json().catch(() => ({})); toast.warning(t('LIBRARY.SYNC_COOLDOWN', {seconds: json.retryAfterSeconds ?? 60})); diff --git a/src/hooks/useLibrarySync.js b/src/hooks/useLibrarySync.js index e4c2d8e..db618bf 100644 --- a/src/hooks/useLibrarySync.js +++ b/src/hooks/useLibrarySync.js @@ -15,16 +15,13 @@ const INITIAL_SYNC_GRACE_MS = 15_000; * @param {boolean} opts.expectInitialSync - if true and no row yet, keep polling (covers the * gap between async sync dispatch and first DB write) * @param {() => void} opts.onComplete - called once per transition to OK/FAILED - * @param {() => void} [opts.onUnauthorized] - called on a 401; polling stops * @returns {{status: string|null, lastSyncedAt: string|null, isRunning: boolean, error: string|null}} */ -export default function useLibrarySync({enabled, expectInitialSync = false, onComplete, onUnauthorized}) { +export default function useLibrarySync({enabled, expectInitialSync = false, onComplete}) { const [state, setState] = useState({status: null, lastSyncedAt: null, error: null}); const previousRunningRef = useRef(null); const onCompleteRef = useRef(onComplete); onCompleteRef.current = onComplete; - const onUnauthorizedRef = useRef(onUnauthorized); - onUnauthorizedRef.current = onUnauthorized; useEffect(() => { if (!enabled) return undefined; @@ -42,10 +39,8 @@ export default function useLibrarySync({enabled, expectInitialSync = false, onCo ); if (cancelled) return; - if (response.status === 401) { - onUnauthorizedRef.current?.(); - return; - } + // The session is over; polling stops. + if (response.status === 401) return; if (!response.ok) { setState((s) => ({...s, error: 'sync-status fetch failed'})); return; diff --git a/src/hooks/useSaveAppearance.js b/src/hooks/useSaveAppearance.js index 8e23c62..6790d32 100644 --- a/src/hooks/useSaveAppearance.js +++ b/src/hooks/useSaveAppearance.js @@ -2,25 +2,19 @@ import {useCallback} from 'react'; import {useTranslation} from 'react-i18next'; import {toast} from 'sonner'; import {usePreferences} from '../context/PreferencesContext.jsx'; -import useSessionExpired from './useSessionExpired.js'; /** - * Returns `save(changes)`: changeAppearance plus the error messages and the session end on a 401. Resolves to - * true when saved. + * Returns `save(changes)`: changeAppearance plus the error messages. Resolves to true when saved. */ export default function useSaveAppearance() { const {t} = useTranslation(); const {changeAppearance} = usePreferences(); - const expireSession = useSessionExpired(); return useCallback(async (changes) => { try { const response = await changeAppearance(changes); if (!response) return false; - if (response.status === 401) { - expireSession(); - return false; - } + if (response.status === 401) return false; if (!response.ok) { toast.error(t('SETTINGS.ERROR_SAVE')); return false; @@ -31,5 +25,5 @@ export default function useSaveAppearance() { toast.error(t('INFO.ERROR.NETWORK_FAILURE')); return false; } - }, [changeAppearance, expireSession, t]); + }, [changeAppearance, t]); } diff --git a/src/hooks/useSaveAppearance.test.jsx b/src/hooks/useSaveAppearance.test.jsx index 840cd97..803d6e5 100644 --- a/src/hooks/useSaveAppearance.test.jsx +++ b/src/hooks/useSaveAppearance.test.jsx @@ -1,17 +1,21 @@ // @vitest-environment jsdom import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; import {act, cleanup, renderHook} from '@testing-library/react'; -import {MemoryRouter} from 'react-router-dom'; +import {MemoryRouter, Route, Routes} from 'react-router-dom'; import {toast} from 'sonner'; import {PreferencesProvider, usePreferences} from '../context/PreferencesContext.jsx'; import useSaveAppearance from './useSaveAppearance.js'; +import RequireSession from '../components/RequireSession.jsx'; import {DEFAULT_APPEARANCE} from '../utils/appearance.js'; vi.mock('react-i18next', () => import('../test/i18n-mock.js')); vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}})); +// RequireSession ends the session on a 401, as on the pages that use the hook. const wrapper = ({children}) => ( - {children} + + }> + ); const renderSave = () => renderHook(() => ({save: useSaveAppearance(), preferences: usePreferences()}), {wrapper}); diff --git a/src/hooks/useSaveResponse.js b/src/hooks/useSaveResponse.js index e520c87..1c98590 100644 --- a/src/hooks/useSaveResponse.js +++ b/src/hooks/useSaveResponse.js @@ -15,6 +15,7 @@ export default function useSaveResponse() { try { const response = await send(); if (response.status === 401) { + // Also on the public profile, which has no RequireSession. expireSession(); return false; } diff --git a/src/hooks/useSessionExpired.js b/src/hooks/useSessionExpired.js index dbe52bf..9c72f11 100644 --- a/src/hooks/useSessionExpired.js +++ b/src/hooks/useSessionExpired.js @@ -1,16 +1,17 @@ import {useCallback, useRef} from 'react'; import {useLocation, useNavigate} from 'react-router-dom'; import {useResetAppearance} from '../context/PreferencesContext.jsx'; +import {useCurrentUserActions} from '../context/CurrentUserContext.jsx'; import {clearSession} from '../utils/auth.js'; /** * Returns a function for a 401 answer: it removes the token and the user's look and opens the login page, which - * then says that the session expired and comes back to the current page after login. The function is as stable - * as `navigate`, so an effect that lists `navigate` can list it too. + * then says that the session expired and comes back to the current page after login. */ export default function useSessionExpired() { const navigate = useNavigate(); const resetAppearance = useResetAppearance(); + const {setUser} = useCurrentUserActions(); const location = useLocation(); const locationRef = useRef(location); locationRef.current = location; @@ -19,6 +20,7 @@ export default function useSessionExpired() { const {pathname, search} = locationRef.current; clearSession(); resetAppearance(); + setUser(null); navigate('/login', {replace: true, state: {sessionExpired: true, from: pathname + search}}); - }, [navigate, resetAppearance]); + }, [navigate, resetAppearance, setUser]); } diff --git a/src/hooks/useStartImport.js b/src/hooks/useStartImport.js index b4f8221..0cfc062 100644 --- a/src/hooks/useStartImport.js +++ b/src/hooks/useStartImport.js @@ -1,23 +1,20 @@ import {useCallback} from 'react'; import {useTranslation} from 'react-i18next'; import {toast} from 'sonner'; -import useSessionExpired from './useSessionExpired.js'; import {createImport} from '../utils/imports.js'; /** - * Returns `start(source, rows)`: it creates an import in Core and resolves to its id, or shows why not (or ends the - * session on a 401) and resolves to null. + * Returns `start(source, rows)`: it creates an import in Core and resolves to its id, or shows why not and + * resolves to null. */ export default function useStartImport() { const {t} = useTranslation(); - const expireSession = useSessionExpired(); return useCallback(async (source, rows) => { try { const response = await createImport(source, rows); - if (response.status === 401) { - expireSession(); - } else if (response.status === 409) { + if (response.status === 401) return null; + if (response.status === 409) { toast.error(t('IMPORT.RUNNING')); } else if (response.status === 503) { toast.error(t('MANUAL_GAME.UNAVAILABLE')); @@ -31,5 +28,5 @@ export default function useStartImport() { toast.error(t('INFO.ERROR.NETWORK_FAILURE')); } return null; - }, [expireSession, t]); + }, [t]); } diff --git a/src/hooks/useUserStats.js b/src/hooks/useUserStats.js index 033dc9f..23587b5 100644 --- a/src/hooks/useUserStats.js +++ b/src/hooks/useUserStats.js @@ -4,10 +4,8 @@ import {statsUrl} from '../utils/stats.js'; /** * GET /api/library/stats for the given groups (none = all). stats is null while loading and after an error. - * - * @param onUnauthorized called on a 401, e.g. useSessionExpired */ -export default function useUserStats(groups, onUnauthorized, enabled = true) { +export default function useUserStats(groups, enabled = true) { const [stats, setStats] = useState(null); const [failed, setFailed] = useState(false); const key = groups.join(','); @@ -16,10 +14,7 @@ export default function useUserStats(groups, onUnauthorized, enabled = true) { setFailed(false); try { const response = await apiFetch(statsUrl(key)); - if (response.status === 401) { - onUnauthorized(); - return; - } + if (response.status === 401) return; if (!response.ok) { setFailed(true); return; @@ -29,7 +24,7 @@ export default function useUserStats(groups, onUnauthorized, enabled = true) { console.error('Stats load error:', err); setFailed(true); } - }, [key, onUnauthorized]); + }, [key]); useEffect(() => { if (enabled) load(); diff --git a/src/main.jsx b/src/main.jsx index 27c38a3..b48b415 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -3,6 +3,7 @@ import {createRoot} from 'react-dom/client'; import {BrowserRouter} from 'react-router-dom'; import App from './App.jsx'; import {PreferencesProvider} from './context/PreferencesContext.jsx'; +import {CurrentUserProvider} from './context/CurrentUserContext.jsx'; import './i18n'; import './style.css'; @@ -10,7 +11,9 @@ createRoot(document.getElementById('root')).render( - + + + diff --git a/src/pages/AdminHintsPage.jsx b/src/pages/AdminHintsPage.jsx index 9511a1b..2ddd7fb 100644 --- a/src/pages/AdminHintsPage.jsx +++ b/src/pages/AdminHintsPage.jsx @@ -1,5 +1,4 @@ import {useCallback, useEffect, useState} from 'react'; -import {useNavigate} from 'react-router-dom'; import {useTranslation} from 'react-i18next'; import {Loader2, Send, Square} from 'lucide-react'; import {toast} from 'sonner'; @@ -12,8 +11,6 @@ import {Label} from '@/components/ui/label'; import {Textarea} from '@/components/ui/textarea'; import HintCard from '../components/HintCard.jsx'; import PageContainer from '../components/PageContainer.jsx'; -import useSessionExpired from '../hooks/useSessionExpired.js'; -import {hasSession} from '../utils/auth.js'; import {createHint, endHint, fetchAdminHints} from '../utils/hints.js'; import {MAX_HINT_LENGTH} from '../utils/limits.js'; @@ -35,8 +32,6 @@ const emptyForm = () => { */ export default function AdminHintsPage() { const {t, i18n} = useTranslation(); - const navigate = useNavigate(); - const expireSession = useSessionExpired(); const [hints, setHints] = useState([]); const [status, setStatus] = useState('loading'); const [form, setForm] = useState(emptyForm); @@ -45,10 +40,7 @@ export default function AdminHintsPage() { const load = useCallback(async () => { try { const response = await fetchAdminHints(); - if (response.status === 401) { - expireSession(); - return; - } + if (response.status === 401) return; if (response.status === 403) { setStatus('forbidden'); return; @@ -63,24 +55,17 @@ export default function AdminHintsPage() { console.error('Admin hints error:', err); setStatus('error'); } - }, [expireSession]); + }, []); useEffect(() => { - if (!hasSession()) { - navigate('/login'); - return; - } load(); - }, [load, navigate]); + }, [load]); const run = async (request, doneKey, onOk) => { setBusy(true); try { const response = await request(); - if (response.status === 401) { - expireSession(); - return; - } + if (response.status === 401) return; if (response.ok) { toast.success(t(doneKey)); onOk?.(); diff --git a/src/pages/AdminUsersPage.jsx b/src/pages/AdminUsersPage.jsx index 83f949f..6f351cd 100644 --- a/src/pages/AdminUsersPage.jsx +++ b/src/pages/AdminUsersPage.jsx @@ -1,5 +1,4 @@ import {useCallback, useEffect, useRef, useState} from 'react'; -import {useNavigate} from 'react-router-dom'; import {useTranslation} from 'react-i18next'; import {Ban, ChevronLeft, ChevronRight, Loader2, Search, ShieldCheck, Trash2} from 'lucide-react'; import {toast} from 'sonner'; @@ -19,9 +18,7 @@ import { import {Input} from '@/components/ui/input'; import {Label} from '@/components/ui/label'; import PageContainer from '../components/PageContainer.jsx'; -import useSessionExpired from '../hooks/useSessionExpired.js'; import {deleteUser, fetchUsers, setUserBlocked} from '../utils/admin.js'; -import {hasSession} from '../utils/auth.js'; const PAGE_SIZE = 20; const REFUSAL_KEY = { @@ -37,8 +34,6 @@ const formatDate = (value, locale) => (value ? new Date(value).toLocaleDateStrin */ export default function AdminUsersPage() { const {t, i18n} = useTranslation(); - const navigate = useNavigate(); - const expireSession = useSessionExpired(); const [query, setQuery] = useState(''); const [search, setSearch] = useState(''); const [page, setPage] = useState(0); @@ -56,10 +51,7 @@ export default function AdminUsersPage() { try { const response = await fetchUsers(search, page, PAGE_SIZE); if (current !== loadCount.current) return; - if (response.status === 401) { - expireSession(); - return; - } + if (response.status === 401) return; if (response.status === 403) { setStatus('forbidden'); return; @@ -76,15 +68,11 @@ export default function AdminUsersPage() { console.error('Admin users error:', err); setStatus('error'); } - }, [search, page, expireSession]); + }, [search, page]); useEffect(() => { - if (!hasSession()) { - navigate('/login'); - return; - } load(); - }, [load, navigate]); + }, [load]); const closeDialog = () => { setPending(null); @@ -96,10 +84,7 @@ export default function AdminUsersPage() { setBusy(true); try { const response = kind === 'delete' ? await deleteUser(user.id) : await setUserBlocked(user.id, !user.blocked); - if (response.status === 401) { - expireSession(); - return; - } + if (response.status === 401) return; if (response.ok) { toast.success(t(kind === 'delete' ? 'ADMIN.DONE_DELETE' : user.blocked ? 'ADMIN.DONE_UNBLOCK' : 'ADMIN.DONE_BLOCK')); closeDialog(); diff --git a/src/pages/Dashboard.jsx b/src/pages/Dashboard.jsx index c4da6d5..af81756 100644 --- a/src/pages/Dashboard.jsx +++ b/src/pages/Dashboard.jsx @@ -1,5 +1,5 @@ -import React, {useCallback, useEffect, useState} from 'react'; -import {Link, useNavigate} from 'react-router-dom'; +import {useCallback, useEffect, useState} from 'react'; +import {Link} from 'react-router-dom'; import {useTranslation} from 'react-i18next'; import {toast} from 'sonner'; import {ArrowRight, Hash, LayoutGrid, Link2, RefreshCw, Trophy} from 'lucide-react'; @@ -19,7 +19,6 @@ import SyncStatusBadge from '../components/SyncStatusBadge.jsx'; import useLibrarySync from '../hooks/useLibrarySync.js'; import useForceSync from '../hooks/useForceSync.js'; import {formatPlaytime} from '../utils/games.js'; -import useSessionExpired from '../hooks/useSessionExpired.js'; import PageContainer from '../components/PageContainer.jsx'; import HeroBanner from '../components/HeroBanner.jsx'; import SteamAvatar from '../components/SteamAvatar.jsx'; @@ -30,8 +29,8 @@ import TileGrid from '../components/TileGrid.jsx'; import statGroupTile from '../components/stats/statGroupTile.jsx'; import {DASHBOARD_TILES, DASHBOARD_TITLES, DEFAULT_DASHBOARD, normalizeLayout, saveTiles, statGroup} from '../utils/tiles.js'; import {usePreferences} from '../context/PreferencesContext.jsx'; -import {apiFetch} from '../utils/api.js'; -import {hasSession} from '../utils/auth.js'; +import {useCurrentUserContext} from '../context/CurrentUserContext.jsx'; +import {request} from '../utils/api.js'; // Games per tile size; the page loads the most for the large tile. const TOP_GAMES = {S: 3, M: 5, L: 10}; @@ -40,13 +39,15 @@ const PLAYING_GAMES = {S: 2, M: 4, L: 8}; const TOP_GAMES_URL = '/api/library/games?page=0&size=20&sort=playtimeMinutes%2Cdesc'; const PLAYING_URL = '/api/library/games?page=0&size=20&sort=playtimeMinutes%2Cdesc&status=PLAYING'; -const JSON_HEADERS = {'Content-Type': 'application/json'}; +// A card whose request fails or has no JSON body stays empty. +async function loadInto(path, set, pick = (data) => data) { + const {ok, data} = await request(`${import.meta.env.VITE_CORE_API_URL}${path}`); + if (ok && data != null) set(pick(data)); +} const Dashboard = () => { const {t} = useTranslation(); - const navigate = useNavigate(); - const expireSession = useSessionExpired(); - const [userData, setUserData] = useState(null); + const {user: userData, updateUser, reload: reloadUser} = useCurrentUserContext(); const [steamData, setSteamData] = useState({count: 0, hasSteamId: false}); const [topGames, setTopGames] = useState([]); const [playing, setPlaying] = useState([]); @@ -67,109 +68,26 @@ const Dashboard = () => { ])].sort(); // No groups would mean "all groups" to the stats endpoint. const hasStatGroups = statGroups.length > 0; - const stats = useUserStats(statGroups, expireSession, hasSession() && userData != null && hasStatGroups); + const stats = useUserStats(statGroups, userData != null && hasStatGroups); const reloadStats = stats.reload; - const fetchSteamCount = useCallback(async () => { - try { - const r = await apiFetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/games-count`, {headers: JSON_HEADERS}); - if (r.status === 401) { - expireSession(); - return; - } - if (r.ok) { - setSteamData(await r.json()); - } - } catch (err) { - console.error('Error fetching steam data:', err); - } - }, [expireSession]); - - const fetchTopGames = useCallback(async () => { - try { - const r = await apiFetch(`${import.meta.env.VITE_CORE_API_URL}${TOP_GAMES_URL}`, {headers: JSON_HEADERS}); - if (r.status === 401) { - expireSession(); - return; - } - if (r.ok) { - const page = await r.json(); - setTopGames((page.content ?? []).slice(0, TOP_GAMES.L)); - } - } catch (err) { - console.error('Error fetching top games:', err); - } - }, [expireSession]); - - const fetchPlaying = useCallback(async () => { - try { - const r = await apiFetch(`${import.meta.env.VITE_CORE_API_URL}${PLAYING_URL}`, {headers: JSON_HEADERS}); - if (r.status === 401) { - expireSession(); - return; - } - if (r.ok) { - const page = await r.json(); - setPlaying((page.content ?? []).slice(0, PLAYING_GAMES.L)); - } - } catch (err) { - console.error('Error fetching playing games:', err); - } - }, [expireSession]); + const fetchSteamCount = useCallback(() => loadInto('/api/library/games-count', setSteamData), []); + const fetchTopGames = useCallback(() => loadInto(TOP_GAMES_URL, setTopGames, (page) => (page.content ?? []).slice(0, TOP_GAMES.L)), []); + const fetchPlaying = useCallback(() => loadInto(PLAYING_URL, setPlaying, (page) => (page.content ?? []).slice(0, PLAYING_GAMES.L)), []); + const fetchActivity = useCallback(() => loadInto('/api/library/activity', setActivity), []); + const fetchAchievements = useCallback(() => loadInto('/api/library/achievements/summary', setAchievements), []); /** * @param excludeAppid the current suggestion, so the new pick differs from it - * @returns false when the request failed + * @returns false when the request failed; true on a 401, which ends the session without a "failed" message */ const fetchSummary = useCallback(async (excludeAppid) => { const query = excludeAppid ? `?exclude=${excludeAppid}` : ''; - try { - const r = await apiFetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/summary${query}`, {headers: JSON_HEADERS}); - if (r.status === 401) { - expireSession(); - return true; - } - if (r.ok) { - setSummary(await r.json()); - setSummaryFailed(false); - return true; - } - } catch (err) { - console.error('Error fetching library summary:', err); - } - setSummaryFailed(true); - return false; - }, [expireSession]); - - const fetchActivity = useCallback(async () => { - try { - const r = await apiFetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/activity`, {headers: JSON_HEADERS}); - if (r.status === 401) { - expireSession(); - return; - } - if (r.ok) { - setActivity(await r.json()); - } - } catch (err) { - console.error('Error fetching activity:', err); - } - }, [expireSession]); - - const fetchAchievements = useCallback(async () => { - try { - const r = await apiFetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/achievements/summary`, {headers: JSON_HEADERS}); - if (r.status === 401) { - expireSession(); - return; - } - if (r.ok) { - setAchievements(await r.json()); - } - } catch (err) { - console.error('Error fetching achievements:', err); - } - }, [expireSession]); + const {ok, status, data} = await request(`${import.meta.env.VITE_CORE_API_URL}/api/library/summary${query}`); + if (ok) setSummary(data); + setSummaryFailed(!ok && status !== 401); + return ok || status === 401; + }, []); const shuffleSuggestion = async () => { setShuffling(true); @@ -191,40 +109,21 @@ const Dashboard = () => { useEffect(() => { const fetchUserData = async () => { - if (!hasSession()) { - navigate('/login'); - return; - } - - try { - const profileResponse = await apiFetch(`${import.meta.env.VITE_API_URL}/api/users/me?v=1`, { - method: 'GET', - headers: JSON_HEADERS, - }); - - if (profileResponse.ok) { - const data = await profileResponse.json(); - setUserData(data); - - // Games added by hand need no Steam ID; history and achievements come only from Steam. - await Promise.all(data.steamId - ? [fetchSteamCount(), fetchTopGames(), fetchPlaying(), fetchSummary(), fetchActivity(), fetchAchievements()] - : [fetchSteamCount(), fetchTopGames(), fetchPlaying(), fetchSummary()]); - } else if (profileResponse.status === 401) { - expireSession(); - } else { - setError('INFO.ERROR.LOAD_PROFILE'); - } - } catch (err) { - console.error('Dashboard fetch error:', err); + const {ok, status, data} = await reloadUser(); + if (ok) { + // Games added by hand need no Steam ID; history and achievements come only from Steam. + await Promise.all([fetchSteamCount(), fetchTopGames(), fetchPlaying(), fetchSummary(), + ...(data.steamId ? [fetchActivity(), fetchAchievements()] : [])]); + } else if (status === 0) { setError('INFO.ERROR.NETWORK_FAILURE'); - } finally { - setIsLoading(false); + } else if (status !== 401) { + setError('INFO.ERROR.LOAD_PROFILE'); } + setIsLoading(false); }; fetchUserData(); - }, [navigate, expireSession, fetchSteamCount, fetchTopGames, fetchPlaying, fetchSummary, fetchActivity, fetchAchievements]); + }, [reloadUser, fetchSteamCount, fetchTopGames, fetchPlaying, fetchSummary, fetchActivity, fetchAchievements]); const hasSteamId = !!userData?.steamId; const showLibrary = hasSteamId || steamData.count > 0; @@ -237,7 +136,6 @@ const Dashboard = () => { enabled: hasSteamId, expectInitialSync: hasSteamId && steamData.count === 0, onComplete: reloadLibrary, - onUnauthorized: expireSession, }); const forceSync = useForceSync(reloadLibrary); @@ -259,7 +157,7 @@ const Dashboard = () => { // The default is saved as "never changed", so a later change of the default reaches this user too. const tiles = JSON.stringify(next) === JSON.stringify(DEFAULT_DASHBOARD) ? null : next; if (!await runSave(() => saveTiles('dashboard-tiles', tiles))) return; - setUserData((d) => ({...d, settings: {...d.settings, dashboardTiles: tiles}})); + updateUser((d) => ({...d, settings: {...d.settings, dashboardTiles: tiles}})); setEditing(false); }; diff --git a/src/pages/Dashboard.test.jsx b/src/pages/Dashboard.test.jsx index be3c9f3..bc3f612 100644 --- a/src/pages/Dashboard.test.jsx +++ b/src/pages/Dashboard.test.jsx @@ -2,8 +2,10 @@ import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react'; import {MemoryRouter, Route, Routes, useLocation} from 'react-router-dom'; +import RequireSession from '../components/RequireSession.jsx'; import Dashboard from './Dashboard.jsx'; import {PreferencesProvider} from '../context/PreferencesContext.jsx'; +import {CurrentUserProvider} from '../context/CurrentUserContext.jsx'; vi.mock('react-i18next', () => import('../test/i18n-mock.js')); vi.mock('../hooks/useLibrarySync.js', () => ({default: () => ({status: 'OK', lastSyncedAt: null, isRunning: false})})); @@ -33,12 +35,12 @@ describe('Dashboard', () => { globalThis.fetch = vi.fn(async (url) => url.includes('/api/users/me') ? ok({email: 'a@b.de', steamId: '76561198000000000'}) : unauthorized()); render( - + - }/> + }>}/> }/> - , + , ); await waitFor(() => expect(fetch.mock.calls.length).toBeGreaterThanOrEqual(5)); @@ -65,9 +67,9 @@ describe('Dashboard', () => { return ok({content: [], total: 0, byStatus: {}}); }); return render( - + - , + , ).container; }; @@ -150,4 +152,23 @@ describe('Dashboard', () => { await waitFor(() => expect(container.querySelector(`img[src="${avatar}"]`)).not.toBeNull()); expect(container.querySelector('img[src*="library_hero"]')).toBeNull(); }); + it('shows the page when a card answers 200 without a JSON body', async () => { + // No Steam ID, so no activity and achievements are asked. + globalThis.fetch = vi.fn(async (url) => { + if (url.includes('/api/users/me')) return ok({email: 'a@b.de', displayName: 'Lobo'}); + if (url.includes('/api/library/games-count')) return ok({count: 1, hasSteamId: true}); + if (url.includes('sort=playtimeMinutes') && !url.includes('status=')) { + return {ok: true, status: 200, json: async () => { throw new SyntaxError('Unexpected token <'); }}; + } + return ok({content: [], total: 0, byStatus: {}}); + }); + render( + + + , + ); + + expect(await screen.findByText(/DASHBOARD.HELLO/)).toBeTruthy(); + }); }); + diff --git a/src/pages/GameDetail.jsx b/src/pages/GameDetail.jsx index ed291dc..ebb43e3 100644 --- a/src/pages/GameDetail.jsx +++ b/src/pages/GameDetail.jsx @@ -22,7 +22,6 @@ import PlayingHint from '../components/PlayingHint.jsx'; import TimeToBeat from '../components/TimeToBeat.jsx'; import {formatPlaytime} from '../utils/games.js'; import useCurrentUser from '../hooks/useCurrentUser.js'; -import useSessionExpired from '../hooks/useSessionExpired.js'; import useSaveAppearance from '../hooks/useSaveAppearance.js'; import {usePreferences} from '../context/PreferencesContext.jsx'; import PageContainer from '../components/PageContainer.jsx'; @@ -32,7 +31,6 @@ import {ACHIEVEMENT_ICONS, reportBrokenImage} from '../utils/brokenImages.js'; import {canCorrect} from '../utils/corrections.js'; import {celebrationFor, celebrationLevel} from '../utils/confetti.js'; import {setGameHidden} from '../utils/hiddenGames.js'; -import {hasSession} from '../utils/auth.js'; import {isSteamKey} from '../utils/manualGames.js'; import {platformLabel} from '../utils/platforms.js'; @@ -65,7 +63,6 @@ export default function GameDetail() { const {appearance} = usePreferences(); const saveAppearance = useSaveAppearance(); const navigate = useNavigate(); - const expireSession = useSessionExpired(); const [data, setData] = useState(null); const [isLoading, setIsLoading] = useState(true); @@ -99,10 +96,6 @@ export default function GameDetail() { useEffect(() => { let cancelled = false; - if (!hasSession()) { - navigate('/login'); - return; - } setIsLoading(true); setError(null); (async () => { @@ -114,10 +107,7 @@ export default function GameDetail() { }, }); if (cancelled) return; - if (response.status === 401) { - expireSession(); - return; - } + if (response.status === 401) return; if (response.status === 404) { setError('GAME_DETAIL.NOT_FOUND'); return; @@ -140,7 +130,7 @@ export default function GameDetail() { return () => { cancelled = true; }; - }, [appid, navigate, expireSession, reloads]); + }, [appid, reloads]); const sortedAchievements = useMemo(() => { if (!data?.achievements) return []; @@ -166,10 +156,7 @@ export default function GameDetail() { setHiding(true); try { const response = await setGameHidden(data.appid, hidden); - if (response.status === 401) { - expireSession(); - return; - } + if (response.status === 401) return; if (!response.ok) { toast.error(t('GAME_DETAIL.HIDE_ERROR')); return; diff --git a/src/pages/GameDetail.reload.test.jsx b/src/pages/GameDetail.reload.test.jsx index 1c67308..412ce23 100644 --- a/src/pages/GameDetail.reload.test.jsx +++ b/src/pages/GameDetail.reload.test.jsx @@ -4,6 +4,7 @@ import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/reac import {MemoryRouter, Route, Routes} from 'react-router-dom'; import GameDetail from './GameDetail.jsx'; import {PreferencesProvider} from '../context/PreferencesContext.jsx'; +import {CurrentUserProvider} from '../context/CurrentUserContext.jsx'; vi.mock('react-i18next', () => import('../test/i18n-mock.js')); // Stands in for a saved correction, which loads the game again. @@ -31,9 +32,9 @@ afterEach(() => { it('keeps unsaved notes while the game loads again after a correction', async () => { render( - + }/> - , + , ); fireEvent.change(await screen.findByLabelText('BACKLOG.NOTES_LABEL'), {target: {value: 'my draft'}}); diff --git a/src/pages/GameDetail.test.jsx b/src/pages/GameDetail.test.jsx index c84b76b..d2c96bf 100644 --- a/src/pages/GameDetail.test.jsx +++ b/src/pages/GameDetail.test.jsx @@ -4,6 +4,7 @@ import {act, cleanup, fireEvent, render, screen, waitFor} from '@testing-library import {MemoryRouter, Route, Routes} from 'react-router-dom'; import GameDetail from './GameDetail.jsx'; import {PreferencesProvider} from '../context/PreferencesContext.jsx'; +import {CurrentUserProvider} from '../context/CurrentUserContext.jsx'; import {resetBrokenImageReports} from '../utils/brokenImages.js'; import {toast} from 'sonner'; @@ -16,12 +17,12 @@ const game = { }; const renderDetail = (appid = 220) => render( - + }/> library page

}/>
-
, +
, ); beforeEach(() => { diff --git a/src/pages/GameLibrary.jsx b/src/pages/GameLibrary.jsx index b1b3b59..1498f2b 100644 --- a/src/pages/GameLibrary.jsx +++ b/src/pages/GameLibrary.jsx @@ -1,10 +1,9 @@ import {useCallback, useEffect, useState} from 'react'; -import {Link, useNavigate, useSearchParams} from 'react-router-dom'; +import {Link, useSearchParams} from 'react-router-dom'; import {useTranslation} from 'react-i18next'; import useLibrarySync from '../hooks/useLibrarySync.js'; import SyncStatusBadge from '../components/SyncStatusBadge.jsx'; import useForceSync from '../hooks/useForceSync.js'; -import useSessionExpired from '../hooks/useSessionExpired.js'; import {formatPlaytime} from '../utils/games.js'; import {libraryQuery, parseStatus} from '../utils/backlog.js'; import {GAME_STATUSES, MAX_LIBRARY_SEARCH_LENGTH} from '../utils/limits.js'; @@ -22,7 +21,6 @@ import LibraryTabs from '../components/LibraryTabs.jsx'; import GameStatusBadge from '../components/GameStatusBadge.jsx'; import PageContainer from '../components/PageContainer.jsx'; import {apiFetch} from '../utils/api.js'; -import {hasSession} from '../utils/auth.js'; import {platformLabel} from '../utils/platforms.js'; const PAGE_SIZES = [20, 40, 80]; @@ -42,8 +40,6 @@ const SORT_OPTIONS = [ export default function GameLibrary() { const {t} = useTranslation(); - const navigate = useNavigate(); - const expireSession = useSessionExpired(); const [searchParams, setSearchParams] = useSearchParams(); const page = Math.max(0, parseInt(searchParams.get('page') ?? '0', 10) || 0); @@ -70,10 +66,6 @@ export default function GameLibrary() { const [error, setError] = useState(null); const load = useCallback(async () => { - if (!hasSession()) { - navigate('/login'); - return; - } setIsLoading(true); setError(null); try { @@ -84,10 +76,7 @@ export default function GameLibrary() { }, }); - if (response.status === 401) { - expireSession(); - return; - } + if (response.status === 401) return; if (!response.ok) { setError('LIBRARY.ERROR_LOAD'); @@ -102,14 +91,13 @@ export default function GameLibrary() { } finally { setIsLoading(false); } - }, [navigate, expireSession, page, size, sort, listStatus, q, listShortOnly, hiddenOnly]); + }, [page, size, sort, listStatus, q, listShortOnly, hiddenOnly]); useEffect(() => { load(); }, [load]); useEffect(() => { - if (!hasSession()) return undefined; let cancelled = false; apiFetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/games-count`) .then((response) => (response.ok ? response.json() : null)) @@ -127,26 +115,25 @@ export default function GameLibrary() { enabled: hasSteamId, expectInitialSync, onComplete: load, - onUnauthorized: expireSession, }); - const updateParams = (next) => { + const updateParams = useCallback((next) => { const params = new URLSearchParams(searchParams); Object.entries(next).forEach(([k, v]) => (v === null || v === '' ? params.delete(k) : params.set(k, String(v)))); setSearchParams(params); - }; + }, [searchParams, setSearchParams]); // Back/forward navigation changes q without typing. Keeps a trailing space the user is still typing. useEffect(() => { setSearch((current) => (current.trim() === q ? current : q)); }, [q]); - // searchParams is a dependency, so a status click during the delay restarts the timer with fresh params. + // updateParams changes with searchParams, so a status click during the delay restarts the timer with fresh params. useEffect(() => { if (search.trim() === q) return undefined; const timer = setTimeout(() => updateParams({q: search.trim(), page: 0}), SEARCH_DEBOUNCE_MS); return () => clearTimeout(timer); - }, [search, q, searchParams]); + }, [search, q, updateParams]); const onStatusChange = (value) => { updateParams({status: value, hidden: null, page: 0}); diff --git a/src/pages/ImportPage.jsx b/src/pages/ImportPage.jsx index 693fb04..0c62863 100644 --- a/src/pages/ImportPage.jsx +++ b/src/pages/ImportPage.jsx @@ -1,5 +1,5 @@ import {memo, useCallback, useEffect, useRef, useState} from 'react'; -import {Link, useNavigate, useSearchParams} from 'react-router-dom'; +import {Link, useSearchParams} from 'react-router-dom'; import {useTranslation} from 'react-i18next'; import {toast} from 'sonner'; import {ArrowLeft, CheckCircle2, Circle, Loader2, RotateCcw, Search, Upload, XCircle} from 'lucide-react'; @@ -12,15 +12,14 @@ import {Textarea} from '@/components/ui/textarea'; import {cn} from '@/lib/utils'; import GameSearch from '../components/GameSearch.jsx'; import PageContainer from '../components/PageContainer.jsx'; -import useSessionExpired from '../hooks/useSessionExpired.js'; import useStartImport from '../hooks/useStartImport.js'; -import {hasSession} from '../utils/auth.js'; import { checkUndo, getImport, listImports, parseCsv, parseTextList, retryImport, saveImport, undoImport, } from '../utils/imports.js'; import {GAME_STATUSES, MAX_IMPORT_ROWS, MAX_PLATFORM_LENGTH} from '../utils/limits.js'; import {hoursToMinutes, toHours} from '../utils/manualGames.js'; -import {loadFavoritePlatform, preselectPlatform} from '../utils/platforms.js'; +import {preselectPlatform} from '../utils/platforms.js'; +import {useCurrentUserContext} from '../context/CurrentUserContext.jsx'; const POLL_MS = 2000; // Statuses a worker still has: the page polls while it shows one. @@ -50,8 +49,6 @@ const SELECT_CLASS = 'h-9 w-full min-w-0 rounded-md border border-input bg-trans */ export default function ImportPage() { const {t} = useTranslation(); - const navigate = useNavigate(); - const expireSession = useSessionExpired(); const [searchParams, setSearchParams] = useSearchParams(); const [imports, setImports] = useState(null); const id = searchParams.get('id'); @@ -59,24 +56,17 @@ export default function ImportPage() { const loadImports = useCallback(async () => { try { const response = await listImports(); - if (response.status === 401) { - expireSession(); - return; - } + if (response.status === 401) return; setImports(response.ok ? (await response.json()).imports : []); } catch (err) { console.error('Import list error:', err); setImports([]); } - }, [expireSession]); + }, []); useEffect(() => { - if (!hasSession()) { - navigate('/login'); - return; - } loadImports(); - }, [navigate, loadImports]); + }, [loadImports]); // Without ?id=, an import a worker still has is shown first; one to review or failed is in the history. const open = imports?.find((entry) => RUNNING.includes(entry.status)); @@ -103,14 +93,14 @@ export default function ImportPage() { {imports === null ? ( ) : shownId ? ( - + ) : ( { show(created); loadImports(); }}/> )} - {imports?.length > 0 && } + {imports?.length > 0 && } ); } @@ -176,7 +166,7 @@ function ImportInput({onCreated}) { } // Polls while a worker has the import. -function ImportView({id, onDone, onChanged, expireSession}) { +function ImportView({id, onDone, onChanged}) { const {t} = useTranslation(); const [data, setData] = useState(null); const [failed, setFailed] = useState(false); @@ -185,11 +175,8 @@ function ImportView({id, onDone, onChanged, expireSession}) { const reload = () => setVersion((current) => current + 1); const lastStatus = useRef(null); // undefined while loading: the review preselects with it, so it waits. - const [favorite, setFavorite] = useState(undefined); - - useEffect(() => { - loadFavoritePlatform().then(setFavorite); - }, []); + const {user} = useCurrentUserContext(); + const favorite = user === undefined ? undefined : user?.settings?.favoritePlatform ?? null; useEffect(() => { let timer; @@ -198,10 +185,7 @@ function ImportView({id, onDone, onChanged, expireSession}) { try { const response = await getImport(id); if (cancelled) return; - if (response.status === 401) { - expireSession(); - return; - } + if (response.status === 401) return; // A 4xx ends the polling; a 5xx while Core restarts is asked again. if (response.status >= 400 && response.status < 500) { setFailed(true); @@ -228,7 +212,7 @@ function ImportView({id, onDone, onChanged, expireSession}) { cancelled = true; clearTimeout(timer); }; - }, [id, expireSession, version, onChanged]); + }, [id, version, onChanged]); if (failed) return

{t('IMPORT.LOAD_FAILED')}

; if (!data || favorite === undefined) return ; @@ -236,9 +220,9 @@ function ImportView({id, onDone, onChanged, expireSession}) {
{data.status === 'READY' ? ( - + ) : ( - + )}
); @@ -271,7 +255,7 @@ function ImportSteps({data}) { } // What the current step does, with its numbers; Retry for a FAILED import. -function ImportProgress({data, onRetried, onDone, expireSession}) { +function ImportProgress({data, onRetried, onDone}) { const {t} = useTranslation(); const [busy, setBusy] = useState(false); const step = stepOf(data); @@ -281,9 +265,8 @@ function ImportProgress({data, onRetried, onDone, expireSession}) { setBusy(true); try { const response = await retryImport(data.id); - if (response.status === 401) { - expireSession(); - } else if (response.status === 409) { + if (response.status === 401) return; + if (response.status === 409) { toast.error(t('IMPORT.RUNNING')); } else if (!response.ok) { toast.error(t('IMPORT.RETRY_FAILED')); @@ -356,7 +339,7 @@ export const initialChoice = (row, favorite) => { }; }; -function Review({data, favorite, onSaved, onDone, expireSession}) { +function Review({data, favorite, onSaved, onDone}) { const {t} = useTranslation(); const [choices, setChoices] = useState(() => data.rows.map((row) => initialChoice(row, favorite))); const [busy, setBusy] = useState(false); @@ -387,10 +370,7 @@ function Review({data, favorite, onSaved, onDone, expireSession}) { lineNo: row.lineNo, igdbId: choice.igdbId, platform: choice.platform.trim(), status: choice.status, playtimeMinutes: hoursToMinutes(choice.hours), }))); - if (response.status === 401) { - expireSession(); - return; - } + if (response.status === 401) return; if (!response.ok) { toast.error(t('IMPORT.SAVE_FAILED')); return; @@ -491,7 +471,7 @@ const ReviewRow = memo(function ReviewRow({index, row, choice, onChange: changeL ); }); -function History({imports, onOpen, onChanged, expireSession}) { +function History({imports, onOpen, onChanged}) { const {t, i18n} = useTranslation(); const [confirm, setConfirm] = useState(null); const [busy, setBusy] = useState(false); @@ -500,10 +480,7 @@ function History({imports, onOpen, onChanged, expireSession}) { setBusy(true); try { const response = await call(); - if (response.status === 401) { - expireSession(); - return; - } + if (response.status === 401) return; if (!response.ok) { toast.error(t('IMPORT.UNDO_FAILED')); return; diff --git a/src/pages/ImportPage.test.jsx b/src/pages/ImportPage.test.jsx index 021a835..959c89d 100644 --- a/src/pages/ImportPage.test.jsx +++ b/src/pages/ImportPage.test.jsx @@ -5,11 +5,12 @@ import {MemoryRouter, Route, Routes} from 'react-router-dom'; import {toast} from 'sonner'; import ImportPage, {initialChoice, stepOf} from './ImportPage.jsx'; import {PreferencesProvider} from '../context/PreferencesContext.jsx'; +import {CurrentUserProvider} from '../context/CurrentUserContext.jsx'; +import {fetchRoutes, json} from '../test/fetchRoutes.js'; vi.mock('react-i18next', () => import('../test/i18n-mock.js')); vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}})); -const json = (status, body) => ({ok: status < 400, status, json: async () => body}); const hades = {igdbId: 1, name: 'Hades', year: 2020, imageUrl: null, platforms: ['PC', 'Switch'], inLibrary: false}; const hades2 = {igdbId: 2, name: 'Hades II', year: 2025, imageUrl: null, platforms: ['PC'], inLibrary: false}; @@ -25,19 +26,20 @@ const ready = { ], }; -// routes: 'METHOD path after /imports' -> response or (init) => response; any other URL (Auth) answers the favorite +// routes: 'METHOD path after /imports' -> response or (init) => response; Auth answers the favorite function renderPage(routes, entry = '/import') { - globalThis.fetch = vi.fn(async (url, init) => { - if (!url.includes('/api/library/imports')) return json(200, {settings: {favoritePlatform: 'Switch'}}); - const path = new URL(url, 'http://x').pathname.replace(/.*\/api\/library\/imports/, ''); - const answer = routes[`${init?.method ?? 'GET'} ${path}`]; - if (!answer) throw new Error(`no route ${init?.method ?? 'GET'} ${path}`); - return typeof answer === 'function' ? answer(init) : answer; + fetchRoutes({ + 'GET /api/users/me': json(200, {settings: {favoritePlatform: 'Switch'}}), + ...Object.fromEntries(Object.entries(routes).map(([key, answer]) => { + const [method, path] = key.split(' '); + return [`${method} /api/library/imports${path}`, + typeof answer === 'function' ? (url, init) => answer(init) : answer]; + })), }); render( - }/> + }/> login

}/>
, @@ -250,3 +252,24 @@ describe('ImportPage', () => { expect(await screen.findByText('IMPORT.MATCHING', {}, {timeout: 4000})).toBeTruthy(); }, 10000); }); + +describe('review findings', () => { + it('shows the review when Auth cannot be reached for the favorite', async () => { + vi.spyOn(console, 'error').mockImplementation(() => undefined); + fetchRoutes({ + 'GET /api/users/me': () => Promise.reject(new TypeError('Failed to fetch')), + 'GET /api/library/imports': json(200, {imports: []}), + 'GET /api/library/imports/5': json(200, ready), + }); + render( + + + }/> + + , + ); + + expect(await screen.findByLabelText('IMPORT.LINE: Hades', {}, {timeout: 2000})).toBeTruthy(); + }); +}); + diff --git a/src/pages/LoginPage.jsx b/src/pages/LoginPage.jsx index e8c0162..9589938 100644 --- a/src/pages/LoginPage.jsx +++ b/src/pages/LoginPage.jsx @@ -10,6 +10,7 @@ import AuthLayout from '../components/AuthLayout.jsx'; import FeedbackAlert from '../components/FeedbackAlert.jsx'; import {hasSession, markSession, safeReturnPath} from '../utils/auth.js'; import {clientFetch} from '../utils/api.js'; +import {useCurrentUserActions} from '../context/CurrentUserContext.jsx'; const LOGIN_ERROR_KEY = { INVALID_CREDENTIALS: 'LOGIN.ERROR_INVALID_CREDENTIALS', @@ -20,6 +21,7 @@ const LOGIN_ERROR_KEY = { export default function LoginPage() { const {t} = useTranslation(); const navigate = useNavigate(); + const {reload: reloadUser} = useCurrentUserActions(); const {state} = useLocation(); const target = safeReturnPath(state?.from) ?? '/dashboard'; @@ -54,6 +56,7 @@ export default function LoginPage() { if (response.ok) { markSession(); + reloadUser(); navigate(target, {replace: true}); } else { const minutes = Math.ceil(Number(response.headers.get('Retry-After') ?? 900) / 60); diff --git a/src/pages/PlayNextPage.jsx b/src/pages/PlayNextPage.jsx index eab3d6f..7572368 100644 --- a/src/pages/PlayNextPage.jsx +++ b/src/pages/PlayNextPage.jsx @@ -1,5 +1,5 @@ import {useCallback, useEffect, useRef, useState} from 'react'; -import {Link, useNavigate} from 'react-router-dom'; +import {Link} from 'react-router-dom'; import {useTranslation} from 'react-i18next'; import {toast} from 'sonner'; import {Check, Loader2, Play, Shuffle, Timer} from 'lucide-react'; @@ -10,11 +10,9 @@ import {Card} from '@/components/ui/card'; import {Skeleton} from '@/components/ui/skeleton'; import GameCover from '../components/GameCover.jsx'; import PageContainer from '../components/PageContainer.jsx'; -import useSessionExpired from '../hooks/useSessionExpired.js'; import {saveGameStatus} from '../utils/backlog.js'; import {formatPlaytime} from '../utils/games.js'; import {apiFetch} from '../utils/api.js'; -import {hasSession} from '../utils/auth.js'; // maxMinutes is IGDB's normal time to beat; a limit leaves out games without IGDB data. const BUDGETS = [ @@ -40,8 +38,6 @@ const pickerQuery = (maxMinutes, mood, exclude) => { export default function PlayNextPage() { const {t} = useTranslation(); - const navigate = useNavigate(); - const expireSession = useSessionExpired(); const [budget, setBudget] = useState(BUDGETS[3]); const [mood, setMood] = useState('ANY'); const [games, setGames] = useState(null); @@ -52,10 +48,6 @@ export default function PlayNextPage() { const latestRequest = useRef(0); const load = useCallback(async (maxMinutes, moodKey, exclude) => { - if (!hasSession()) { - navigate('/login'); - return; - } const request = ++latestRequest.current; setLoading(true); setError(null); @@ -63,10 +55,7 @@ export default function PlayNextPage() { const response = await apiFetch( `${import.meta.env.VITE_CORE_API_URL}/api/library/picker?${pickerQuery(maxMinutes, moodKey, exclude)}`); if (request !== latestRequest.current) return; - if (response.status === 401) { - expireSession(); - return; - } + if (response.status === 401) return; if (!response.ok) { setError('PLAY_NEXT.ERROR_LOAD'); return; @@ -82,7 +71,7 @@ export default function PlayNextPage() { if (request === latestRequest.current) setLoading(false); } // No `t` here: a language change gives a new `t`, and a reload would replace the random picks. - }, [navigate, expireSession]); + }, []); useEffect(() => { load(budget.maxMinutes, mood, []); @@ -161,7 +150,7 @@ export default function PlayNextPage() { {games?.length > 0 && (
- {games.map((game) => )} + {games.map((game) => )}
)} @@ -197,7 +186,7 @@ const emptyKey = (budget, mood) => { return budget.maxMinutes ? 'PLAY_NEXT.EMPTY_BUDGET' : 'PLAY_NEXT.EMPTY'; }; -function PickCard({game, expireSession}) { +function PickCard({game}) { const {t} = useTranslation(); const [busy, setBusy] = useState(false); const [started, setStarted] = useState(false); @@ -206,10 +195,7 @@ function PickCard({game, expireSession}) { setBusy(true); try { const response = await saveGameStatus(game.appid, 'PLAYING'); - if (response.status === 401) { - expireSession(); - return; - } + if (response.status === 401) return; if (!response.ok) { toast.error(t('BACKLOG.SAVE_FAILED')); return; diff --git a/src/pages/PublicProfilePage.test.jsx b/src/pages/PublicProfilePage.test.jsx index 03bb883..660c679 100644 --- a/src/pages/PublicProfilePage.test.jsx +++ b/src/pages/PublicProfilePage.test.jsx @@ -4,6 +4,7 @@ import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/reac import {MemoryRouter, Route, Routes} from 'react-router-dom'; import PublicProfilePage from './PublicProfilePage.jsx'; import {PreferencesProvider} from '../context/PreferencesContext.jsx'; +import {CurrentUserProvider} from '../context/CurrentUserContext.jsx'; vi.mock('react-i18next', () => import('../test/i18n-mock.js')); @@ -27,9 +28,9 @@ function mockBackends({profileStatus = 200, libraryStatus = 200, style, answer = } const renderPage = (name = 'Lobo') => render( - + }/> - , + , ); describe('PublicProfilePage', () => { diff --git a/src/pages/SettingsPage.jsx b/src/pages/SettingsPage.jsx index 8609741..5df750d 100644 --- a/src/pages/SettingsPage.jsx +++ b/src/pages/SettingsPage.jsx @@ -1,5 +1,5 @@ import {useEffect, useRef, useState} from 'react'; -import {useNavigate, useSearchParams} from 'react-router-dom'; +import {useSearchParams} from 'react-router-dom'; import {useTranslation} from 'react-i18next'; import {useForm} from 'react-hook-form'; import {zodResolver} from '@hookform/resolvers/zod'; @@ -20,7 +20,7 @@ import FormField from '../components/FormField.jsx'; import SteamAccountCard from '../components/SteamAccountCard.jsx'; import {settingsSchema} from '../utils/validation.js'; import {usePreferences} from '../context/PreferencesContext.jsx'; -import useSessionExpired from '../hooks/useSessionExpired.js'; +import {useCurrentUserContext} from '../context/CurrentUserContext.jsx'; import useSaveAppearance from '../hooks/useSaveAppearance.js'; import {AccentPicker, CornersPicker} from '../components/StylePickers.jsx'; import ProfileStyleSettings from '../components/ProfileStyleSettings.jsx'; @@ -28,8 +28,8 @@ import {normalizeProfileStyle} from '../utils/profileStyle.js'; import useSaveProfileStyle from '../hooks/useSaveProfileStyle.js'; import useSaveResponse from '../hooks/useSaveResponse.js'; import {coverUrl} from '../utils/games.js'; -import {apiFetch, refreshSession} from '../utils/api.js'; -import {hasSession, markSession} from '../utils/auth.js'; +import {refreshSession} from '../utils/api.js'; +import {markSession} from '../utils/auth.js'; import {FAVORITE_PLATFORMS, saveFavoritePlatform} from '../utils/platforms.js'; import {LANGUAGES, saveSettings} from '../utils/settings.js'; import {MAX_DISPLAY_NAME_LENGTH} from '../utils/limits.js'; @@ -60,14 +60,12 @@ const formatDate = (value, locale) => { export default function SettingsPage() { const {t, i18n} = useTranslation(); - const navigate = useNavigate(); - const expireSession = useSessionExpired(); const {isDarkMode, setDarkMode, setLanguage, appearance} = usePreferences(); const saveAppearance = useSaveAppearance(); const saveStyle = useSaveProfileStyle(); const runSave = useSaveResponse(); - const [profile, setProfile] = useState(null); + const {user: profile, updateUser: updateProfile, reload: reloadUser} = useCurrentUserContext(); const [isLoading, setIsLoading] = useState(true); const [loadError, setLoadError] = useState(null); const [savingProfileStyle, setSavingProfileStyle] = useState(false); @@ -99,62 +97,26 @@ export default function SettingsPage() { }, [steamOutcome, setSearchParams, t]); useEffect(() => { - if (!hasSession()) { - navigate('/login'); - return; - } - const load = async () => { - try { - if (steamOutcome === 'linked') { - // Refresh reads the new Steam ID from the database, so Core gets it in the token at once. - await refreshSession().catch(() => false); - markSession(); - } - const response = await apiFetch(`${import.meta.env.VITE_API_URL}/api/users/me?v=1`, { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - }, - }); - - if (response.status === 401) { - expireSession(); - return; - } - - if (!response.ok) { - setLoadError('INFO.ERROR.LOAD_PROFILE'); - return; - } - - const data = await response.json(); - setProfile(data); - reset({displayName: data.displayName ?? ''}); - } catch (err) { - console.error('Settings load error:', err); - setLoadError('INFO.ERROR.NETWORK_FAILURE'); - } finally { - setIsLoading(false); + if (steamOutcome === 'linked') { + // Refresh reads the new Steam ID from the database, so Core gets it in the token at once. + await refreshSession().catch(() => false); + markSession(); } + const {ok, status, data} = await reloadUser(); + if (ok) reset({displayName: data.displayName ?? ''}); + else if (status !== 401) setLoadError(status === 0 ? 'INFO.ERROR.NETWORK_FAILURE' : 'INFO.ERROR.LOAD_PROFILE'); + setIsLoading(false); }; load(); - }, [navigate, expireSession, reset, steamOutcome]); + }, [reloadUser, reset, steamOutcome]); const persistSettings = async (send) => { - if (!hasSession()) { - navigate('/login'); - return {ok: false}; - } - try { const response = await send(); - if (response.status === 401) { - expireSession(); - return {ok: false}; - } + if (response.status === 401) return {ok: false}; if (!response.ok) { const data = await response.json().catch(() => ({})); @@ -185,7 +147,7 @@ export default function SettingsPage() { const handleFavoritePlatformChange = async (value) => { const platform = value === NO_FAVORITE ? null : value; if (await runSave(() => saveFavoritePlatform(platform))) { - setProfile((p) => ({...p, settings: {...p.settings, favoritePlatform: platform}})); + updateProfile((p) => ({...p, settings: {...p.settings, favoritePlatform: platform}})); toast.success(t('SETTINGS.SAVED')); } }; @@ -194,14 +156,14 @@ export default function SettingsPage() { const next = !profile?.settings?.publicProfile; const result = await persistSettings(() => saveSettings({publicProfile: next})); if (result.ok) { - setProfile((p) => ({...p, settings: {...p.settings, publicProfile: next}})); + updateProfile((p) => ({...p, settings: {...p.settings, publicProfile: next}})); } }; // Shown at once; the pickers wait for the answer, so the saves reach Auth one after the other. const handleProfileStyleChange = async (changes) => { const previous = normalizeProfileStyle(profile?.settings?.profileStyle); - const showProfileStyle = (next) => setProfile((p) => ({...p, settings: {...p.settings, profileStyle: next}})); + const showProfileStyle = (next) => updateProfile((p) => ({...p, settings: {...p.settings, profileStyle: next}})); showProfileStyle({...previous, ...changes}); setSavingProfileStyle(true); showProfileStyle(await saveStyle(changes, previous) ?? previous); @@ -222,9 +184,7 @@ export default function SettingsPage() { const publicProfile = !!profile?.settings?.publicProfile && !!values.displayName; const result = await persistSettings(() => saveSettings({displayName: values.displayName, publicProfile})); if (result.ok) { - setProfile((p) => (p ? { - ...p, displayName: values.displayName, settings: {...p.settings, publicProfile}, - } : p)); + updateProfile((p) => ({...p, displayName: values.displayName, settings: {...p.settings, publicProfile}})); } }; @@ -382,7 +342,7 @@ export default function SettingsPage() { setProfile((p) => ({...p, steamId: null, steamIdVerified: false}))}/> + onUnlinked={() => updateProfile((p) => ({...p, steamId: null, steamIdVerified: false}))}/> diff --git a/src/pages/SettingsPage.steam.test.jsx b/src/pages/SettingsPage.steam.test.jsx index df531ea..bdbe572 100644 --- a/src/pages/SettingsPage.steam.test.jsx +++ b/src/pages/SettingsPage.steam.test.jsx @@ -5,6 +5,7 @@ import {MemoryRouter} from 'react-router-dom'; import {toast} from 'sonner'; import SettingsPage from './SettingsPage.jsx'; import {PreferencesProvider} from '../context/PreferencesContext.jsx'; +import {CurrentUserProvider} from '../context/CurrentUserContext.jsx'; vi.mock('react-i18next', async () => { const mock = await import('../test/i18n-mock.js'); @@ -32,7 +33,7 @@ function renderPage(me, {redirectUrl = STEAM_URL, entry = '/settings'} = {}) { } return {ok: true, status: 200, json: async () => me}; }); - render(); + render(); } describe('SettingsPage Steam account', () => { diff --git a/src/pages/SettingsPage.steamToast.test.jsx b/src/pages/SettingsPage.steamToast.test.jsx index de129d4..77e2801 100644 --- a/src/pages/SettingsPage.steamToast.test.jsx +++ b/src/pages/SettingsPage.steamToast.test.jsx @@ -5,6 +5,7 @@ import {MemoryRouter} from 'react-router-dom'; import {toast} from 'sonner'; import SettingsPage from './SettingsPage.jsx'; import {PreferencesProvider} from '../context/PreferencesContext.jsx'; +import {CurrentUserProvider} from '../context/CurrentUserContext.jsx'; // Like react-i18next: a language change re-renders every subscriber with a fresh `t`. vi.mock('react-i18next', async () => { @@ -44,7 +45,7 @@ describe('SettingsPage Steam toast', () => { }); it('shows the linked toast once, also after a language change', async () => { - render(); + render(); await screen.findByText('SETTINGS.STEAM_LINK.VERIFIED'); expect(toast.success).toHaveBeenCalledTimes(1); diff --git a/src/pages/SettingsPage.test.jsx b/src/pages/SettingsPage.test.jsx index 78bb800..1f8ae9b 100644 --- a/src/pages/SettingsPage.test.jsx +++ b/src/pages/SettingsPage.test.jsx @@ -4,6 +4,7 @@ import {act, cleanup, fireEvent, render, screen, waitFor, within} from '@testing import {MemoryRouter} from 'react-router-dom'; import SettingsPage from './SettingsPage.jsx'; import {PreferencesProvider} from '../context/PreferencesContext.jsx'; +import {CurrentUserProvider} from '../context/CurrentUserContext.jsx'; vi.mock('react-i18next', async () => { const mock = await import('../test/i18n-mock.js'); @@ -26,7 +27,7 @@ function renderPage(me) { // Both PUTs answer 204 without a body, like Auth does. ? {ok: true, status: 204, json: async () => { throw new SyntaxError('no body'); }} : {ok: true, status: 200, json: async () => me})); - render(); + render(); } /** @@ -66,7 +67,7 @@ describe('SettingsPage public profile', () => { it('builds a save on the one before it, also while that one is still open', async () => { const auth = fakeAuth(profile()); - render(); + render(); fireEvent.click(await screen.findByRole('switch', {name: 'SETTINGS.PUBLIC_PROFILE'})); await waitFor(() => expect(puts()).toHaveLength(1)); diff --git a/src/pages/StatsPage.jsx b/src/pages/StatsPage.jsx index 6f71666..240a79b 100644 --- a/src/pages/StatsPage.jsx +++ b/src/pages/StatsPage.jsx @@ -1,5 +1,3 @@ -import {useEffect} from 'react'; -import {useNavigate} from 'react-router-dom'; import {useTranslation} from 'react-i18next'; import {RefreshCw} from 'lucide-react'; @@ -13,22 +11,13 @@ import TopGamesStats from '../components/stats/TopGamesStats.jsx'; import TasteStats from '../components/stats/TasteStats.jsx'; import HabitsStats from '../components/stats/HabitsStats.jsx'; import FinishedStats from '../components/stats/FinishedStats.jsx'; -import useSessionExpired from '../hooks/useSessionExpired.js'; import useUserStats from '../hooks/useUserStats.js'; -import {hasSession} from '../utils/auth.js'; const ALL_GROUPS = []; export default function StatsPage() { const {t} = useTranslation(); - const navigate = useNavigate(); - const expireSession = useSessionExpired(); - const loggedIn = hasSession(); - const {stats, failed, reload} = useUserStats(ALL_GROUPS, expireSession, loggedIn); - - useEffect(() => { - if (!loggedIn) navigate('/login'); - }, [loggedIn, navigate]); + const {stats, failed, reload} = useUserStats(ALL_GROUPS); return ( diff --git a/src/pages/WishlistPage.jsx b/src/pages/WishlistPage.jsx index 8d8f508..804248d 100644 --- a/src/pages/WishlistPage.jsx +++ b/src/pages/WishlistPage.jsx @@ -1,5 +1,4 @@ import {useEffect, useState} from 'react'; -import {useNavigate} from 'react-router-dom'; import {useTranslation} from 'react-i18next'; import {ExternalLink, Gift, Percent} from 'lucide-react'; @@ -9,36 +8,25 @@ import {Card} from '@/components/ui/card'; import {Skeleton} from '@/components/ui/skeleton'; import GameCover from '../components/GameCover.jsx'; import LibraryTabs from '../components/LibraryTabs.jsx'; -import useSessionExpired from '../hooks/useSessionExpired.js'; import PageContainer from '../components/PageContainer.jsx'; import {apiFetch} from '../utils/api.js'; -import {hasSession} from '../utils/auth.js'; const formatAdded = (instant, locale) => instant ? new Date(instant).toLocaleDateString(locale, {year: 'numeric', month: 'short', day: 'numeric'}) : null; export default function WishlistPage() { const {t, i18n} = useTranslation(); - const navigate = useNavigate(); - const expireSession = useSessionExpired(); const [games, setGames] = useState(null); const [error, setError] = useState(null); const [onlyOnSale, setOnlyOnSale] = useState(false); useEffect(() => { - if (!hasSession()) { - navigate('/login'); - return undefined; - } let cancelled = false; (async () => { try { const response = await apiFetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/wishlist`); if (cancelled) return; - if (response.status === 401) { - expireSession(); - return; - } + if (response.status === 401) return; if (!response.ok) { setError('WISHLIST.ERROR_LOAD'); return; @@ -53,7 +41,7 @@ export default function WishlistPage() { return () => { cancelled = true; }; - }, [navigate, expireSession]); + }, []); const shown = (games ?? []).filter((g) => !onlyOnSale || g.discountPct > 0); const onSaleCount = (games ?? []).filter((g) => g.discountPct > 0).length; diff --git a/src/pages/WishlistPage.test.jsx b/src/pages/WishlistPage.test.jsx index 35a7eff..7943484 100644 --- a/src/pages/WishlistPage.test.jsx +++ b/src/pages/WishlistPage.test.jsx @@ -2,6 +2,7 @@ import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react'; import {MemoryRouter, Route, Routes, useLocation} from 'react-router-dom'; +import RequireSession from '../components/RequireSession.jsx'; import WishlistPage from './WishlistPage.jsx'; import {PreferencesProvider} from '../context/PreferencesContext.jsx'; @@ -19,7 +20,7 @@ function LoginState() { const renderPage = () => render( - }/> + }>}/> }/> , diff --git a/src/pages/languageSwitch.test.jsx b/src/pages/languageSwitch.test.jsx index 1774803..bcda55d 100644 --- a/src/pages/languageSwitch.test.jsx +++ b/src/pages/languageSwitch.test.jsx @@ -4,6 +4,7 @@ import {cleanup, render, screen} from '@testing-library/react'; import {MemoryRouter, Route, Routes} from 'react-router-dom'; import {switchLanguage} from '../test/i18n-mock.js'; import {PreferencesProvider} from '../context/PreferencesContext.jsx'; +import {CurrentUserProvider} from '../context/CurrentUserContext.jsx'; import Dashboard from './Dashboard.jsx'; import GameDetail from './GameDetail.jsx'; import GameLibrary from './GameLibrary.jsx'; @@ -14,12 +15,12 @@ import WishlistPage from './WishlistPage.jsx'; vi.mock('react-i18next', () => import('../test/i18n-mock.js')); const pages = [ - ['Dashboard', '/', '/', , 'INFO.ERROR.LOAD_PROFILE'], - ['GameDetail', '/games/:appid', '/games/220', , 'GAME_DETAIL.ERROR_LOAD'], - ['GameLibrary', '/library', '/library', , 'LIBRARY.ERROR_LOAD'], - ['PublicProfilePage', '/u/:name', '/u/Lobo', , 'PROFILE.ERROR_LOAD'], - ['SettingsPage', '/settings', '/settings', , 'INFO.ERROR.LOAD_PROFILE'], - ['WishlistPage', '/wishlist', '/wishlist', , 'WISHLIST.ERROR_LOAD'], + ['Dashboard', '/', '/', Dashboard, 'INFO.ERROR.LOAD_PROFILE'], + ['GameDetail', '/games/:appid', '/games/220', GameDetail, 'GAME_DETAIL.ERROR_LOAD'], + ['GameLibrary', '/library', '/library', GameLibrary, 'LIBRARY.ERROR_LOAD'], + ['PublicProfilePage', '/u/:name', '/u/Lobo', PublicProfilePage, 'PROFILE.ERROR_LOAD'], + ['SettingsPage', '/settings', '/settings', SettingsPage, 'INFO.ERROR.LOAD_PROFILE'], + ['WishlistPage', '/wishlist', '/wishlist', WishlistPage, 'WISHLIST.ERROR_LOAD'], ]; beforeEach(() => { @@ -34,10 +35,10 @@ afterEach(() => { }); describe('a language change', () => { - it.each(pages)('%s: loads nothing again and shows the error in the new language', async (name, path, url, page, errorKey) => { + it.each(pages)('%s: loads nothing again and shows the error in the new language', async (name, path, url, Page, errorKey) => { const tree = () => ( - + }/> ); const {rerender} = render(tree()); diff --git a/src/test/fetchRoutes.js b/src/test/fetchRoutes.js new file mode 100644 index 0000000..dc622a6 --- /dev/null +++ b/src/test/fetchRoutes.js @@ -0,0 +1,20 @@ +import {vi} from 'vitest'; + +export const json = (status, body) => ({ok: status >= 200 && status < 300, status, json: async () => body}); + +/** + * A fetch mock that answers by method and path, e.g. `'GET /api/users/me'`. The path starts at `/api/` (the base URL + * is not set in tests) and leaves out the query. A value is an answer or `(url, init) => answer`. Any other request + * throws, so a test cannot pass on a request it did not expect. + * Sets `globalThis.fetch` and returns the mock. + */ +export function fetchRoutes(routes) { + const mock = vi.fn(async (url, init) => { + const key = `${init?.method ?? 'GET'} ${new URL(url, 'http://x').pathname.replace(/^.*?(?=\/api\/)/, '')}`; + if (!(key in routes)) throw new Error(`no route ${key}`); + const answer = routes[key]; + return typeof answer === 'function' ? answer(url, init) : answer; + }); + globalThis.fetch = mock; + return mock; +} diff --git a/src/utils/api.js b/src/utils/api.js index 93d921c..955f930 100644 --- a/src/utils/api.js +++ b/src/utils/api.js @@ -55,14 +55,41 @@ export async function rotatingFetch(url, init = {}) { } /** - * fetch for the logged-in user. A 401 renews the access cookie once and sends the request again. When that fails, - * the 401 goes back to the caller. Ending the session is the caller's job (useSessionExpired), so the user sees - * why and comes back to the same page. + * Fires `expired` when the session is over. RequireSession ends it, so the user sees why and comes back to the same + * page; a public page does not listen. + */ +export const sessionEvents = new EventTarget(); + +/** + * fetch for the logged-in user. A 401 renews the access cookie once and sends the request again. A 401 after that + * goes back to the caller and fires `expired`; the caller only stops. */ export async function apiFetch(url, init = {}) { - const response = await clientFetch(url, init); - if (response.status !== 401 || !(await refreshSession())) { - return response; + let response = await clientFetch(url, init); + if (response.status === 401) { + if (await refreshSession()) response = await clientFetch(url, init); + if (response.status === 401) sessionEvents.dispatchEvent(new Event('expired')); } - return clientFetch(url, init); + return response; +} + +/** + * apiFetch that never throws: `{ok, status, data}` with the JSON body or null. A network error is status 0 and is + * logged, an abort only returns. + */ +export async function request(url, init = {}) { + let response; + try { + response = await apiFetch(url, init); + } catch (err) { + if (!init.signal?.aborted) console.error(`Request to ${url} failed:`, err); + return {ok: false, status: 0, data: null}; + } + let data = null; + try { + data = await response.json(); + } catch { + // no JSON body, e.g. a 204 + } + return {ok: response.ok, status: response.status, data}; } diff --git a/src/utils/api.test.js b/src/utils/api.test.js index 1ef77b1..6bac5e3 100644 --- a/src/utils/api.test.js +++ b/src/utils/api.test.js @@ -1,6 +1,6 @@ // @vitest-environment jsdom import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; -import {apiFetch, clientFetch, logout} from './api.js'; +import {apiFetch, clientFetch, logout, request, sessionEvents} from './api.js'; const response = (status) => ({ok: status >= 200 && status < 300, status, json: async () => ({})}); const isRefresh = (url) => url.endsWith('/api/auth/refresh'); @@ -59,7 +59,7 @@ describe('apiFetch', () => { expect(refreshCalls()).toHaveLength(1); }); - it('returns the 401 when the refresh fails, without a loop, and leaves ending the session to the caller', async () => { + it('returns the 401 when the refresh fails, without a loop', async () => { globalThis.fetch = vi.fn(async () => response(401)); const result = await apiFetch('/api/x'); @@ -126,6 +126,83 @@ describe('apiFetch', () => { }); }); +describe('the expired event', () => { + let expired; + const count = () => expired.mock.calls.length; + + beforeEach(() => { + expired = vi.fn(); + sessionEvents.addEventListener('expired', expired); + }); + + afterEach(() => { + sessionEvents.removeEventListener('expired', expired); + }); + + it('fires once when the refresh says the session is over', async () => { + globalThis.fetch = vi.fn(async () => response(401)); + + await apiFetch('/api/x'); + + expect(count()).toBe(1); + }); + + it('fires when the request after a good refresh still gets a 401', async () => { + globalThis.fetch = vi.fn(async (url) => response(isRefresh(url) ? 204 : 401)); + + await apiFetch('/api/x'); + + expect(count()).toBe(1); + }); + + it('does not fire when the refresh has no network, or for clientFetch', async () => { + globalThis.fetch = vi.fn(async (url) => { + if (isRefresh(url)) throw new TypeError('Failed to fetch'); + return response(401); + }); + + await expect(apiFetch('/api/x')).rejects.toThrow(); + await clientFetch('/api/x'); + + expect(count()).toBe(0); + }); +}); + +describe('request', () => { + it('gives the status and the JSON body', async () => { + globalThis.fetch = vi.fn(async () => ({ok: true, status: 200, json: async () => ({a: 1})})); + + expect(await request('/api/x')).toEqual({ok: true, status: 200, data: {a: 1}}); + }); + + it('gives null data for an answer without a JSON body', async () => { + globalThis.fetch = vi.fn(async () => ({ok: true, status: 204, json: async () => { throw new SyntaxError('empty'); }})); + + expect(await request('/api/x')).toEqual({ok: true, status: 204, data: null}); + }); + + it('gives the error body of a refused request', async () => { + globalThis.fetch = vi.fn(async () => ({ok: false, status: 409, json: async () => ({code: 'CONFLICT'})})); + + expect(await request('/api/x', {method: 'PUT'})).toEqual({ok: false, status: 409, data: {code: 'CONFLICT'}}); + }); + + it('gives status 0 on a network error and logs it, but not an abort', async () => { + const log = vi.spyOn(console, 'error').mockImplementation(() => undefined); + globalThis.fetch = vi.fn(async () => { throw new TypeError('Failed to fetch'); }); + + expect(await request('/api/x')).toEqual({ok: false, status: 0, data: null}); + expect(log).toHaveBeenCalledTimes(1); + + const controller = new AbortController(); + controller.abort(); + globalThis.fetch = vi.fn(async () => { throw new DOMException('aborted', 'AbortError'); }); + expect(await request('/api/x', {signal: controller.signal})).toEqual({ok: false, status: 0, data: null}); + expect(log).toHaveBeenCalledTimes(1); + log.mockRestore(); + }); +}); + describe('logout', () => { it('posts to Auth and ignores a network error', async () => { globalThis.fetch = vi.fn(async () => { diff --git a/src/utils/auth.js b/src/utils/auth.js index 4bad9cc..3ea5f58 100644 --- a/src/utils/auth.js +++ b/src/utils/auth.js @@ -1,6 +1,6 @@ // The tokens live in HttpOnly cookies that no script can read. This flag only remembers that a login happened; a // stale flag costs one 401 and a failed refresh, then the login page opens. -const SESSION_KEY = 'tbp_session'; +export const SESSION_KEY = 'tbp_session'; // Where the token lived before the cookies. Removed on the next login or logout. const LEGACY_TOKEN_KEY = 'tbp_jwt'; diff --git a/src/utils/imports.js b/src/utils/imports.js index 4fef954..2365e23 100644 --- a/src/utils/imports.js +++ b/src/utils/imports.js @@ -45,7 +45,7 @@ const COLUMNS = { * @return {rows} with title, platform, playtimeMinutes and status where the file has them, or {error} as an i18n key */ export const parseCsv = (text) => { - const clean = text.replace(/^/, ''); + const clean = text.replace(/^\uFEFF/, ''); const firstLine = clean.split(/\r?\n/, 1)[0]; const delimiter = [',', ';', '\t'].reduce((best, next) => firstLine.split(next).length > firstLine.split(best).length ? next : best); diff --git a/src/utils/platforms.js b/src/utils/platforms.js index b24a319..19ad4ea 100644 --- a/src/utils/platforms.js +++ b/src/utils/platforms.js @@ -30,18 +30,6 @@ export const preselectPlatform = (own, platforms, favorite) => { // A Steam game has no stored platform. export const platformLabel = (game) => (game.source === 'MANUAL' ? game.platform : 'Steam'); -/** - * The user's favorite platform from Auth; null when none is set or the load failed, so a caller can go on. - */ -export const loadFavoritePlatform = async () => { - try { - const response = await apiFetch(`${AUTH_URL()}/api/users/me?v=1`); - return response.ok ? (await response.json()).settings?.favoritePlatform ?? null : null; - } catch { - return null; - } -}; - /** * PUT /api/users/settings/favorite-platform; null clears it. The caller handles the response status. */ diff --git a/src/utils/profileStyle.js b/src/utils/profileStyle.js index 1ab475d..7bc8831 100644 --- a/src/utils/profileStyle.js +++ b/src/utils/profileStyle.js @@ -1,5 +1,6 @@ import {apiFetch} from './api.js'; import {ACCENTS, CORNERS, DEFAULT_APPEARANCE, appid} from './appearance.js'; +import {fetchMe} from './settings.js'; export const DEFAULT_PROFILE_STYLE = { accent: DEFAULT_APPEARANCE.accent, corners: DEFAULT_APPEARANCE.corners, bannerAppid: null, backgroundAppid: null, @@ -16,7 +17,7 @@ export const normalizeProfileStyle = (value) => ({ * The stored style from GET /api/users/me; null when there is no answer to use. */ export const loadProfileStyle = async () => { - const response = await apiFetch(`${import.meta.env.VITE_API_URL}/api/users/me?v=1`); + const response = await fetchMe(); if (!response.ok) return null; const data = await response.json(); return normalizeProfileStyle(data.settings?.profileStyle); diff --git a/src/utils/settings.js b/src/utils/settings.js index c1c5365..1f24686 100644 --- a/src/utils/settings.js +++ b/src/utils/settings.js @@ -22,10 +22,12 @@ export const cacheLanguage = (language) => { } }; +export const ME_URL = () => `${import.meta.env.VITE_API_URL}/api/users/me?v=1`; + /** * GET /api/users/me on Auth. The caller handles the response status. */ -export const fetchMe = () => apiFetch(`${import.meta.env.VITE_API_URL}/api/users/me?v=1`, { +export const fetchMe = () => apiFetch(ME_URL(), { signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), });