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),
});