Public Access
Compare commits
10
Commits
24a98062ee
...
aa5832225d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa5832225d | ||
|
|
28ff6eae52 | ||
|
|
6da612bebd | ||
|
|
05f4ca42e5 | ||
|
|
628920a006 | ||
|
|
9ddc50f819 | ||
|
|
cbafa3d540 | ||
|
|
2a22789435 | ||
|
|
d0ea9e900d | ||
|
|
111ab1e200 |
No files matched your search
@@ -0,0 +1 @@
|
|||||||
|
{"numTotalTestSuites":23,"numPassedTestSuites":23,"numFailedTestSuites":0,"numPendingTestSuites":0,"numTotalTests":56,"numPassedTests":56,"numFailedTests":0,"numPendingTests":0,"numTodoTests":0,"snapshot":{"added":0,"failure":false,"filesAdded":0,"filesRemoved":0,"filesRemovedList":[],"filesUnmatched":0,"filesUpdated":0,"matched":0,"total":0,"unchecked":0,"uncheckedKeysByFile":[],"unmatched":0,"updated":0,"didUpdate":false},"startTime":1790690796160,"success":true,"testResults":[{"assertionResults":[{"ancestorTitles":["App"],"fullName":"App ends the session on a 401 that the refresh cannot fix","status":"passed","title":"ends the session on a 401 that the refresh cannot fix","duration":931.6336369999999,"failureMessages":[],"meta":{},"tags":[],"benchmarks":[]}],"startTime":1790690797891,"endTime":1790690798822.6335,"status":"passed","message":"","name":"/workspace/tbp/ToBePlayed-Frontend/src/App.test.jsx"},{"assertionResults":[{"ancestorTitles":["AddGamePanel"],"fullName":"AddGamePanel does not search below two characters","status":"passed","title":"does not search below two characters","duration":769.245433,"failureMessages":[],"meta":{},"tags":[],"benchmarks":[]},{"ancestorTitles":["AddGamePanel"],"fullName":"AddGamePanel searches once after typing stops and blocks games already in the library","status":"passed","title":"searches once after typing stops and blocks games already in the library","duration":433.3170190000003,"failureMessages":[],"meta":{},"tags":[],"benchmarks":[]},{"ancestorTitles":["AddGamePanel"],"fullName":"AddGamePanel adds the picked game with the favorite platform and the hours, then opens its page","status":"passed","title":"adds the picked game with the favorite platform and the hours, then opens its page","duration":529.4851979999999,"failureMessages":[],"meta":{},"tags":[],"benchmarks":[]},{"ancestorTitles":["AddGamePanel"],"fullName":"AddGamePanel preselects no platform without a favorite among several, so Add waits for a pick","status":"passed","title":"preselects no platform without a favorite among several, so Add waits for a pick","duration":444.57771,"failureMessages":[],"meta":{},"tags":[],"benchmarks":[]},{"ancestorTitles":["AddGamePanel"],"fullName":"AddGamePanel sends several checked games to a search import and opens its review","status":"passed","title":"sends several checked games to a search import and opens its review","duration":428.44052999999985,"failureMessages":[],"meta":{},"tags":[],"benchmarks":[]},{"ancestorTitles":["AddGamePanel"],"fullName":"AddGamePanel says so when the game is already in the library","status":"passed","title":"says so when the game is already in the library","duration":447.7203089999998,"failureMessages":[],"meta":{},"tags":[],"benchmarks":[]},{"ancestorTitles":["AddGamePanel"],"fullName":"AddGamePanel shows that adding is off when Core has no IGDB app","status":"passed","title":"shows that adding is off when Core has no IGDB app","duration":408.3551859999998,"failureMessages":[],"meta":{},"tags":[],"benchmarks":[]}],"startTime":1790690797326,"endTime":1790690800787.3552,"status":"passed","message":"","name":"/workspace/tbp/ToBePlayed-Frontend/src/components/AddGamePanel.test.jsx"},{"assertionResults":[{"ancestorTitles":["AppHeader"],"fullName":"AppHeader says so when Auth refuses the language switch","status":"passed","title":"says so when Auth refuses the language switch","duration":364.1882109999999,"failureMessages":[],"meta":{},"tags":[],"benchmarks":[]},{"ancestorTitles":["AppHeader"],"fullName":"AppHeader keeps a name on the home link when a narrow phone hides the app name","status":"passed","title":"keeps a name on the home link when a narrow phone hides the app name","duration":18.29670699999997,"failureMessages":[],"meta":{},"tags":[],"benchmarks":[]},{"ancestorTitles":["AppHeader"],"fullName":"AppHeader offers the admin page and \"view as\" to an admin","status":"passed","title":"offers the admin page and \"view as\" to an admin","duration":54.321174000000156,"failureMessages":[],"meta":{},"tags":[],"benchmarks":[]},{"ancestorTitles":["AppHeader"],"fullName":"AppHeader shows the badge and hides the admin page while an admin views as a user","status":"passed","title":"shows the badge and hides the admin page while an admin views as a user","duration":53.448589000000084,"failureMessages":[],"meta":{},"tags":[],"benchmarks":[]},{"ancestorTitles":["AppHeader"],"fullName":"AppHeader offers nothing of that to a user","status":"passed","title":"offers nothing of that to a user","duration":39.23294800000008,"failureMessages":[],"meta":{},"tags":[],"benchmarks":[]}],"startTime":1790690797351,"endTime":1790690797881.233,"status":"passed","message":"","name":"/workspace/tbp/ToBePlayed-Frontend/src/components/AppHeader.test.jsx"},{"assertionResults":[{"ancestorTitles":["BacklogCard"],"fullName":"BacklogCard enables saving only after a change","status":"passed","title":"enables saving only after a change","duration":227.31609400000002,"faLine truncated
|
||||||
@@ -99,7 +99,8 @@ To Be Played is split into three repositories:
|
|||||||
- Vitest + React Testing Library + jsdom
|
- Vitest + React Testing Library + jsdom
|
||||||
- Production image: nginx (Alpine), SPA fallback, long-term caching for hashed assets, Content Security Policy
|
- Production image: nginx (Alpine), SPA fallback, long-term caching for hashed assets, Content Security Policy
|
||||||
and security headers
|
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
|
## Architecture
|
||||||
|
|
||||||
@@ -127,8 +128,19 @@ flowchart LR
|
|||||||
- Calls for the logged-in user go through `src/utils/api.js`. `clientFetch` sends the HttpOnly cookies and the
|
- 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.
|
`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
|
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
|
`401`, `apiFetch` ends the session (`expireSession`); the pages only stop. `request()` wraps `apiFetch` as
|
||||||
profile) and the public `GET /api/version` in the settings use plain `fetch`.
|
`{ok, status, data}` and never throws. Public pages (landing page, public profile) and the public
|
||||||
|
`GET /api/version` in the settings use plain `fetch`.
|
||||||
|
- `src/utils/auth.js` is the session store: `markSession`, `clearSession` (logout) and `expireSession` (a `401`)
|
||||||
|
tell every `useSession()`, also on a login or logout in another tab. The user (`CurrentUserProvider`) and the
|
||||||
|
look (`PreferencesProvider`) follow it.
|
||||||
|
- The pages that need a login sit under the `RequireSession` layout route in `App.jsx`. Without a session it opens
|
||||||
|
the login page: after a `401` with the "session expired" message and back to the same page, after a logout
|
||||||
|
without a way back. A public page stays open when the session ends there.
|
||||||
|
- `CurrentUserProvider` holds `GET /api/users/me` for the whole app (`useCurrentUserContext`, `useCurrentUser`).
|
||||||
|
Dashboard and settings load it again when they open.
|
||||||
|
- Tests render through `src/test/renderWithProviders.jsx` (a data router like `main.jsx`) and answer requests
|
||||||
|
with `src/test/fetchRoutes.js`, which fails a test on any request without a route.
|
||||||
- `src/utils/brokenImages.js` reports pictures that fail to load (`GameCover`, achievement icons on the game
|
- `src/utils/brokenImages.js` reports pictures that fail to load (`GameCover`, achievement icons on the game
|
||||||
detail page) to Core.
|
detail page) to Core.
|
||||||
- `useLibrarySync` polls Core's `/api/library/sync-status` while a Steam sync runs, then loads the data again.
|
- `useLibrarySync` polls Core's `/api/library/sync-status` while a Steam sync runs, then loads the data again.
|
||||||
@@ -141,9 +153,10 @@ flowchart LR
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
src/
|
src/
|
||||||
├── pages/ # One component per route (Dashboard, GameLibrary, GameDetail, ...)
|
├── pages/ # One component per route (Dashboard, GameLibrary, GameDetail, ...): loads data, places cards
|
||||||
├── components/ # Shared components; ui/ holds the base components
|
├── components/ # Cards and shared components; ui/ holds the base components, import/ and stats/ the parts
|
||||||
├── hooks/ # Data hooks (library sync, Steam profile, appearance, session expiry)
|
│ # of one page that share page-only code
|
||||||
|
├── hooks/ # Data hooks (game, dashboard, library sync, Steam profile, appearance, session)
|
||||||
├── context/ # PreferencesContext: theme, language, appearance
|
├── context/ # PreferencesContext: theme, language, appearance
|
||||||
├── utils/ # Validation schemas and helpers
|
├── utils/ # Validation schemas and helpers
|
||||||
└── test/ # Test setup (i18n mock)
|
└── test/ # Test setup (i18n mock)
|
||||||
@@ -191,6 +204,7 @@ browser bundle, so they must never hold a secret.
|
|||||||
## Tests and build
|
## Tests and build
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
npm run lint
|
||||||
npm test
|
npm test
|
||||||
npm run build
|
npm run build
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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}},
|
||||||
|
];
|
||||||
Generated
+1312
File diff suppressed because it is too large.
Load diff
+6
-1
@@ -7,7 +7,8 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"test": "vitest run"
|
"test": "vitest run",
|
||||||
|
"lint": "eslint ."
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource-variable/inter": "^5.3.0",
|
"@fontsource-variable/inter": "^5.3.0",
|
||||||
@@ -29,10 +30,14 @@
|
|||||||
"zod": "^3.25.76"
|
"zod": "^3.25.76"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^10.0.1",
|
||||||
"@testing-library/dom": "^10.4.2",
|
"@testing-library/dom": "^10.4.2",
|
||||||
"@testing-library/react": "^16.3.3",
|
"@testing-library/react": "^16.3.3",
|
||||||
"@vitejs/plugin-react": "^6.1.1",
|
"@vitejs/plugin-react": "^6.1.1",
|
||||||
"autoprefixer": "~10.6.1",
|
"autoprefixer": "~10.6.1",
|
||||||
|
"eslint": "^10.11.0",
|
||||||
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
|
"globals": "^17.12.0",
|
||||||
"jsdom": "^30.1.1",
|
"jsdom": "^30.1.1",
|
||||||
"postcss": "~8.5.28",
|
"postcss": "~8.5.28",
|
||||||
"tailwindcss": "~4.3.3",
|
"tailwindcss": "~4.3.3",
|
||||||
|
|||||||
@@ -205,6 +205,7 @@
|
|||||||
"SYNCING": "Synchronisiere mit Steam …",
|
"SYNCING": "Synchronisiere mit Steam …",
|
||||||
"SYNCING_FIRST_TIME": "Erste Synchronisation mit Steam läuft. Deine Spiele erscheinen gleich.",
|
"SYNCING_FIRST_TIME": "Erste Synchronisation mit Steam läuft. Deine Spiele erscheinen gleich.",
|
||||||
"SYNC_FAILED": "Synchronisation fehlgeschlagen. Bitte später erneut versuchen.",
|
"SYNC_FAILED": "Synchronisation fehlgeschlagen. Bitte später erneut versuchen.",
|
||||||
|
"SYNC_STALE": "Die Synchronisation dauert länger als sonst. Bitte lade die Seite später neu.",
|
||||||
"LAST_SYNCED": "Zuletzt synchronisiert {{when}}",
|
"LAST_SYNCED": "Zuletzt synchronisiert {{when}}",
|
||||||
"FILTER_ALL": "Alle",
|
"FILTER_ALL": "Alle",
|
||||||
"SEARCH_PLACEHOLDER": "Spiel suchen …",
|
"SEARCH_PLACEHOLDER": "Spiel suchen …",
|
||||||
@@ -654,6 +655,8 @@
|
|||||||
"SAVE": "{{count}} Spiele hinzufügen",
|
"SAVE": "{{count}} Spiele hinzufügen",
|
||||||
"SAVE_FAILED": "Der Import konnte nicht gespeichert werden.",
|
"SAVE_FAILED": "Der Import konnte nicht gespeichert werden.",
|
||||||
"NEW": "Neuer Import",
|
"NEW": "Neuer Import",
|
||||||
|
"SELECT_ALL": "Alle auswählen",
|
||||||
|
"SELECT_NONE": "Alle abwählen",
|
||||||
"LINE": "Zeile {{line}}",
|
"LINE": "Zeile {{line}}",
|
||||||
"SEARCH_FAILED": "Suche fehlgeschlagen",
|
"SEARCH_FAILED": "Suche fehlgeschlagen",
|
||||||
"SEARCH_AGAIN": "Neu suchen",
|
"SEARCH_AGAIN": "Neu suchen",
|
||||||
@@ -703,5 +706,8 @@
|
|||||||
"RETRY": "Nochmal versuchen",
|
"RETRY": "Nochmal versuchen",
|
||||||
"RETRY_FAILED": "Der Import konnte nicht neu gestartet werden.",
|
"RETRY_FAILED": "Der Import konnte nicht neu gestartet werden.",
|
||||||
"ATTEMPT": "Versuch {{attempt}}: der letzte Versuch kam nicht weiter."
|
"ATTEMPT": "Versuch {{attempt}}: der letzte Versuch kam nicht weiter."
|
||||||
|
},
|
||||||
|
"UNSAVED_CHANGES": {
|
||||||
|
"CONFIRM": "Du hast Änderungen, die nicht gespeichert sind. Seite verlassen und die Änderungen verwerfen?"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -205,6 +205,7 @@
|
|||||||
"SYNCING": "Syncing with Steam …",
|
"SYNCING": "Syncing with Steam …",
|
||||||
"SYNCING_FIRST_TIME": "First sync with Steam in progress. Your games will appear here shortly.",
|
"SYNCING_FIRST_TIME": "First sync with Steam in progress. Your games will appear here shortly.",
|
||||||
"SYNC_FAILED": "Sync failed. Please try again later.",
|
"SYNC_FAILED": "Sync failed. Please try again later.",
|
||||||
|
"SYNC_STALE": "The sync takes longer than usual. Reload the page later.",
|
||||||
"LAST_SYNCED": "Last synced {{when}}",
|
"LAST_SYNCED": "Last synced {{when}}",
|
||||||
"FILTER_ALL": "All",
|
"FILTER_ALL": "All",
|
||||||
"SEARCH_PLACEHOLDER": "Search games …",
|
"SEARCH_PLACEHOLDER": "Search games …",
|
||||||
@@ -654,6 +655,8 @@
|
|||||||
"SAVE": "Add {{count}} games",
|
"SAVE": "Add {{count}} games",
|
||||||
"SAVE_FAILED": "Could not save the import.",
|
"SAVE_FAILED": "Could not save the import.",
|
||||||
"NEW": "New import",
|
"NEW": "New import",
|
||||||
|
"SELECT_ALL": "Select all",
|
||||||
|
"SELECT_NONE": "Deselect all",
|
||||||
"LINE": "Line {{line}}",
|
"LINE": "Line {{line}}",
|
||||||
"SEARCH_FAILED": "Search failed",
|
"SEARCH_FAILED": "Search failed",
|
||||||
"SEARCH_AGAIN": "Search again",
|
"SEARCH_AGAIN": "Search again",
|
||||||
@@ -703,5 +706,8 @@
|
|||||||
"RETRY": "Try again",
|
"RETRY": "Try again",
|
||||||
"RETRY_FAILED": "Could not start the import again.",
|
"RETRY_FAILED": "Could not start the import again.",
|
||||||
"ATTEMPT": "Try {{attempt}}: the last try made no progress."
|
"ATTEMPT": "Try {{attempt}}: the last try made no progress."
|
||||||
|
},
|
||||||
|
"UNSAVED_CHANGES": {
|
||||||
|
"CONFIRM": "You have changes that are not saved. Leave this page and lose them?"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+20
-22
@@ -1,6 +1,6 @@
|
|||||||
import {Link, Route, Routes, useLocation, useNavigate} from 'react-router-dom';
|
import {Link, Route, Routes, useLocation, useNavigate} from 'react-router-dom';
|
||||||
import {useTranslation} from 'react-i18next';
|
import {useTranslation} from 'react-i18next';
|
||||||
import {useEffect, useState} from 'react';
|
import {useEffect} from 'react';
|
||||||
|
|
||||||
import LandingPage from './pages/LandingPage';
|
import LandingPage from './pages/LandingPage';
|
||||||
import LoginPage from './pages/LoginPage';
|
import LoginPage from './pages/LoginPage';
|
||||||
@@ -18,28 +18,25 @@ import StatsPage from './pages/StatsPage.jsx';
|
|||||||
import ImportPage from './pages/ImportPage.jsx';
|
import ImportPage from './pages/ImportPage.jsx';
|
||||||
import PublicProfilePage from './pages/PublicProfilePage.jsx';
|
import PublicProfilePage from './pages/PublicProfilePage.jsx';
|
||||||
import AppHeader from './components/AppHeader.jsx';
|
import AppHeader from './components/AppHeader.jsx';
|
||||||
|
import RequireSession from './components/RequireSession.jsx';
|
||||||
import {Toaster} from '@/components/ui/sonner';
|
import {Toaster} from '@/components/ui/sonner';
|
||||||
import {TooltipProvider} from '@/components/ui/tooltip';
|
import {TooltipProvider} from '@/components/ui/tooltip';
|
||||||
import PageBackground from './components/PageBackground.jsx';
|
import PageBackground from './components/PageBackground.jsx';
|
||||||
import {usePreferences} from './context/PreferencesContext.jsx';
|
import {usePreferences} from './context/PreferencesContext.jsx';
|
||||||
import {clearSession, hasSession} from './utils/auth.js';
|
import {clearSession} from './utils/auth.js';
|
||||||
|
import useSession from './hooks/useSession.js';
|
||||||
|
import {UnsavedChangesGuard} from './hooks/useUnsavedChanges.js';
|
||||||
import {logout} from './utils/api.js';
|
import {logout} from './utils/api.js';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const {t} = useTranslation();
|
const {t} = useTranslation();
|
||||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
const {active: isAuthenticated} = useSession();
|
||||||
const {appearance, syncPreferences, resetAppearance} = usePreferences();
|
const {appearance, syncPreferences} = usePreferences();
|
||||||
|
|
||||||
// Hooks for routing
|
// Hooks for routing
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// No local expiry check here: a 401 from the backend ends the session (useSessionExpired), and a browser
|
|
||||||
// clock that runs ahead would otherwise end it silently.
|
|
||||||
setIsAuthenticated(hasSession());
|
|
||||||
}, [location]);
|
|
||||||
|
|
||||||
// On login and on page load; the cached look shows until Auth answers.
|
// On login and on page load; the cached look shows until Auth answers.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isAuthenticated) syncPreferences();
|
if (isAuthenticated) syncPreferences();
|
||||||
@@ -49,8 +46,6 @@ function App() {
|
|||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
await logout();
|
await logout();
|
||||||
clearSession();
|
clearSession();
|
||||||
resetAppearance();
|
|
||||||
setIsAuthenticated(false);
|
|
||||||
navigate('/login');
|
navigate('/login');
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -67,17 +62,19 @@ function App() {
|
|||||||
<Route path="/" element={<LandingPage/>}/>
|
<Route path="/" element={<LandingPage/>}/>
|
||||||
<Route path="/login" element={<LoginPage/>}/>
|
<Route path="/login" element={<LoginPage/>}/>
|
||||||
<Route path="/register" element={<RegistrationPage/>}/>
|
<Route path="/register" element={<RegistrationPage/>}/>
|
||||||
<Route path="/dashboard" element={<Dashboard/>}/>
|
<Route element={<RequireSession/>}>
|
||||||
<Route path="/library" element={<GameLibrary/>}/>
|
<Route path="/dashboard" element={<Dashboard/>}/>
|
||||||
<Route path="/library/:appid" element={<GameDetail/>}/>
|
<Route path="/library" element={<GameLibrary/>}/>
|
||||||
<Route path="/wishlist" element={<WishlistPage/>}/>
|
<Route path="/library/:appid" element={<GameDetail/>}/>
|
||||||
<Route path="/play-next" element={<PlayNextPage/>}/>
|
<Route path="/wishlist" element={<WishlistPage/>}/>
|
||||||
<Route path="/stats" element={<StatsPage/>}/>
|
<Route path="/play-next" element={<PlayNextPage/>}/>
|
||||||
<Route path="/import" element={<ImportPage/>}/>
|
<Route path="/stats" element={<StatsPage/>}/>
|
||||||
|
<Route path="/import" element={<ImportPage/>}/>
|
||||||
|
<Route path="/settings" element={<SettingsPage/>}/>
|
||||||
|
<Route path="/admin/users" element={<AdminUsersPage/>}/>
|
||||||
|
<Route path="/admin/hints" element={<AdminHintsPage/>}/>
|
||||||
|
</Route>
|
||||||
<Route path="/u/:name" element={<PublicProfilePage/>}/>
|
<Route path="/u/:name" element={<PublicProfilePage/>}/>
|
||||||
<Route path="/settings" element={<SettingsPage/>}/>
|
|
||||||
<Route path="/admin/users" element={<AdminUsersPage/>}/>
|
|
||||||
<Route path="/admin/hints" element={<AdminHintsPage/>}/>
|
|
||||||
<Route path="/impressum" element={<ImprintPage/>}/>
|
<Route path="/impressum" element={<ImprintPage/>}/>
|
||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
@@ -88,6 +85,7 @@ function App() {
|
|||||||
</Link>
|
</Link>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
<UnsavedChangesGuard/>
|
||||||
<Toaster richColors position="bottom-right"/>
|
<Toaster richColors position="bottom-right"/>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
+16
-19
@@ -1,9 +1,9 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||||
import {act, cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
|
import {act, cleanup, fireEvent, screen, waitFor} from '@testing-library/react';
|
||||||
import {MemoryRouter, useLocation} from 'react-router-dom';
|
|
||||||
import App from './App.jsx';
|
import App from './App.jsx';
|
||||||
import {PreferencesProvider} from './context/PreferencesContext.jsx';
|
import {fetchRoutes, json} from './test/fetchRoutes.js';
|
||||||
|
import {renderWithProviders} from './test/renderWithProviders.jsx';
|
||||||
|
|
||||||
vi.mock('react-i18next', () => import('./test/i18n-mock.js'));
|
vi.mock('react-i18next', () => import('./test/i18n-mock.js'));
|
||||||
vi.mock('./hooks/useLibrarySync.js', () => ({default: () => ({status: 'OK', lastSyncedAt: null, isRunning: false})}));
|
vi.mock('./hooks/useLibrarySync.js', () => ({default: () => ({status: 'OK', lastSyncedAt: null, isRunning: false})}));
|
||||||
@@ -14,16 +14,8 @@ const page = {content: [{appid: 220, name: 'Half-Life 2', playtimeMinutes: 0, st
|
|||||||
|
|
||||||
const wait = (ms) => act(() => new Promise((resolve) => setTimeout(resolve, ms)));
|
const wait = (ms) => act(() => new Promise((resolve) => setTimeout(resolve, ms)));
|
||||||
|
|
||||||
const current = {location: null};
|
|
||||||
|
|
||||||
function Spy() {
|
|
||||||
current.location = useLocation();
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
localStorage.setItem('tbp_session', '1');
|
localStorage.setItem('tbp_session', '1');
|
||||||
globalThis.fetch = vi.fn(async () => ({ok: true, status: 200, json: async () => page}));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -33,19 +25,24 @@ afterEach(() => {
|
|||||||
|
|
||||||
describe('App', () => {
|
describe('App', () => {
|
||||||
it('ends the session on a 401 that the refresh cannot fix', async () => {
|
it('ends the session on a 401 that the refresh cannot fix', async () => {
|
||||||
render(
|
let expired = false;
|
||||||
<MemoryRouter initialEntries={['/library']}>
|
const library = () => (expired ? json(401, {}) : json(200, page));
|
||||||
<PreferencesProvider><App/><Spy/></PreferencesProvider>
|
fetchRoutes({
|
||||||
</MemoryRouter>,
|
'GET /api/users/me': json(200, {settings: {}}),
|
||||||
);
|
'GET /api/library/games': library,
|
||||||
|
'GET /api/library/games-count': json(200, {hasSteamId: true}),
|
||||||
|
'GET /api/library/steam-profile': json(200, null),
|
||||||
|
'POST /api/auth/refresh': json(401, {}),
|
||||||
|
});
|
||||||
|
const {router} = renderWithProviders(<App/>, {entries: ['/library']});
|
||||||
await waitFor(() => expect(screen.getByText('Half-Life 2')).toBeTruthy());
|
await waitFor(() => expect(screen.getByText('Half-Life 2')).toBeTruthy());
|
||||||
|
|
||||||
fetch.mockImplementation(async () => ({ok: false, status: 401, json: async () => ({})}));
|
expired = true;
|
||||||
fireEvent.change(screen.getByRole('searchbox'), {target: {value: 'half'}});
|
fireEvent.change(screen.getByRole('searchbox'), {target: {value: 'half'}});
|
||||||
await wait(DEBOUNCE_WAIT_MS);
|
await wait(DEBOUNCE_WAIT_MS);
|
||||||
|
|
||||||
await waitFor(() => expect(current.location.pathname).toBe('/login'));
|
await waitFor(() => expect(router.state.location.pathname).toBe('/login'));
|
||||||
expect(current.location.state).toEqual({sessionExpired: true, from: '/library?q=half&page=0'});
|
expect(router.state.location.state).toEqual({sessionExpired: true, from: '/library?q=half&page=0'});
|
||||||
expect(localStorage.getItem('tbp_session')).toBeNull();
|
expect(localStorage.getItem('tbp_session')).toBeNull();
|
||||||
expect(fetch).toHaveBeenCalledWith(expect.stringContaining('/api/auth/refresh'), expect.objectContaining({method: 'POST'}));
|
expect(fetch).toHaveBeenCalledWith(expect.stringContaining('/api/auth/refresh'), expect.objectContaining({method: 'POST'}));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import {useTranslation} from 'react-i18next';
|
||||||
|
import {UserRound} from 'lucide-react';
|
||||||
|
|
||||||
|
import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
|
||||||
|
import {useCurrentUserContext} from '../context/CurrentUserContext.jsx';
|
||||||
|
|
||||||
|
const formatDate = (value, locale) => {
|
||||||
|
if (!value) return '—';
|
||||||
|
try {
|
||||||
|
return new Date(value).toLocaleString(locale);
|
||||||
|
} catch {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AccountInfoCard() {
|
||||||
|
const {t, i18n} = useTranslation();
|
||||||
|
const {user: profile} = useCurrentUserContext();
|
||||||
|
const accountRows = [
|
||||||
|
[t('SETTINGS.EMAIL'), profile?.email],
|
||||||
|
[t('SETTINGS.ROLE'), profile?.role],
|
||||||
|
[t('SETTINGS.CREATED_DATE'), formatDate(profile?.createdDate, i18n.language)],
|
||||||
|
[t('SETTINGS.LAST_LOGIN'), formatDate(profile?.lastLogin, i18n.language)],
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2"><UserRound className="size-4 text-primary"/>{t('SETTINGS.ACCOUNT')}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<dl className="divide-y">
|
||||||
|
{accountRows.map(([label, value]) => (
|
||||||
|
<div key={label} className="flex flex-col gap-1 py-3 first:pt-0 last:pb-0 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<dt className="text-sm text-muted-foreground">{label}</dt>
|
||||||
|
<dd className="text-sm font-medium break-all">{value || '—'}</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,11 +11,11 @@ import {Label} from '@/components/ui/label';
|
|||||||
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/components/ui/select';
|
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/components/ui/select';
|
||||||
import GameCover from './GameCover.jsx';
|
import GameCover from './GameCover.jsx';
|
||||||
import GameSearch from './GameSearch.jsx';
|
import GameSearch from './GameSearch.jsx';
|
||||||
import useSessionExpired from '../hooks/useSessionExpired.js';
|
|
||||||
import useStartImport from '../hooks/useStartImport.js';
|
import useStartImport from '../hooks/useStartImport.js';
|
||||||
import {GAME_STATUSES, MAX_PLATFORM_LENGTH} from '../utils/limits.js';
|
import {GAME_STATUSES, MAX_PLATFORM_LENGTH} from '../utils/limits.js';
|
||||||
import {addGame, hoursToMinutes} from '../utils/manualGames.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
|
* 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}) {
|
export default function AddGamePanel({onClose}) {
|
||||||
const {t} = useTranslation();
|
const {t} = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const expireSession = useSessionExpired();
|
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
const [picked, setPicked] = useState(null);
|
const [picked, setPicked] = useState(null);
|
||||||
// igdbId -> hit, over several searches.
|
// igdbId -> hit, over several searches.
|
||||||
const [selected, setSelected] = useState(() => new Map());
|
const [selected, setSelected] = useState(() => new Map());
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [favorite, setFavorite] = useState(null);
|
const favorite = useCurrentUserContext().user?.settings?.favoritePlatform ?? null;
|
||||||
useEffect(() => {
|
|
||||||
loadFavoritePlatform().then(setFavorite);
|
|
||||||
}, []);
|
|
||||||
const startImport = useStartImport();
|
const startImport = useStartImport();
|
||||||
|
|
||||||
const toggle = (hit) => {
|
const toggle = (hit) => {
|
||||||
@@ -64,8 +60,7 @@ export default function AddGamePanel({onClose}) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{picked ? (
|
{picked ? (
|
||||||
<AddForm hit={picked} favorite={favorite} onBack={() => setPicked(null)} navigate={navigate}
|
<AddForm hit={picked} favorite={favorite} onBack={() => setPicked(null)} navigate={navigate}/>
|
||||||
expireSession={expireSession}/>
|
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<p className="text-sm text-muted-foreground">{t('MANUAL_GAME.ADD_HINT')}</p>
|
<p className="text-sm text-muted-foreground">{t('MANUAL_GAME.ADD_HINT')}</p>
|
||||||
@@ -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 {t} = useTranslation();
|
||||||
const [platform, setPlatform] = useState(() => preselectPlatform(null, hit.platforms, favorite));
|
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.
|
// 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({
|
const response = await addGame({
|
||||||
igdbId: hit.igdbId, platform: platform.trim(), status, playtimeMinutes: hoursToMinutes(hours),
|
igdbId: hit.igdbId, platform: platform.trim(), status, playtimeMinutes: hoursToMinutes(hours),
|
||||||
});
|
});
|
||||||
if (response.status === 401) {
|
if (response.status === 401) return;
|
||||||
expireSession();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (response.status === 409) {
|
if (response.status === 409) {
|
||||||
toast.error(t('MANUAL_GAME.ALREADY_IN_LIBRARY'));
|
toast.error(t('MANUAL_GAME.ALREADY_IN_LIBRARY'));
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||||
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
|
import {cleanup, fireEvent, screen, waitFor} from '@testing-library/react';
|
||||||
import {MemoryRouter, Route, Routes} from 'react-router-dom';
|
|
||||||
import {toast} from 'sonner';
|
import {toast} from 'sonner';
|
||||||
import AddGamePanel from './AddGamePanel.jsx';
|
import AddGamePanel from './AddGamePanel.jsx';
|
||||||
|
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
||||||
|
import {renderWithProviders} from '../test/renderWithProviders.jsx';
|
||||||
|
|
||||||
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||||
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
|
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
|
||||||
@@ -14,22 +15,17 @@ const zelda = {
|
|||||||
};
|
};
|
||||||
const portal = {...zelda, appid: 400, igdbId: 71, name: 'Portal', inLibrary: true};
|
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') {
|
function renderPanel(post = json(201, {appid: -1025}), favoritePlatform = 'Nintendo Switch',
|
||||||
globalThis.fetch = vi.fn(async (url, init) => {
|
found = json(200, {available: true, results: [zelda, portal]})) {
|
||||||
if (url.includes('/api/users/me')) return json(200, {role: 'USER', settings: {favoritePlatform}});
|
fetchRoutes({
|
||||||
return init?.method === 'POST' ? post : json(200, {available: true, results: [zelda, portal]});
|
'GET /api/users/me': json(200, {role: 'USER', settings: {favoritePlatform}}),
|
||||||
|
'GET /api/library/game-search': found,
|
||||||
|
'POST /api/library/games': post,
|
||||||
|
'POST /api/library/imports': post,
|
||||||
});
|
});
|
||||||
render(
|
renderWithProviders(<AddGamePanel onClose={() => {}}/>, {path: '/library', entries: ['/library'],
|
||||||
<MemoryRouter initialEntries={['/library']}>
|
routes: {'/library/:appid': <p>detail page</p>, '/import': <p>import page</p>}});
|
||||||
<Routes>
|
|
||||||
<Route path="/library" element={<AddGamePanel onClose={() => {}}/>}/>
|
|
||||||
<Route path="/library/:appid" element={<p>detail page</p>}/>
|
|
||||||
<Route path="/import" element={<p>import page</p>}/>
|
|
||||||
</Routes>
|
|
||||||
</MemoryRouter>,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const searchCalls = () => fetch.mock.calls.filter(([url]) => url.includes('game-search'));
|
const searchCalls = () => fetch.mock.calls.filter(([url]) => url.includes('game-search'));
|
||||||
@@ -118,9 +114,7 @@ describe('AddGamePanel', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('shows that adding is off when Core has no IGDB app', async () => {
|
it('shows that adding is off when Core has no IGDB app', async () => {
|
||||||
renderPanel();
|
renderPanel(undefined, undefined, json(200, {available: false, results: []}));
|
||||||
fetch.mockImplementation(async (url) => (url.includes('/api/users/me')
|
|
||||||
? json(200, {settings: {}}) : json(200, {available: false, results: []})));
|
|
||||||
search('zelda');
|
search('zelda');
|
||||||
|
|
||||||
expect(await screen.findByText('MANUAL_GAME.UNAVAILABLE')).toBeTruthy();
|
expect(await screen.findByText('MANUAL_GAME.UNAVAILABLE')).toBeTruthy();
|
||||||
|
|||||||
@@ -21,10 +21,9 @@ import Logo from './Logo.jsx';
|
|||||||
import PageContainer from './PageContainer.jsx';
|
import PageContainer from './PageContainer.jsx';
|
||||||
import SteamAvatar from './SteamAvatar.jsx';
|
import SteamAvatar from './SteamAvatar.jsx';
|
||||||
import useCurrentUser from '../hooks/useCurrentUser.js';
|
import useCurrentUser from '../hooks/useCurrentUser.js';
|
||||||
import useSessionExpired from '../hooks/useSessionExpired.js';
|
|
||||||
import useSteamProfile from '../hooks/useSteamProfile.js';
|
import useSteamProfile from '../hooks/useSteamProfile.js';
|
||||||
import {ROLES, viewAs} from '../utils/admin.js';
|
import {ROLES, viewAs} from '../utils/admin.js';
|
||||||
import {markSession} from '../utils/auth.js';
|
import {expireSession, markSession} from '../utils/auth.js';
|
||||||
|
|
||||||
const NAV_ITEMS = [
|
const NAV_ITEMS = [
|
||||||
{to: '/dashboard', labelKey: 'NAV.DASHBOARD', Icon: LayoutDashboard},
|
{to: '/dashboard', labelKey: 'NAV.DASHBOARD', Icon: LayoutDashboard},
|
||||||
@@ -48,7 +47,6 @@ export default function AppHeader({isAuthenticated, onLogout}) {
|
|||||||
const steamProfile = useSteamProfile(isAuthenticated);
|
const steamProfile = useSteamProfile(isAuthenticated);
|
||||||
const currentUser = useCurrentUser(isAuthenticated);
|
const currentUser = useCurrentUser(isAuthenticated);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const expireSession = useSessionExpired();
|
|
||||||
const isAdmin = currentUser?.role === 'PLATFORM_ADMIN';
|
const isAdmin = currentUser?.role === 'PLATFORM_ADMIN';
|
||||||
const viewingAs = currentUser && currentUser.activeRole !== currentUser.role ? currentUser.activeRole : null;
|
const viewingAs = currentUser && currentUser.activeRole !== currentUser.role ? currentUser.activeRole : null;
|
||||||
|
|
||||||
@@ -56,7 +54,7 @@ export default function AppHeader({isAuthenticated, onLogout}) {
|
|||||||
try {
|
try {
|
||||||
const response = await viewAs(role === currentUser.role ? null : role);
|
const response = await viewAs(role === currentUser.role ? null : role);
|
||||||
if (response.ok) {
|
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 loads the user and the Steam data again.
|
||||||
markSession();
|
markSession();
|
||||||
navigate('/dashboard');
|
navigate('/dashboard');
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
import {afterEach, describe, expect, it, vi} from 'vitest';
|
import {afterEach, describe, expect, it, vi} from 'vitest';
|
||||||
import {cleanup, fireEvent, render, screen} from '@testing-library/react';
|
import {cleanup, fireEvent, screen} from '@testing-library/react';
|
||||||
import {MemoryRouter} from 'react-router-dom';
|
|
||||||
import {toast} from 'sonner';
|
import {toast} from 'sonner';
|
||||||
import {useTranslation} from 'react-i18next';
|
import {useTranslation} from 'react-i18next';
|
||||||
import AppHeader from './AppHeader.jsx';
|
import AppHeader from './AppHeader.jsx';
|
||||||
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
|
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
||||||
|
import {renderWithProviders} from '../test/renderWithProviders.jsx';
|
||||||
|
|
||||||
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||||
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
|
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
|
||||||
@@ -16,26 +16,18 @@ afterEach(() => {
|
|||||||
useTranslation().i18n.language = 'de';
|
useTranslation().i18n.language = 'de';
|
||||||
});
|
});
|
||||||
|
|
||||||
const loggedInAs = (me) => {
|
const loggedInAs = (me, routes = {}) => {
|
||||||
localStorage.setItem('tbp_session', '1');
|
localStorage.setItem('tbp_session', '1');
|
||||||
globalThis.fetch = vi.fn(async (url) => ({
|
fetchRoutes({'GET /api/users/me': json(200, me), 'GET /api/library/steam-profile': json(200, {}), ...routes});
|
||||||
ok: true, status: 200, json: async () => (url.includes('/api/users/me') ? me : {}),
|
renderWithProviders(<AppHeader isAuthenticated onLogout={() => {}}/>);
|
||||||
}));
|
|
||||||
render(
|
|
||||||
<MemoryRouter>
|
|
||||||
<PreferencesProvider><AppHeader isAuthenticated onLogout={() => {}}/></PreferencesProvider>
|
|
||||||
</MemoryRouter>,
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const openMenu = () => fireEvent.keyDown(screen.getByRole('button', {name: 'NAV.MENU'}), {key: 'Enter'});
|
const openMenu = () => fireEvent.keyDown(screen.getByRole('button', {name: 'NAV.MENU'}), {key: 'Enter'});
|
||||||
|
|
||||||
describe('AppHeader', () => {
|
describe('AppHeader', () => {
|
||||||
it('says so when Auth refuses the language switch', async () => {
|
it('says so when Auth refuses the language switch', async () => {
|
||||||
loggedInAs({role: 'USER', activeRole: 'USER', settings: {language: 'de'}});
|
loggedInAs({role: 'USER', activeRole: 'USER', settings: {language: 'de'}},
|
||||||
fetch.mockImplementation(async (url, init) => (init?.method === 'PUT'
|
{'PUT /api/users/settings': json(500, {})});
|
||||||
? {ok: false, status: 500, json: async () => ({})}
|
|
||||||
: {ok: true, status: 200, json: async () => ({role: 'USER', activeRole: 'USER', settings: {language: 'de'}})}));
|
|
||||||
|
|
||||||
await screen.findByRole('button', {name: 'NAV.MENU'});
|
await screen.findByRole('button', {name: 'NAV.MENU'});
|
||||||
openMenu();
|
openMenu();
|
||||||
@@ -46,11 +38,8 @@ describe('AppHeader', () => {
|
|||||||
|
|
||||||
// jsdom applies no media queries, so the test hides the name the way the 380px breakpoint does.
|
// jsdom applies no media queries, so the test hides the name the way the 380px breakpoint does.
|
||||||
it('keeps a name on the home link when a narrow phone hides the app name', () => {
|
it('keeps a name on the home link when a narrow phone hides the app name', () => {
|
||||||
render(
|
fetchRoutes({});
|
||||||
<MemoryRouter>
|
renderWithProviders(<AppHeader isAuthenticated={false} onLogout={() => {}}/>);
|
||||||
<PreferencesProvider><AppHeader isAuthenticated={false} onLogout={() => {}}/></PreferencesProvider>
|
|
||||||
</MemoryRouter>,
|
|
||||||
);
|
|
||||||
screen.getByText('ToBePlayed').style.display = 'none';
|
screen.getByText('ToBePlayed').style.display = 'none';
|
||||||
|
|
||||||
expect(screen.getByRole('link', {name: 'ToBePlayed'})).toBeTruthy();
|
expect(screen.getByRole('link', {name: 'ToBePlayed'})).toBeTruthy();
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import {useTranslation} from 'react-i18next';
|
||||||
|
import {ImageOff, Palette, RotateCcw} from 'lucide-react';
|
||||||
|
|
||||||
|
import {Button} from '@/components/ui/button';
|
||||||
|
import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
|
||||||
|
import {Label} from '@/components/ui/label';
|
||||||
|
import {Switch} from '@/components/ui/switch';
|
||||||
|
import {AccentPicker, CornersPicker} from './StylePickers.jsx';
|
||||||
|
import {usePreferences} from '../context/PreferencesContext.jsx';
|
||||||
|
import useSaveAppearance from '../hooks/useSaveAppearance.js';
|
||||||
|
import {coverUrl} from '../utils/games.js';
|
||||||
|
|
||||||
|
export default function AppearanceCard() {
|
||||||
|
const {t} = useTranslation();
|
||||||
|
const {appearance} = usePreferences();
|
||||||
|
const saveAppearance = useSaveAppearance();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2"><Palette className="size-4 text-primary"/>{t('SETTINGS.APPEARANCE')}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<AccentPicker id="accent" value={appearance.accent} onChange={(accent) => saveAppearance({accent})}/>
|
||||||
|
<CornersPicker id="corners" value={appearance.corners} onChange={(corners) => saveAppearance({corners})}/>
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="wideLayout">{t('SETTINGS.WIDE_LAYOUT')}</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">{t('SETTINGS.WIDE_LAYOUT_HINT')}</p>
|
||||||
|
</div>
|
||||||
|
<Switch id="wideLayout" checked={appearance.wideLayout}
|
||||||
|
onCheckedChange={(wideLayout) => saveAppearance({wideLayout})}/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label id="background-label">{t('SETTINGS.BACKGROUND')}</Label>
|
||||||
|
{!appearance.backgroundAppid && (
|
||||||
|
<p className="text-xs text-muted-foreground">{t('SETTINGS.BACKGROUND_HINT')}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{appearance.backgroundAppid && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<img src={coverUrl(appearance.backgroundAppid)} alt=""
|
||||||
|
onError={(event) => { event.currentTarget.hidden = true; }}
|
||||||
|
className="h-10 w-20 rounded-md object-cover"/>
|
||||||
|
<Button type="button" variant="outline" size="sm"
|
||||||
|
aria-describedby="background-label"
|
||||||
|
onClick={() => saveAppearance({backgroundAppid: null})}>
|
||||||
|
<ImageOff/>{t('SETTINGS.BACKGROUND_REMOVE')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label id="banner-label" htmlFor="showBanner">{t('SETTINGS.BANNER')}</Label>
|
||||||
|
{appearance.showBanner && !appearance.bannerAppid && (
|
||||||
|
<p className="text-xs text-muted-foreground">{t('SETTINGS.BANNER_HINT')}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Switch id="showBanner" checked={appearance.showBanner}
|
||||||
|
onCheckedChange={(showBanner) => saveAppearance({showBanner})}/>
|
||||||
|
</div>
|
||||||
|
{appearance.showBanner && appearance.bannerAppid && (
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<img src={coverUrl(appearance.bannerAppid)} alt=""
|
||||||
|
onError={(event) => { event.currentTarget.hidden = true; }}
|
||||||
|
className="h-10 w-20 rounded-md object-cover"/>
|
||||||
|
<Button type="button" variant="outline" size="sm"
|
||||||
|
aria-describedby="banner-label"
|
||||||
|
onClick={() => saveAppearance({bannerAppid: null})}>
|
||||||
|
<RotateCcw/>{t('SETTINGS.BANNER_AUTO')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import {useEffect, useState} from 'react';
|
import {useEffect, useState} from 'react';
|
||||||
import {useNavigate} from 'react-router-dom';
|
|
||||||
import {useTranslation} from 'react-i18next';
|
import {useTranslation} from 'react-i18next';
|
||||||
import {toast} from 'sonner';
|
import {toast} from 'sonner';
|
||||||
import {ListTodo, Loader2, Star} from 'lucide-react';
|
import {ListTodo, Loader2, Star} from 'lucide-react';
|
||||||
@@ -11,10 +10,9 @@ import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/c
|
|||||||
import {Textarea} from '@/components/ui/textarea';
|
import {Textarea} from '@/components/ui/textarea';
|
||||||
import {cn} from '@/lib/utils';
|
import {cn} from '@/lib/utils';
|
||||||
import {STATUS_META} from './GameStatusBadge.jsx';
|
import {STATUS_META} from './GameStatusBadge.jsx';
|
||||||
import {NO_PRIORITY, saveBacklogEntry, toBacklogRequest} from '../utils/backlog.js';
|
import {changedFields, NO_PRIORITY, saveBacklogEntry} from '../utils/backlog.js';
|
||||||
import {GAME_PRIORITIES, GAME_STATUSES, MAX_NOTES_LENGTH} from '../utils/limits.js';
|
import {GAME_PRIORITIES, GAME_STATUSES, MAX_NOTES_LENGTH} from '../utils/limits.js';
|
||||||
import useSessionExpired from '../hooks/useSessionExpired.js';
|
import useUnsavedChanges from '../hooks/useUnsavedChanges.js';
|
||||||
import {hasSession} from '../utils/auth.js';
|
|
||||||
|
|
||||||
const toForm = (entry) => ({
|
const toForm = (entry) => ({
|
||||||
status: entry.status ?? 'BACKLOG',
|
status: entry.status ?? 'BACKLOG',
|
||||||
@@ -25,8 +23,6 @@ const toForm = (entry) => ({
|
|||||||
|
|
||||||
export default function BacklogCard({game, onSaved}) {
|
export default function BacklogCard({game, onSaved}) {
|
||||||
const {t} = useTranslation();
|
const {t} = useTranslation();
|
||||||
const navigate = useNavigate();
|
|
||||||
const expireSession = useSessionExpired();
|
|
||||||
const [saved, setSaved] = useState(() => toForm(game));
|
const [saved, setSaved] = useState(() => toForm(game));
|
||||||
const [form, setForm] = useState(saved);
|
const [form, setForm] = useState(saved);
|
||||||
|
|
||||||
@@ -37,21 +33,16 @@ export default function BacklogCard({game, onSaved}) {
|
|||||||
}, [game.status]);
|
}, [game.status]);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
const dirty = JSON.stringify(form) !== JSON.stringify(saved);
|
const changes = changedFields(saved, form);
|
||||||
|
const dirty = Object.keys(changes).length > 0;
|
||||||
|
useUnsavedChanges(dirty);
|
||||||
const set = (field) => (value) => setForm((f) => ({...f, [field]: value}));
|
const set = (field) => (value) => setForm((f) => ({...f, [field]: value}));
|
||||||
|
|
||||||
const save = async () => {
|
const save = async () => {
|
||||||
if (!hasSession()) {
|
|
||||||
navigate('/login');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
const response = await saveBacklogEntry(game.appid, toBacklogRequest(form));
|
const response = await saveBacklogEntry(game.appid, changes);
|
||||||
if (response.status === 401) {
|
if (response.status === 401) return;
|
||||||
expireSession();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
toast.error(t('BACKLOG.SAVE_FAILED'));
|
toast.error(t('BACKLOG.SAVE_FAILED'));
|
||||||
return;
|
return;
|
||||||
@@ -77,72 +68,75 @@ export default function BacklogCard({game, onSaved}) {
|
|||||||
{t('BACKLOG.TITLE')}
|
{t('BACKLOG.TITLE')}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div className="grid gap-5 sm:grid-cols-3">
|
{/* Locked while saving: the answer replaces the form, so an edit made meanwhile would be lost. */}
|
||||||
<div className="space-y-2">
|
<fieldset disabled={busy} className="contents">
|
||||||
<Label htmlFor="backlog-status">{t('BACKLOG.STATUS_LABEL')}</Label>
|
<div className="grid gap-5 sm:grid-cols-3">
|
||||||
<Select value={form.status} onValueChange={set('status')}>
|
<div className="space-y-2">
|
||||||
<SelectTrigger id="backlog-status" className="w-full"><SelectValue/></SelectTrigger>
|
<Label htmlFor="backlog-status">{t('BACKLOG.STATUS_LABEL')}</Label>
|
||||||
<SelectContent>
|
<Select value={form.status} onValueChange={set('status')}>
|
||||||
{GAME_STATUSES.map((s) => {
|
<SelectTrigger id="backlog-status" className="w-full"><SelectValue/></SelectTrigger>
|
||||||
const {icon: Icon, text} = STATUS_META[s];
|
<SelectContent>
|
||||||
return (
|
{GAME_STATUSES.map((s) => {
|
||||||
<SelectItem key={s} value={s}>
|
const {icon: Icon, text} = STATUS_META[s];
|
||||||
<Icon className={text}/>
|
return (
|
||||||
{t(`BACKLOG.STATUS.${s}`)}
|
<SelectItem key={s} value={s}>
|
||||||
</SelectItem>
|
<Icon className={text}/>
|
||||||
);
|
{t(`BACKLOG.STATUS.${s}`)}
|
||||||
})}
|
</SelectItem>
|
||||||
</SelectContent>
|
);
|
||||||
</Select>
|
})}
|
||||||
</div>
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="backlog-priority">{t('BACKLOG.PRIORITY_LABEL')}</Label>
|
<Label htmlFor="backlog-priority">{t('BACKLOG.PRIORITY_LABEL')}</Label>
|
||||||
<Select value={form.priority} onValueChange={set('priority')}>
|
<Select value={form.priority} onValueChange={set('priority')}>
|
||||||
<SelectTrigger id="backlog-priority" className="w-full"><SelectValue/></SelectTrigger>
|
<SelectTrigger id="backlog-priority" className="w-full"><SelectValue/></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value={NO_PRIORITY}>{t('BACKLOG.PRIORITY.NONE')}</SelectItem>
|
<SelectItem value={NO_PRIORITY}>{t('BACKLOG.PRIORITY.NONE')}</SelectItem>
|
||||||
{GAME_PRIORITIES.map((p) => (
|
{GAME_PRIORITIES.map((p) => (
|
||||||
<SelectItem key={p} value={p}>{t(`BACKLOG.PRIORITY.${p}`)}</SelectItem>
|
<SelectItem key={p} value={p}>{t(`BACKLOG.PRIORITY.${p}`)}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label id="backlog-rating">{t('BACKLOG.RATING_LABEL')}</Label>
|
||||||
|
<div role="group" aria-labelledby="backlog-rating" className="flex h-9 items-center gap-1">
|
||||||
|
{[1, 2, 3, 4, 5].map((n) => (
|
||||||
|
<button
|
||||||
|
key={n}
|
||||||
|
type="button"
|
||||||
|
aria-label={t('BACKLOG.RATING_STARS', {count: n})}
|
||||||
|
aria-pressed={form.rating === n}
|
||||||
|
onClick={() => set('rating')(form.rating === n ? 0 : n)}
|
||||||
|
className="rounded-sm p-0.5 outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
|
||||||
|
>
|
||||||
|
<Star className={cn('size-6 transition-colors',
|
||||||
|
n <= form.rating ? 'fill-amber-400 text-amber-400' : 'text-muted-foreground/40 hover:text-amber-400')}/>
|
||||||
|
</button>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</div>
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label id="backlog-rating">{t('BACKLOG.RATING_LABEL')}</Label>
|
|
||||||
<div role="group" aria-labelledby="backlog-rating" className="flex h-9 items-center gap-1">
|
|
||||||
{[1, 2, 3, 4, 5].map((n) => (
|
|
||||||
<button
|
|
||||||
key={n}
|
|
||||||
type="button"
|
|
||||||
aria-label={t('BACKLOG.RATING_STARS', {count: n})}
|
|
||||||
aria-pressed={form.rating === n}
|
|
||||||
onClick={() => set('rating')(form.rating === n ? 0 : n)}
|
|
||||||
className="rounded-sm p-0.5 outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
|
|
||||||
>
|
|
||||||
<Star className={cn('size-6 transition-colors',
|
|
||||||
n <= form.rating ? 'fill-amber-400 text-amber-400' : 'text-muted-foreground/40 hover:text-amber-400')}/>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-baseline justify-between">
|
<div className="flex items-baseline justify-between">
|
||||||
<Label htmlFor="backlog-notes">{t('BACKLOG.NOTES_LABEL')}</Label>
|
<Label htmlFor="backlog-notes">{t('BACKLOG.NOTES_LABEL')}</Label>
|
||||||
<span className="text-xs text-muted-foreground tabular-nums">{form.notes.length} / {MAX_NOTES_LENGTH}</span>
|
<span className="text-xs text-muted-foreground tabular-nums">{form.notes.length} / {MAX_NOTES_LENGTH}</span>
|
||||||
|
</div>
|
||||||
|
<Textarea
|
||||||
|
id="backlog-notes"
|
||||||
|
rows={3}
|
||||||
|
maxLength={MAX_NOTES_LENGTH}
|
||||||
|
placeholder={t('BACKLOG.NOTES_PLACEHOLDER')}
|
||||||
|
value={form.notes}
|
||||||
|
onChange={(e) => set('notes')(e.target.value)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Textarea
|
</fieldset>
|
||||||
id="backlog-notes"
|
|
||||||
rows={3}
|
|
||||||
maxLength={MAX_NOTES_LENGTH}
|
|
||||||
placeholder={t('BACKLOG.NOTES_PLACEHOLDER')}
|
|
||||||
value={form.notes}
|
|
||||||
onChange={(e) => set('notes')(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
<Button variant="ghost" onClick={() => setForm(saved)} disabled={!dirty || busy}>
|
<Button variant="ghost" onClick={() => setForm(saved)} disabled={!dirty || busy}>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||||
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
|
import {act, cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
|
||||||
import {MemoryRouter} from 'react-router-dom';
|
import {MemoryRouter} from 'react-router-dom';
|
||||||
|
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
||||||
import BacklogCard from './BacklogCard.jsx';
|
import BacklogCard from './BacklogCard.jsx';
|
||||||
|
|
||||||
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||||
@@ -13,7 +14,8 @@ const saveButton = () => screen.getByRole('button', {name: 'BACKLOG.SAVE'});
|
|||||||
describe('BacklogCard', () => {
|
describe('BacklogCard', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
localStorage.setItem('tbp_session', '1');
|
localStorage.setItem('tbp_session', '1');
|
||||||
globalThis.fetch = vi.fn(async (url, init) => ({ok: true, status: 200, json: async () => JSON.parse(init.body)}));
|
// Core answers the whole entry; the fields not sent come from the server.
|
||||||
|
fetchRoutes({'PATCH /api/library/games/220/backlog': (url, init) => json(200, {...game, ...JSON.parse(init.body)})});
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -41,8 +43,44 @@ describe('BacklogCard', () => {
|
|||||||
await waitFor(() => expect(onSaved).toHaveBeenCalled());
|
await waitFor(() => expect(onSaved).toHaveBeenCalled());
|
||||||
const [url, init] = fetch.mock.calls[0];
|
const [url, init] = fetch.mock.calls[0];
|
||||||
expect(url).toMatch(/\/api\/library\/games\/220\/backlog$/);
|
expect(url).toMatch(/\/api\/library\/games\/220\/backlog$/);
|
||||||
expect(init.method).toBe('PUT');
|
expect(init.method).toBe('PATCH');
|
||||||
expect(JSON.parse(init.body)).toEqual({status: 'PLAYING', rating: null, notes: null, priority: null});
|
expect(JSON.parse(init.body)).toEqual({rating: null, notes: null});
|
||||||
|
expect(saveButton().disabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends only the changed notes, so an older tab does not overwrite the other fields', async () => {
|
||||||
|
const onSaved = vi.fn();
|
||||||
|
render(<MemoryRouter><BacklogCard game={game} onSaved={onSaved}/></MemoryRouter>);
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByRole('textbox'), {target: {value: 'new notes'}});
|
||||||
|
fireEvent.click(saveButton());
|
||||||
|
|
||||||
|
await waitFor(() => expect(onSaved).toHaveBeenCalled());
|
||||||
|
expect(JSON.parse(fetch.mock.calls[0][1].body)).toEqual({notes: 'new notes'});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('locks the fields while the save runs, so nothing typed then is lost', async () => {
|
||||||
|
let answer;
|
||||||
|
fetchRoutes({'PATCH /api/library/games/220/backlog': (url, init) => new Promise((resolve) => {
|
||||||
|
answer = () => resolve(json(200, {...game, ...JSON.parse(init.body)}));
|
||||||
|
})});
|
||||||
|
render(<MemoryRouter><BacklogCard game={game}/></MemoryRouter>);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', {name: 'BACKLOG.RATING_STARS:5'}));
|
||||||
|
fireEvent.click(saveButton());
|
||||||
|
|
||||||
|
// :disabled, not .disabled: the fieldset locks them, not their own attribute.
|
||||||
|
expect(screen.getByRole('textbox').matches(':disabled')).toBe(true);
|
||||||
|
expect(screen.getByRole('button', {name: 'BACKLOG.RATING_STARS:1'}).matches(':disabled')).toBe(true);
|
||||||
|
await act(async () => answer());
|
||||||
|
expect(screen.getByRole('textbox').matches(':disabled')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not count blank notes instead of none as a change', () => {
|
||||||
|
render(<MemoryRouter><BacklogCard game={{...game, notes: null}}/></MemoryRouter>);
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByRole('textbox'), {target: {value: ' '}});
|
||||||
|
|
||||||
expect(saveButton().disabled).toBe(true);
|
expect(saveButton().disabled).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -5,7 +5,6 @@ import {Loader2, Trophy} from 'lucide-react';
|
|||||||
|
|
||||||
import {Button} from '@/components/ui/button';
|
import {Button} from '@/components/ui/button';
|
||||||
import {Card} from '@/components/ui/card';
|
import {Card} from '@/components/ui/card';
|
||||||
import useSessionExpired from '../hooks/useSessionExpired.js';
|
|
||||||
import {saveGameStatus} from '../utils/backlog.js';
|
import {saveGameStatus} from '../utils/backlog.js';
|
||||||
import {allAchievementsUnlocked} from '../utils/confetti.js';
|
import {allAchievementsUnlocked} from '../utils/confetti.js';
|
||||||
|
|
||||||
@@ -16,7 +15,6 @@ import {allAchievementsUnlocked} from '../utils/confetti.js';
|
|||||||
*/
|
*/
|
||||||
export default function CompletedHint({game, onChanged}) {
|
export default function CompletedHint({game, onChanged}) {
|
||||||
const {t} = useTranslation();
|
const {t} = useTranslation();
|
||||||
const expireSession = useSessionExpired();
|
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
if (!allAchievementsUnlocked(game) || game.status === 'COMPLETED') return null;
|
if (!allAchievementsUnlocked(game) || game.status === 'COMPLETED') return null;
|
||||||
@@ -25,10 +23,7 @@ export default function CompletedHint({game, onChanged}) {
|
|||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
const response = await saveGameStatus(game.appid, 'COMPLETED');
|
const response = await saveGameStatus(game.appid, 'COMPLETED');
|
||||||
if (response.status === 401) {
|
if (response.status === 401) return;
|
||||||
expireSession();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
toast.error(t('BACKLOG.SAVE_FAILED'));
|
toast.error(t('BACKLOG.SAVE_FAILED'));
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||||
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
|
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
|
||||||
import {MemoryRouter} from 'react-router-dom';
|
import {MemoryRouter} from 'react-router-dom';
|
||||||
|
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
||||||
import CompletedHint from './CompletedHint.jsx';
|
import CompletedHint from './CompletedHint.jsx';
|
||||||
|
|
||||||
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||||
@@ -13,9 +14,8 @@ const renderHint = (props) => render(<MemoryRouter><CompletedHint game={game} on
|
|||||||
describe('CompletedHint', () => {
|
describe('CompletedHint', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
localStorage.setItem('tbp_session', '1');
|
localStorage.setItem('tbp_session', '1');
|
||||||
globalThis.fetch = vi.fn(async (url, init) => ({
|
fetchRoutes({'PUT /api/library/games/220/status': (url, init) =>
|
||||||
ok: true, status: 200, json: async () => ({...JSON.parse(init.body), rating: 5, notes: 'keep', priority: null}),
|
json(200, {...JSON.parse(init.body), rating: 5, notes: 'keep', priority: null})});
|
||||||
}));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
|||||||
@@ -49,6 +49,9 @@ function pieces(size, width, height) {
|
|||||||
export default function Confetti({size, onDone}) {
|
export default function Confetti({size, onDone}) {
|
||||||
const canvasRef = useRef(null);
|
const canvasRef = useRef(null);
|
||||||
const [reduce] = useState(reducedMotion);
|
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(() => {
|
useEffect(() => {
|
||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
@@ -67,7 +70,7 @@ export default function Confetti({size, onDone}) {
|
|||||||
context.setTransform(1, 0, 0, 1, 0, 0);
|
context.setTransform(1, 0, 0, 1, 0, 0);
|
||||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
if (elapsed >= DURATION_MS) {
|
if (elapsed >= DURATION_MS) {
|
||||||
onDone?.();
|
onDoneRef.current?.();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
context.globalAlpha = 1 - elapsed / DURATION_MS;
|
context.globalAlpha = 1 - elapsed / DURATION_MS;
|
||||||
@@ -88,7 +91,6 @@ export default function Confetti({size, onDone}) {
|
|||||||
};
|
};
|
||||||
frame = requestAnimationFrame(draw);
|
frame = requestAnimationFrame(draw);
|
||||||
return () => cancelAnimationFrame(frame);
|
return () => cancelAnimationFrame(frame);
|
||||||
// Not on onDone: a new function from a re-render must not restart the animation.
|
|
||||||
}, [size]);
|
}, [size]);
|
||||||
|
|
||||||
if (reduce) return null;
|
if (reduce) return null;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {act, cleanup, fireEvent, render, screen} from '@testing-library/react';
|
|||||||
import {toast} from 'sonner';
|
import {toast} from 'sonner';
|
||||||
import DashboardHint, {AUTO_CLOSE_MS} from './DashboardHint.jsx';
|
import DashboardHint, {AUTO_CLOSE_MS} from './DashboardHint.jsx';
|
||||||
import {hintText} from '../utils/hints.js';
|
import {hintText} from '../utils/hints.js';
|
||||||
|
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
||||||
|
|
||||||
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||||
vi.mock('sonner', () => ({toast: {error: vi.fn()}}));
|
vi.mock('sonner', () => ({toast: {error: vi.fn()}}));
|
||||||
@@ -11,11 +12,13 @@ vi.mock('sonner', () => ({toast: {error: vi.fn()}}));
|
|||||||
const DISMISS = {id: 2, textEn: 'Server update tonight', textDe: 'Heute Nacht Update', kind: 'DISMISS'};
|
const DISMISS = {id: 2, textEn: 'Server update tonight', textDe: 'Heute Nacht Update', kind: 'DISMISS'};
|
||||||
const ACCEPT = {id: 1, textEn: 'Link your Steam account', textDe: null, kind: 'ACCEPT'};
|
const ACCEPT = {id: 1, textEn: 'Link your Steam account', textDe: null, kind: 'ACCEPT'};
|
||||||
|
|
||||||
const answer = (status, body) => ({ok: status < 300, status, headers: new Headers(), json: async () => body});
|
const hintRoutes = (list, done = json(204)) => fetchRoutes({
|
||||||
|
'GET /api/hints': list, 'PUT /api/hints/1/done': done, 'PUT /api/hints/2/done': done,
|
||||||
|
});
|
||||||
const doneCalls = () => fetch.mock.calls.filter(([, init]) => init?.method === 'PUT').map(([url]) => url);
|
const doneCalls = () => fetch.mock.calls.filter(([, init]) => init?.method === 'PUT').map(([url]) => url);
|
||||||
|
|
||||||
const showHints = async (...hints) => {
|
const showHints = async (...hints) => {
|
||||||
globalThis.fetch = vi.fn(async (url, init) => (init?.method ? answer(204) : answer(200, {results: hints})));
|
hintRoutes(json(200, {results: hints}));
|
||||||
render(<DashboardHint/>);
|
render(<DashboardHint/>);
|
||||||
await act(async () => undefined);
|
await act(async () => undefined);
|
||||||
};
|
};
|
||||||
@@ -88,7 +91,7 @@ describe('DashboardHint', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('shows an error when the answer is not stored', async () => {
|
it('shows an error when the answer is not stored', async () => {
|
||||||
globalThis.fetch = vi.fn(async (url, init) => (init?.method ? answer(500) : answer(200, {results: [ACCEPT]})));
|
hintRoutes(json(200, {results: [ACCEPT]}), json(500));
|
||||||
render(<DashboardHint/>);
|
render(<DashboardHint/>);
|
||||||
await act(async () => undefined);
|
await act(async () => undefined);
|
||||||
|
|
||||||
@@ -109,7 +112,7 @@ describe('DashboardHint', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('shows nothing when the hints do not load', async () => {
|
it('shows nothing when the hints do not load', async () => {
|
||||||
globalThis.fetch = vi.fn(async () => answer(500));
|
hintRoutes(json(500));
|
||||||
render(<DashboardHint/>);
|
render(<DashboardHint/>);
|
||||||
await act(async () => undefined);
|
await act(async () => undefined);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import {Link} from 'react-router-dom';
|
||||||
|
import {useTranslation} from 'react-i18next';
|
||||||
|
import {Hash, LayoutGrid, Link2, RefreshCw} from 'lucide-react';
|
||||||
|
|
||||||
|
import {Badge} from '@/components/ui/badge';
|
||||||
|
import {Button} from '@/components/ui/button';
|
||||||
|
import {Card} from '@/components/ui/card';
|
||||||
|
import HeroBanner from './HeroBanner.jsx';
|
||||||
|
import SteamAvatar from './SteamAvatar.jsx';
|
||||||
|
import SyncStatusBadge from './SyncStatusBadge.jsx';
|
||||||
|
import {useCurrentUserContext} from '../context/CurrentUserContext.jsx';
|
||||||
|
|
||||||
|
export default function DashboardWelcomeCard({bannerAppid, avatarUrl, sync, forceSync, editing, onCustomize}) {
|
||||||
|
const {t} = useTranslation();
|
||||||
|
const {user: userData} = useCurrentUserContext();
|
||||||
|
const hasSteamId = !!userData?.steamId;
|
||||||
|
const syncBusy = forceSync.busy || sync.isRunning;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="relative overflow-hidden p-6 md:p-8 flex flex-col md:flex-row md:items-center md:justify-between gap-6">
|
||||||
|
{/* A picked game, else the game played most in the last two weeks. */}
|
||||||
|
<HeroBanner appid={bannerAppid}/>
|
||||||
|
<div className="relative flex min-w-0 items-center gap-4">
|
||||||
|
{/* The slot is there before the avatar loads, so the title does not jump. */}
|
||||||
|
{hasSteamId && (
|
||||||
|
<span className="flex size-16 shrink-0 items-center justify-center rounded-full bg-muted ring-2 ring-primary/60">
|
||||||
|
<SteamAvatar url={avatarUrl} className="size-16" iconClassName="size-8 text-muted-foreground"/>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">{t('DASHBOARD.WELCOME_BACK')}</p>
|
||||||
|
<h1 className="mt-1 text-3xl md:text-4xl font-bold tracking-tight truncate">
|
||||||
|
{t('DASHBOARD.HELLO')} {userData?.displayName ?? userData?.email?.split('@')[0]} 👋
|
||||||
|
</h1>
|
||||||
|
{hasSteamId ? (
|
||||||
|
<Badge variant="outline" className="mt-3 gap-1.5 font-mono text-muted-foreground">
|
||||||
|
<Hash className="size-3"/>
|
||||||
|
{userData.steamId}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Button asChild variant="link" className="mt-2 h-auto p-0">
|
||||||
|
<Link to="/settings"><Link2 className="size-4"/>{t('DASHBOARD.NO_STEAM_ID')}</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative flex flex-col items-start md:items-end gap-3 shrink-0">
|
||||||
|
{hasSteamId && (
|
||||||
|
<>
|
||||||
|
<SyncStatusBadge
|
||||||
|
status={sync.status}
|
||||||
|
lastSyncedAt={sync.lastSyncedAt}
|
||||||
|
isRunning={sync.isRunning}
|
||||||
|
stale={sync.stale}
|
||||||
|
/>
|
||||||
|
<Button onClick={forceSync.trigger} disabled={syncBusy}>
|
||||||
|
<RefreshCw className={syncBusy ? 'animate-spin' : ''}/>
|
||||||
|
{t('LIBRARY.RESYNC')}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Button variant="ghost" size="sm" disabled={editing} onClick={onCustomize}>
|
||||||
|
<LayoutGrid/>{t('TILES.CUSTOMIZE')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -18,8 +18,6 @@ import {
|
|||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import {Input} from '@/components/ui/input';
|
import {Input} from '@/components/ui/input';
|
||||||
import {Label} from '@/components/ui/label';
|
import {Label} from '@/components/ui/label';
|
||||||
import {useResetAppearance} from '../context/PreferencesContext.jsx';
|
|
||||||
import useSessionExpired from '../hooks/useSessionExpired.js';
|
|
||||||
import {deleteAccount} from '../utils/account.js';
|
import {deleteAccount} from '../utils/account.js';
|
||||||
import {clearSession} from '../utils/auth.js';
|
import {clearSession} from '../utils/auth.js';
|
||||||
|
|
||||||
@@ -32,8 +30,6 @@ const sameEmail = (typed, email) => !!email && typed.trim().toLowerCase() === em
|
|||||||
export default function DeleteAccountCard({email}) {
|
export default function DeleteAccountCard({email}) {
|
||||||
const {t} = useTranslation();
|
const {t} = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const resetAppearance = useResetAppearance();
|
|
||||||
const expireSession = useSessionExpired();
|
|
||||||
const [typedEmail, setTypedEmail] = useState('');
|
const [typedEmail, setTypedEmail] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
@@ -43,20 +39,19 @@ export default function DeleteAccountCard({email}) {
|
|||||||
try {
|
try {
|
||||||
const response = await deleteAccount(password);
|
const response = await deleteAccount(password);
|
||||||
if (response.status === 204) {
|
if (response.status === 204) {
|
||||||
clearSession();
|
|
||||||
resetAppearance();
|
|
||||||
toast.success(t('SETTINGS.DELETE_ACCOUNT.DONE'));
|
toast.success(t('SETTINGS.DELETE_ACCOUNT.DONE'));
|
||||||
navigate('/', {replace: true});
|
// First away from the settings, which would open the login page without a session. flushSync: the
|
||||||
|
// router shows the new page before the session change renders, not later in a transition.
|
||||||
|
await navigate('/', {replace: true, flushSync: true});
|
||||||
|
clearSession();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (response.status === 401) {
|
if (response.status === 403) {
|
||||||
expireSession();
|
|
||||||
} else if (response.status === 403) {
|
|
||||||
toast.error(t('SETTINGS.DELETE_ACCOUNT.WRONG_PASSWORD'));
|
toast.error(t('SETTINGS.DELETE_ACCOUNT.WRONG_PASSWORD'));
|
||||||
} else if (response.status === 429) {
|
} else if (response.status === 429) {
|
||||||
const minutes = Math.ceil(Number(response.headers.get('Retry-After') ?? 900) / 60);
|
const minutes = Math.ceil(Number(response.headers.get('Retry-After') ?? 900) / 60);
|
||||||
toast.error(t('SETTINGS.DELETE_ACCOUNT.TOO_MANY', {minutes}));
|
toast.error(t('SETTINGS.DELETE_ACCOUNT.TOO_MANY', {minutes}));
|
||||||
} else {
|
} else if (response.status !== 401) {
|
||||||
toast.error(t('SETTINGS.DELETE_ACCOUNT.ERROR'));
|
toast.error(t('SETTINGS.DELETE_ACCOUNT.ERROR'));
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -1,25 +1,25 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||||
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
|
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
|
||||||
import {MemoryRouter, Route, Routes} from 'react-router-dom';
|
import {createMemoryRouter, RouterProvider} from 'react-router-dom';
|
||||||
|
import RequireSession from './RequireSession.jsx';
|
||||||
import {toast} from 'sonner';
|
import {toast} from 'sonner';
|
||||||
import DeleteAccountCard from './DeleteAccountCard.jsx';
|
import DeleteAccountCard from './DeleteAccountCard.jsx';
|
||||||
|
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
||||||
|
|
||||||
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||||
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
|
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
|
||||||
|
|
||||||
const EMAIL = 'player@example.com';
|
const EMAIL = 'player@example.com';
|
||||||
|
|
||||||
const answer = (status, headers = {}) => ({ok: status < 300, status, headers: new Headers(headers), json: async () => ({})});
|
const deleteAnswers = (answer, routes = {}) => fetchRoutes({'DELETE /api/users/me': answer, ...routes});
|
||||||
|
|
||||||
const renderCard = () => render(
|
// A data router like main.jsx, without the providers: their /me load would change the request order.
|
||||||
<MemoryRouter initialEntries={['/settings']}>
|
const renderCard = () => render(<RouterProvider router={createMemoryRouter([
|
||||||
<Routes>
|
{element: <RequireSession/>, children: [{path: '/settings', element: <DeleteAccountCard email={EMAIL}/>}]},
|
||||||
<Route path="/settings" element={<DeleteAccountCard email={EMAIL}/>}/>
|
{path: '/', element: <p>landing</p>},
|
||||||
<Route path="/" element={<p>landing</p>}/>
|
{path: '/login', element: <p>login</p>},
|
||||||
<Route path="/login" element={<p>login</p>}/>
|
], {initialEntries: ['/settings']})}/>);
|
||||||
</Routes>
|
|
||||||
</MemoryRouter>);
|
|
||||||
|
|
||||||
const openAndFill = (email, password) => {
|
const openAndFill = (email, password) => {
|
||||||
fireEvent.click(screen.getByRole('button', {name: 'SETTINGS.DELETE_ACCOUNT.BUTTON'}));
|
fireEvent.click(screen.getByRole('button', {name: 'SETTINGS.DELETE_ACCOUNT.BUTTON'}));
|
||||||
@@ -32,7 +32,7 @@ const confirmButton = () => screen.getByRole('button', {name: 'SETTINGS.DELETE_A
|
|||||||
describe('DeleteAccountCard', () => {
|
describe('DeleteAccountCard', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
localStorage.setItem('tbp_session', '1');
|
localStorage.setItem('tbp_session', '1');
|
||||||
globalThis.fetch = vi.fn(async () => answer(204));
|
deleteAnswers(json(204, {}));
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -67,7 +67,7 @@ describe('DeleteAccountCard', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('keeps the dialog open on a wrong password', async () => {
|
it('keeps the dialog open on a wrong password', async () => {
|
||||||
fetch.mockResolvedValue(answer(403));
|
deleteAnswers(json(403, {}));
|
||||||
renderCard();
|
renderCard();
|
||||||
openAndFill(EMAIL, 'wrong');
|
openAndFill(EMAIL, 'wrong');
|
||||||
|
|
||||||
@@ -79,7 +79,7 @@ describe('DeleteAccountCard', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('shows the lock message after too many wrong passwords', async () => {
|
it('shows the lock message after too many wrong passwords', async () => {
|
||||||
fetch.mockResolvedValue(answer(429, {'Retry-After': '900'}));
|
deleteAnswers({...json(429, {}), headers: new Headers({'Retry-After': '900'})});
|
||||||
renderCard();
|
renderCard();
|
||||||
openAndFill(EMAIL, 'wrong');
|
openAndFill(EMAIL, 'wrong');
|
||||||
|
|
||||||
@@ -89,7 +89,7 @@ describe('DeleteAccountCard', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('opens the login page when the session is gone', async () => {
|
it('opens the login page when the session is gone', async () => {
|
||||||
fetch.mockResolvedValue(answer(401));
|
deleteAnswers(json(401, {}), {'POST /api/auth/refresh': json(401, {})});
|
||||||
renderCard();
|
renderCard();
|
||||||
openAndFill(EMAIL, 'secret');
|
openAndFill(EMAIL, 'secret');
|
||||||
|
|
||||||
@@ -99,7 +99,9 @@ describe('DeleteAccountCard', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('shows the network error when Auth does not answer', async () => {
|
it('shows the network error when Auth does not answer', async () => {
|
||||||
fetch.mockRejectedValue(new TypeError('Failed to fetch'));
|
deleteAnswers(() => {
|
||||||
|
throw new TypeError('Failed to fetch');
|
||||||
|
});
|
||||||
renderCard();
|
renderCard();
|
||||||
openAndFill(EMAIL, 'secret');
|
openAndFill(EMAIL, 'secret');
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import {Flag, Loader2} from 'lucide-react';
|
|||||||
|
|
||||||
import {Button} from '@/components/ui/button';
|
import {Button} from '@/components/ui/button';
|
||||||
import {Card} from '@/components/ui/card';
|
import {Card} from '@/components/ui/card';
|
||||||
import useSessionExpired from '../hooks/useSessionExpired.js';
|
|
||||||
import {dismissFinishedHint, saveGameStatus} from '../utils/backlog.js';
|
import {dismissFinishedHint, saveGameStatus} from '../utils/backlog.js';
|
||||||
import {formatPlaytime} from '../utils/games.js';
|
import {formatPlaytime} from '../utils/games.js';
|
||||||
|
|
||||||
@@ -17,7 +16,6 @@ import {formatPlaytime} from '../utils/games.js';
|
|||||||
*/
|
*/
|
||||||
export default function FinishedHint({game, onChanged, onDismissed}) {
|
export default function FinishedHint({game, onChanged, onDismissed}) {
|
||||||
const {t} = useTranslation();
|
const {t} = useTranslation();
|
||||||
const expireSession = useSessionExpired();
|
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
const hint = game.finishedHint;
|
const hint = game.finishedHint;
|
||||||
@@ -28,10 +26,7 @@ export default function FinishedHint({game, onChanged, onDismissed}) {
|
|||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
const response = await request();
|
const response = await request();
|
||||||
if (response.status === 401) {
|
if (response.status === 401) return;
|
||||||
expireSession();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
toast.error(t('BACKLOG.SAVE_FAILED'));
|
toast.error(t('BACKLOG.SAVE_FAILED'));
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||||
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
|
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
|
||||||
import {MemoryRouter, Route, Routes} from 'react-router-dom';
|
import {MemoryRouter, Route, Routes} from 'react-router-dom';
|
||||||
|
import RequireSession from './RequireSession.jsx';
|
||||||
import FinishedHint from './FinishedHint.jsx';
|
import FinishedHint from './FinishedHint.jsx';
|
||||||
|
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
||||||
|
|
||||||
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||||
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
|
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
|
||||||
@@ -12,7 +14,9 @@ const ending = {appid: 322110, status: 'PLAYING', playtimeMinutes: 600, timeToBe
|
|||||||
const renderHint = (props) => render(
|
const renderHint = (props) => render(
|
||||||
<MemoryRouter initialEntries={['/library/322110']}>
|
<MemoryRouter initialEntries={['/library/322110']}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/library/:appid" element={<FinishedHint game={ending} onChanged={() => {}} onDismissed={() => {}} {...props}/>}/>
|
<Route element={<RequireSession/>}>
|
||||||
|
<Route path="/library/:appid" element={<FinishedHint game={ending} onChanged={() => {}} onDismissed={() => {}} {...props}/>}/>
|
||||||
|
</Route>
|
||||||
<Route path="/login" element={<p>login page</p>}/>
|
<Route path="/login" element={<p>login page</p>}/>
|
||||||
</Routes>
|
</Routes>
|
||||||
</MemoryRouter>);
|
</MemoryRouter>);
|
||||||
@@ -21,9 +25,11 @@ const puts = () => fetch.mock.calls.filter(([, init]) => init?.method === 'PUT')
|
|||||||
describe('FinishedHint', () => {
|
describe('FinishedHint', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
localStorage.setItem('tbp_session', '1');
|
localStorage.setItem('tbp_session', '1');
|
||||||
globalThis.fetch = vi.fn(async (url, init) => (String(url).endsWith('/finished-hint')
|
fetchRoutes({
|
||||||
? {ok: true, status: 204}
|
'PUT /api/library/games/322110/status': (url, init) =>
|
||||||
: {ok: true, status: 200, json: async () => ({...JSON.parse(init.body), rating: null, notes: null, priority: null})}));
|
json(200, {...JSON.parse(init.body), rating: null, notes: null, priority: null}),
|
||||||
|
'PUT /api/library/games/322110/finished-hint': json(204),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -80,7 +86,10 @@ describe('FinishedHint', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('ends the session on a 401', async () => {
|
it('ends the session on a 401', async () => {
|
||||||
globalThis.fetch = vi.fn(async () => ({ok: false, status: 401}));
|
fetchRoutes({
|
||||||
|
'PUT /api/library/games/322110/finished-hint': json(401, {}),
|
||||||
|
'POST /api/auth/refresh': json(401, {}),
|
||||||
|
});
|
||||||
renderHint();
|
renderHint();
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', {name: 'GAME_DETAIL.NOT_YET'}));
|
fireEvent.click(screen.getByRole('button', {name: 'GAME_DETAIL.NOT_YET'}));
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import {useMemo, useState} from 'react';
|
||||||
|
import {useTranslation} from 'react-i18next';
|
||||||
|
import {Trophy} from 'lucide-react';
|
||||||
|
|
||||||
|
import {Card} from '@/components/ui/card';
|
||||||
|
import {cn} from '@/lib/utils';
|
||||||
|
import {ACHIEVEMENT_ICONS, reportBrokenImage} from '../utils/brokenImages.js';
|
||||||
|
|
||||||
|
const formatUnlockedAt = (instant, locale) => {
|
||||||
|
if (!instant) return '';
|
||||||
|
try {
|
||||||
|
return new Date(instant).toLocaleDateString(locale, {year: 'numeric', month: 'short', day: 'numeric'});
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function AchievementIcon({appid, src, unlocked}) {
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
if (!src || failed) return <div className="size-12 rounded-md bg-muted"/>;
|
||||||
|
return (
|
||||||
|
<img src={src} alt="" className={cn('size-12 rounded-md', !unlocked && 'grayscale')}
|
||||||
|
onError={() => {
|
||||||
|
reportBrokenImage(appid, ACHIEVEMENT_ICONS);
|
||||||
|
setFailed(true);
|
||||||
|
}}/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function GameAchievements({game}) {
|
||||||
|
const {t, i18n} = useTranslation();
|
||||||
|
|
||||||
|
const sortedAchievements = useMemo(() => {
|
||||||
|
if (!game.achievements) return [];
|
||||||
|
return [...game.achievements].sort((a, b) => {
|
||||||
|
if (a.unlocked !== b.unlocked) return a.unlocked ? -1 : 1;
|
||||||
|
if (a.unlocked && b.unlocked) {
|
||||||
|
return (b.unlockedAt ?? '').localeCompare(a.unlockedAt ?? '');
|
||||||
|
}
|
||||||
|
return (a.displayName ?? a.apiName).localeCompare(b.displayName ?? b.apiName);
|
||||||
|
});
|
||||||
|
}, [game.achievements]);
|
||||||
|
|
||||||
|
const stats = [
|
||||||
|
[t('GAME_DETAIL.UNLOCKED'), game.achievementsUnlocked],
|
||||||
|
[t('GAME_DETAIL.REMAINING'), game.achievementsRemaining],
|
||||||
|
[t('GAME_DETAIL.COMPLETION'), `${game.completionPercent}%`],
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Card className="p-6 gap-5">
|
||||||
|
<div className="flex items-baseline justify-between">
|
||||||
|
<h2 className="flex items-center gap-2 text-lg font-semibold">
|
||||||
|
<Trophy className="size-4 text-amber-500"/>
|
||||||
|
{t('GAME_DETAIL.ACHIEVEMENTS')}
|
||||||
|
</h2>
|
||||||
|
<span className="text-sm text-muted-foreground tabular-nums">
|
||||||
|
{game.achievementsUnlocked} / {game.achievementsTotal}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2.5 w-full overflow-hidden rounded-full bg-muted">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-primary transition-all"
|
||||||
|
style={{width: `${Math.min(100, game.completionPercent)}%`}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-3 text-center">
|
||||||
|
{stats.map(([label, value]) => (
|
||||||
|
<div key={label} className="rounded-lg bg-muted/50 p-3">
|
||||||
|
<div className="text-2xl font-bold tabular-nums">{value}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">{label}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<ul className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
|
{sortedAchievements.map((a) => {
|
||||||
|
const icon = a.unlocked ? a.iconUrl : a.iconGrayUrl;
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={a.apiName}
|
||||||
|
className={cn(
|
||||||
|
'flex items-start gap-3 rounded-xl border bg-card p-3',
|
||||||
|
a.unlocked ? 'border-primary/40' : 'opacity-60',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<AchievementIcon key={icon} appid={game.appid} src={icon} unlocked={a.unlocked}/>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="truncate font-medium">{a.displayName ?? a.apiName}</div>
|
||||||
|
{a.description && (
|
||||||
|
<div className="mt-1 line-clamp-2 text-xs text-muted-foreground">{a.description}</div>
|
||||||
|
)}
|
||||||
|
{a.unlocked && a.unlockedAt && (
|
||||||
|
<div className="mt-1 text-xs text-primary tabular-nums">
|
||||||
|
{formatUnlockedAt(a.unlockedAt, i18n.language)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,7 +8,6 @@ import {Button} from '@/components/ui/button';
|
|||||||
import {Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle} from '@/components/ui/dialog';
|
import {Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle} from '@/components/ui/dialog';
|
||||||
import {Input} from '@/components/ui/input';
|
import {Input} from '@/components/ui/input';
|
||||||
import {Label} from '@/components/ui/label';
|
import {Label} from '@/components/ui/label';
|
||||||
import useSessionExpired from '../hooks/useSessionExpired.js';
|
|
||||||
import {CORRECTABLE_MOODS, IGDB_GENRES, resetCorrection, saveCorrection} from '../utils/corrections.js';
|
import {CORRECTABLE_MOODS, IGDB_GENRES, resetCorrection, saveCorrection} from '../utils/corrections.js';
|
||||||
import {genreKey} from '../utils/stats.js';
|
import {genreKey} from '../utils/stats.js';
|
||||||
import {MAX_CORRECTION_GENRES, MAX_CORRECTION_LENGTH} from '../utils/limits.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}) {
|
export default function GameCorrectionDialog({game, open, onOpenChange, onChanged}) {
|
||||||
const {t} = useTranslation();
|
const {t} = useTranslation();
|
||||||
const expireSession = useSessionExpired();
|
|
||||||
const [values, setValues] = useState(() => initialValues(game));
|
const [values, setValues] = useState(() => initialValues(game));
|
||||||
const [busy, setBusy] = useState(null);
|
const [busy, setBusy] = useState(null);
|
||||||
// The field whose save or reset the next game from GameDetail answers; the other fields keep their edits.
|
// 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(() => {
|
useEffect(() => {
|
||||||
if (open) setValues(initialValues(game));
|
if (open) setValues(initialValues(game));
|
||||||
// Only when the dialog opens; a reload after a save is handled below.
|
// Only when the dialog opens; a reload after a save is handled below.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -61,10 +60,7 @@ export default function GameCorrectionDialog({game, open, onOpenChange, onChange
|
|||||||
setBusy(field);
|
setBusy(field);
|
||||||
try {
|
try {
|
||||||
const response = await request();
|
const response = await request();
|
||||||
if (response.status === 401) {
|
if (response.status === 401) return;
|
||||||
expireSession();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
toast.success(t('CORRECTION.SAVED'));
|
toast.success(t('CORRECTION.SAVED'));
|
||||||
sentField.current = field;
|
sentField.current = field;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/reac
|
|||||||
import {MemoryRouter} from 'react-router-dom';
|
import {MemoryRouter} from 'react-router-dom';
|
||||||
import {toast} from 'sonner';
|
import {toast} from 'sonner';
|
||||||
import GameCorrectionDialog from './GameCorrectionDialog.jsx';
|
import GameCorrectionDialog from './GameCorrectionDialog.jsx';
|
||||||
|
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
||||||
|
|
||||||
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||||
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
|
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
|
||||||
@@ -14,7 +15,11 @@ const game = {
|
|||||||
corrections: [{field: 'NAME', correctedAt: '2026-09-28T10:00:00Z'}],
|
corrections: [{field: 'NAME', correctedAt: '2026-09-28T10:00:00Z'}],
|
||||||
};
|
};
|
||||||
|
|
||||||
const answer = (status, body = {}) => ({ok: status < 300, status, json: async () => body});
|
const GAME_URL = '/api/library/games/620/corrections';
|
||||||
|
const corrections = (answer) => fetchRoutes({
|
||||||
|
[`PUT ${GAME_URL}/name`]: answer, [`PUT ${GAME_URL}/image`]: answer, [`PUT ${GAME_URL}/speedrun`]: answer,
|
||||||
|
[`PUT ${GAME_URL}/moods`]: answer, [`DELETE ${GAME_URL}/name`]: answer,
|
||||||
|
});
|
||||||
|
|
||||||
const renderDialog = (onChanged = vi.fn()) => {
|
const renderDialog = (onChanged = vi.fn()) => {
|
||||||
render(<MemoryRouter><GameCorrectionDialog game={game} open onOpenChange={vi.fn()} onChanged={onChanged}/></MemoryRouter>);
|
render(<MemoryRouter><GameCorrectionDialog game={game} open onOpenChange={vi.fn()} onChanged={onChanged}/></MemoryRouter>);
|
||||||
@@ -28,7 +33,7 @@ const sent = () => fetch.mock.calls.map(([url, init]) => ({url, method: init?.me
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
localStorage.setItem('tbp_session', '1');
|
localStorage.setItem('tbp_session', '1');
|
||||||
globalThis.fetch = vi.fn(async () => answer(204));
|
corrections(json(204, {}));
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -75,7 +80,7 @@ describe('GameCorrectionDialog', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('shows why Core refused a value', async () => {
|
it('shows why Core refused a value', async () => {
|
||||||
globalThis.fetch = vi.fn(async () => answer(400, {code: 'INVALID_CORRECTION', message: 'image must be https'}));
|
corrections(json(400, {code: 'INVALID_CORRECTION', message: 'image must be https'}));
|
||||||
const onChanged = renderDialog();
|
const onChanged = renderDialog();
|
||||||
|
|
||||||
fireEvent.change(screen.getByLabelText('CORRECTION.FIELD.IMAGE'), {target: {value: 'http://x'}});
|
fireEvent.change(screen.getByLabelText('CORRECTION.FIELD.IMAGE'), {target: {value: 'http://x'}});
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import {useState} from 'react';
|
||||||
|
import {useTranslation} from 'react-i18next';
|
||||||
|
import {Clock, ExternalLink, Eye, EyeOff, Gamepad2, Image as ImageIcon, ImageOff, Merge, PanelTop, PencilLine, RotateCcw, Timer} from 'lucide-react';
|
||||||
|
import {toast} from 'sonner';
|
||||||
|
|
||||||
|
import {Badge} from '@/components/ui/badge';
|
||||||
|
import {Button} from '@/components/ui/button';
|
||||||
|
import {Card} from '@/components/ui/card';
|
||||||
|
import GameCover from './GameCover.jsx';
|
||||||
|
import GameStatusBadge from './GameStatusBadge.jsx';
|
||||||
|
import ProfileArtMenu from './ProfileArtMenu.jsx';
|
||||||
|
import TimeToBeat from './TimeToBeat.jsx';
|
||||||
|
import useSaveAppearance from '../hooks/useSaveAppearance.js';
|
||||||
|
import {usePreferences} from '../context/PreferencesContext.jsx';
|
||||||
|
import {formatPlaytime} from '../utils/games.js';
|
||||||
|
import {setGameHidden} from '../utils/hiddenGames.js';
|
||||||
|
import {isSteamKey} from '../utils/manualGames.js';
|
||||||
|
import {platformLabel} from '../utils/platforms.js';
|
||||||
|
|
||||||
|
const SPEEDRUN_PREFIX = 'https://www.speedrun.com/';
|
||||||
|
|
||||||
|
export default function GameHeroCard({game, manual, auditor, profileStyle, onCorrect, onMerge, onHiddenChanged, onProfileStyleSaved}) {
|
||||||
|
const {t} = useTranslation();
|
||||||
|
const {appearance} = usePreferences();
|
||||||
|
const saveAppearance = useSaveAppearance();
|
||||||
|
const [hiding, setHiding] = useState(false);
|
||||||
|
|
||||||
|
const onSteam = isSteamKey(game.appid);
|
||||||
|
// Core checks the link too; a link from the API must never run script.
|
||||||
|
const speedrunUrl = typeof game.speedrunUrl === 'string' && game.speedrunUrl.startsWith(SPEEDRUN_PREFIX)
|
||||||
|
? game.speedrunUrl : null;
|
||||||
|
const isBackground = appearance.backgroundAppid === game.appid;
|
||||||
|
const isBanner = appearance.showBanner && appearance.bannerAppid === game.appid;
|
||||||
|
|
||||||
|
const toggleHidden = async () => {
|
||||||
|
const hidden = !game.hidden;
|
||||||
|
setHiding(true);
|
||||||
|
try {
|
||||||
|
const response = await setGameHidden(game.appid, hidden);
|
||||||
|
if (response.status === 401) return;
|
||||||
|
if (!response.ok) {
|
||||||
|
toast.error(t('GAME_DETAIL.HIDE_ERROR'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onHiddenChanged(hidden);
|
||||||
|
toast.success(t(hidden ? 'GAME_DETAIL.HIDDEN_TOAST' : 'GAME_DETAIL.SHOWN_TOAST'));
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Hide game error:', err);
|
||||||
|
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
|
||||||
|
} finally {
|
||||||
|
setHiding(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="relative overflow-hidden p-0 gap-0 md:flex-row">
|
||||||
|
<GameCover appid={game.appid} imageUrl={game.imageUrl} className="w-full rounded-none md:w-96 md:shrink-0"/>
|
||||||
|
<div className="flex flex-1 flex-col justify-between gap-4 p-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl md:text-3xl font-bold tracking-tight">
|
||||||
|
{game.name ?? `App ${game.appid}`}
|
||||||
|
</h1>
|
||||||
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
|
<Badge variant="secondary" className="tabular-nums">
|
||||||
|
<Clock className="size-3"/>
|
||||||
|
{t('GAME_DETAIL.PLAYTIME', {time: formatPlaytime(game.playtimeMinutes)})}
|
||||||
|
</Badge>
|
||||||
|
<GameStatusBadge status={game.status}/>
|
||||||
|
{platformLabel(game) && (
|
||||||
|
<Badge variant="outline"><Gamepad2 className="size-3"/>{platformLabel(game)}</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-3">
|
||||||
|
<TimeToBeat time={game.timeToBeat}/>
|
||||||
|
</div>
|
||||||
|
{speedrunUrl && (
|
||||||
|
<Button asChild variant="outline" size="sm" className="mt-3">
|
||||||
|
<a href={speedrunUrl} target="_blank" rel="noopener noreferrer">
|
||||||
|
<Timer/>{t('GAME_DETAIL.OPEN_IN_SPEEDRUN')}<ExternalLink/>
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{auditor && (
|
||||||
|
<Button variant="outline" size="sm" className="mt-3 ml-2" onClick={onCorrect}>
|
||||||
|
<PencilLine/>{t('CORRECTION.OPEN')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{auditor && (
|
||||||
|
<Button variant="outline" size="sm" className="mt-3 ml-2" onClick={onMerge}>
|
||||||
|
<Merge/>{t('MERGE.OPEN')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Store page, background and banner need Steam art, so a game off Steam has none of them.
|
||||||
|
Hide is here too: only a Steam game can be hidden. */}
|
||||||
|
{onSteam && <div className="flex flex-wrap gap-2">
|
||||||
|
<Button asChild variant="outline" size="sm">
|
||||||
|
<a href={`https://store.steampowered.com/app/${game.appid}`} target="_blank" rel="noopener noreferrer">
|
||||||
|
{t('GAME_DETAIL.OPEN_IN_STEAM')}<ExternalLink/>
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
{isBackground ? (
|
||||||
|
<Button variant="outline" size="sm" onClick={() => saveAppearance({backgroundAppid: null})}>
|
||||||
|
<ImageOff/>{t('GAME_DETAIL.REMOVE_BACKGROUND')}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button variant="outline" size="sm" onClick={() => saveAppearance({backgroundAppid: game.appid})}>
|
||||||
|
<ImageIcon/>{t('GAME_DETAIL.SET_BACKGROUND')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{isBanner ? (
|
||||||
|
<Button variant="outline" size="sm" onClick={() => saveAppearance({bannerAppid: null})}>
|
||||||
|
<RotateCcw/>{t('SETTINGS.BANNER_AUTO')}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button variant="outline" size="sm"
|
||||||
|
onClick={() => saveAppearance({bannerAppid: game.appid, showBanner: true})}>
|
||||||
|
<PanelTop/>{t('GAME_DETAIL.SET_BANNER')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{profileStyle && (
|
||||||
|
<ProfileArtMenu appid={game.appid} style={profileStyle} onSaved={onProfileStyleSaved}/>
|
||||||
|
)}
|
||||||
|
{!manual && (
|
||||||
|
<Button variant="outline" size="sm" onClick={toggleHidden} disabled={hiding}>
|
||||||
|
{game.hidden ? <Eye/> : <EyeOff/>}
|
||||||
|
{t(game.hidden ? 'GAME_DETAIL.UNHIDE' : 'GAME_DETAIL.HIDE')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,7 +6,6 @@ import {toast} from 'sonner';
|
|||||||
import {Button} from '@/components/ui/button';
|
import {Button} from '@/components/ui/button';
|
||||||
import {Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle} from '@/components/ui/dialog';
|
import {Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle} from '@/components/ui/dialog';
|
||||||
import GameSearch from './GameSearch.jsx';
|
import GameSearch from './GameSearch.jsx';
|
||||||
import useSessionExpired from '../hooks/useSessionExpired.js';
|
|
||||||
import {mergeGame} from '../utils/corrections.js';
|
import {mergeGame} from '../utils/corrections.js';
|
||||||
import {isSteamKey} from '../utils/manualGames.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}) {
|
export default function GameMergeDialog({game, open, onOpenChange, onMerged}) {
|
||||||
const {t} = useTranslation();
|
const {t} = useTranslation();
|
||||||
const expireSession = useSessionExpired();
|
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
const [picked, setPicked] = useState(null);
|
const [picked, setPicked] = useState(null);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
@@ -34,10 +32,7 @@ export default function GameMergeDialog({game, open, onOpenChange, onMerged}) {
|
|||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
const response = await mergeGame(game.appid, picked.appid);
|
const response = await mergeGame(game.appid, picked.appid);
|
||||||
if (response.status === 401) {
|
if (response.status === 401) return;
|
||||||
expireSession();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const data = await response.json().catch(() => ({}));
|
const data = await response.json().catch(() => ({}));
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
toast.success(t('MERGE.DONE'));
|
toast.success(t('MERGE.DONE'));
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/reac
|
|||||||
import {MemoryRouter} from 'react-router-dom';
|
import {MemoryRouter} from 'react-router-dom';
|
||||||
import {toast} from 'sonner';
|
import {toast} from 'sonner';
|
||||||
import GameMergeDialog from './GameMergeDialog.jsx';
|
import GameMergeDialog from './GameMergeDialog.jsx';
|
||||||
|
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
||||||
|
|
||||||
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||||
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
|
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
|
||||||
@@ -12,12 +13,11 @@ const hades = {appid: 1145360, name: 'Hades'};
|
|||||||
const hit = (appid, name) => ({appid, igdbId: Math.abs(appid), name, year: 2020, imageUrl: null,
|
const hit = (appid, name) => ({appid, igdbId: Math.abs(appid), name, year: 2020, imageUrl: null,
|
||||||
platforms: ['Switch'], inLibrary: false, inCatalog: true});
|
platforms: ['Switch'], inLibrary: false, inCatalog: true});
|
||||||
|
|
||||||
const json = (status, body) => ({ok: status < 400, status, json: async () => body});
|
|
||||||
|
|
||||||
function renderDialog(game, post, results) {
|
function renderDialog(game, post, results) {
|
||||||
globalThis.fetch = vi.fn(async (url, init) => init?.method === 'POST'
|
fetchRoutes({
|
||||||
? post
|
'GET /api/library/game-search': json(200, {available: true, results}),
|
||||||
: json(200, {available: true, results}));
|
[`POST /api/library/games/${game.appid}/merge`]: post,
|
||||||
|
});
|
||||||
const onMerged = vi.fn();
|
const onMerged = vi.fn();
|
||||||
render(<MemoryRouter><GameMergeDialog game={game} open onOpenChange={vi.fn()} onMerged={onMerged}/></MemoryRouter>);
|
render(<MemoryRouter><GameMergeDialog game={game} open onOpenChange={vi.fn()} onMerged={onMerged}/></MemoryRouter>);
|
||||||
return onMerged;
|
return onMerged;
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import {Loader2, Search} from 'lucide-react';
|
|||||||
import {Badge} from '@/components/ui/badge';
|
import {Badge} from '@/components/ui/badge';
|
||||||
import {Input} from '@/components/ui/input';
|
import {Input} from '@/components/ui/input';
|
||||||
import GameCover from './GameCover.jsx';
|
import GameCover from './GameCover.jsx';
|
||||||
import useSessionExpired from '../hooks/useSessionExpired.js';
|
|
||||||
import {searchGames} from '../utils/manualGames.js';
|
import {searchGames} from '../utils/manualGames.js';
|
||||||
import {MAX_GAME_SEARCH_LENGTH, MIN_GAME_SEARCH_LENGTH} from '../utils/limits.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.
|
// Asks Core 400 ms after the last key press; searchState is idle, loading, done, unavailable or failed.
|
||||||
function useGameSearch(query) {
|
function useGameSearch(query) {
|
||||||
const expireSession = useSessionExpired();
|
|
||||||
const [searchState, setSearchState] = useState('idle');
|
const [searchState, setSearchState] = useState('idle');
|
||||||
const [results, setResults] = useState([]);
|
const [results, setResults] = useState([]);
|
||||||
|
|
||||||
@@ -97,10 +95,7 @@ function useGameSearch(query) {
|
|||||||
setSearchState('loading');
|
setSearchState('loading');
|
||||||
try {
|
try {
|
||||||
const response = await searchGames(trimmed, controller.signal);
|
const response = await searchGames(trimmed, controller.signal);
|
||||||
if (response.status === 401) {
|
if (response.status === 401) return;
|
||||||
expireSession();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
setSearchState('failed');
|
setSearchState('failed');
|
||||||
return;
|
return;
|
||||||
@@ -116,7 +111,7 @@ function useGameSearch(query) {
|
|||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
controller.abort();
|
controller.abort();
|
||||||
};
|
};
|
||||||
}, [query, expireSession]);
|
}, [query]);
|
||||||
|
|
||||||
return {searchState, results};
|
return {searchState, results};
|
||||||
}
|
}
|
||||||
@@ -8,9 +8,9 @@ import {Button} from '@/components/ui/button';
|
|||||||
import {Card} from '@/components/ui/card';
|
import {Card} from '@/components/ui/card';
|
||||||
import {Input} from '@/components/ui/input';
|
import {Input} from '@/components/ui/input';
|
||||||
import {Label} from '@/components/ui/label';
|
import {Label} from '@/components/ui/label';
|
||||||
import useSessionExpired from '../hooks/useSessionExpired.js';
|
|
||||||
import {hoursToMinutes, removeGame, toHours, updateManualGame} from '../utils/manualGames.js';
|
import {hoursToMinutes, removeGame, toHours, updateManualGame} from '../utils/manualGames.js';
|
||||||
import {MAX_PLATFORM_LENGTH} from '../utils/limits.js';
|
import {MAX_PLATFORM_LENGTH} from '../utils/limits.js';
|
||||||
|
import useUnsavedChanges, {discardChanges} from '../hooks/useUnsavedChanges.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Platform and hours of a game added by hand, and removing it. Steam games get both from the sync.
|
* Platform and hours of a game added by hand, and removing it. Steam games get both from the sync.
|
||||||
@@ -20,7 +20,6 @@ import {MAX_PLATFORM_LENGTH} from '../utils/limits.js';
|
|||||||
export default function ManualGameCard({game, onSaved}) {
|
export default function ManualGameCard({game, onSaved}) {
|
||||||
const {t} = useTranslation();
|
const {t} = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const expireSession = useSessionExpired();
|
|
||||||
const [platform, setPlatform] = useState(game.platform ?? '');
|
const [platform, setPlatform] = useState(game.platform ?? '');
|
||||||
const [hours, setHours] = useState(toHours(game.playtimeMinutes));
|
const [hours, setHours] = useState(toHours(game.playtimeMinutes));
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
@@ -28,15 +27,13 @@ export default function ManualGameCard({game, onSaved}) {
|
|||||||
|
|
||||||
const playtimeMinutes = hoursToMinutes(hours);
|
const playtimeMinutes = hoursToMinutes(hours);
|
||||||
const dirty = platform.trim() !== (game.platform ?? '') || playtimeMinutes !== game.playtimeMinutes;
|
const dirty = platform.trim() !== (game.platform ?? '') || playtimeMinutes !== game.playtimeMinutes;
|
||||||
|
useUnsavedChanges(dirty);
|
||||||
|
|
||||||
const run = async (request, onOk) => {
|
const run = async (request, onOk) => {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
const response = await request();
|
const response = await request();
|
||||||
if (response.status === 401) {
|
if (response.status === 401) return;
|
||||||
expireSession();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
toast.error(t('BACKLOG.SAVE_FAILED'));
|
toast.error(t('BACKLOG.SAVE_FAILED'));
|
||||||
return;
|
return;
|
||||||
@@ -57,6 +54,8 @@ export default function ManualGameCard({game, onSaved}) {
|
|||||||
|
|
||||||
const remove = () => run(() => removeGame(game.appid), () => {
|
const remove = () => run(() => removeGame(game.appid), () => {
|
||||||
toast.success(t('MANUAL_GAME.REMOVED'));
|
toast.success(t('MANUAL_GAME.REMOVED'));
|
||||||
|
// The game is gone, so are its unsaved edits on this page.
|
||||||
|
discardChanges();
|
||||||
navigate('/library', {replace: true});
|
navigate('/library', {replace: true});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||||
|
import {cleanup, fireEvent, screen, waitFor} from '@testing-library/react';
|
||||||
|
import {toast} from 'sonner';
|
||||||
|
import ManualGameCard from './ManualGameCard.jsx';
|
||||||
|
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
||||||
|
import {renderWithProviders} from '../test/renderWithProviders.jsx';
|
||||||
|
|
||||||
|
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||||
|
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
|
||||||
|
|
||||||
|
const game = {appid: -1025, platform: 'PS5', playtimeMinutes: 90};
|
||||||
|
|
||||||
|
const answer = (routes) => fetchRoutes({'GET /api/users/me': json(200, {settings: {}}), ...routes});
|
||||||
|
|
||||||
|
const renderCard = (onSaved = vi.fn()) => renderWithProviders(<ManualGameCard game={game} onSaved={onSaved}/>,
|
||||||
|
{path: '/library/:appid', entries: ['/library/-1025'], routes: {'/library': <p>library page</p>}});
|
||||||
|
|
||||||
|
const saveButton = () => screen.getByRole('button', {name: 'BACKLOG.SAVE'});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.setItem('tbp_session', '1');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
localStorage.clear();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ManualGameCard', () => {
|
||||||
|
it('saves the trimmed platform and the minutes, then reports them', async () => {
|
||||||
|
answer({'PUT /api/library/games/-1025/manual': json(200, {})});
|
||||||
|
const onSaved = vi.fn();
|
||||||
|
renderCard(onSaved);
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText('MANUAL_GAME.PLATFORM'), {target: {value: ' Switch '}});
|
||||||
|
fireEvent.change(screen.getByLabelText('MANUAL_GAME.HOURS'), {target: {value: '2'}});
|
||||||
|
fireEvent.click(saveButton());
|
||||||
|
|
||||||
|
await waitFor(() => expect(onSaved).toHaveBeenCalledWith({platform: 'Switch', playtimeMinutes: 120}));
|
||||||
|
const [url, init] = fetch.mock.calls.find(([, call]) => call?.method === 'PUT');
|
||||||
|
expect(url).toContain('/api/library/games/-1025/manual');
|
||||||
|
expect(JSON.parse(init.body)).toEqual({platform: 'Switch', playtimeMinutes: 120});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the save button disabled while nothing changed', () => {
|
||||||
|
answer({});
|
||||||
|
renderCard();
|
||||||
|
|
||||||
|
expect(saveButton().disabled).toBe(true);
|
||||||
|
fireEvent.change(screen.getByLabelText('MANUAL_GAME.PLATFORM'), {target: {value: 'PS5 '}});
|
||||||
|
expect(saveButton().disabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes the game only after a confirm and opens the library', async () => {
|
||||||
|
answer({'DELETE /api/library/games/-1025': json(204, null)});
|
||||||
|
const {router} = renderCard();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', {name: 'MANUAL_GAME.REMOVE'}));
|
||||||
|
expect(fetch.mock.calls.some(([, init]) => init?.method === 'DELETE')).toBe(false);
|
||||||
|
fireEvent.click(screen.getByRole('button', {name: 'MANUAL_GAME.REMOVE_YES'}));
|
||||||
|
|
||||||
|
await waitFor(() => expect(router.state.location.pathname).toBe('/library'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows an error when the save fails', async () => {
|
||||||
|
answer({'PUT /api/library/games/-1025/manual': json(500, {})});
|
||||||
|
const onSaved = vi.fn();
|
||||||
|
renderCard(onSaved);
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText('MANUAL_GAME.PLATFORM'), {target: {value: 'Switch'}});
|
||||||
|
fireEvent.click(saveButton());
|
||||||
|
|
||||||
|
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('BACKLOG.SAVE_FAILED'));
|
||||||
|
expect(onSaved).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows no toast on a 401', async () => {
|
||||||
|
answer({'PUT /api/library/games/-1025/manual': json(401, {}), 'POST /api/auth/refresh': json(401, {})});
|
||||||
|
const onSaved = vi.fn();
|
||||||
|
renderCard(onSaved);
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText('MANUAL_GAME.PLATFORM'), {target: {value: 'Switch'}});
|
||||||
|
fireEvent.click(saveButton());
|
||||||
|
|
||||||
|
await waitFor(() => expect(fetch.mock.calls.some(([url]) => url.endsWith('/api/auth/refresh'))).toBe(true));
|
||||||
|
await waitFor(() => expect(saveButton().disabled).toBe(false));
|
||||||
|
expect(toast.error).not.toHaveBeenCalled();
|
||||||
|
expect(toast.success).not.toHaveBeenCalled();
|
||||||
|
expect(onSaved).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import {Link} from 'react-router-dom';
|
||||||
|
import {useTranslation} from 'react-i18next';
|
||||||
|
import {ArrowRight, Trophy} from 'lucide-react';
|
||||||
|
|
||||||
|
import {Button} from '@/components/ui/button';
|
||||||
|
import {Card} from '@/components/ui/card';
|
||||||
|
import GameCover from './GameCover.jsx';
|
||||||
|
import {formatPlaytime} from '../utils/games.js';
|
||||||
|
|
||||||
|
export default function MostPlayedCard({games, syncing}) {
|
||||||
|
const {t} = useTranslation();
|
||||||
|
const maxMinutes = games[0]?.playtimeMinutes || 1;
|
||||||
|
return (
|
||||||
|
<Card className="gap-0 py-0">
|
||||||
|
<div className="flex items-center justify-between px-5 py-4 border-b">
|
||||||
|
<h2 className="flex items-center gap-2 font-semibold">
|
||||||
|
<Trophy className="size-4 text-amber-500"/>
|
||||||
|
{t('DASHBOARD.MOST_PLAYED')}
|
||||||
|
</h2>
|
||||||
|
<Button asChild variant="ghost" size="sm">
|
||||||
|
<Link to="/library">{t('DASHBOARD.SHOW_ALL')}<ArrowRight/></Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{games.length === 0 ? (
|
||||||
|
<p className="px-5 py-10 text-center text-sm text-muted-foreground">
|
||||||
|
{syncing ? t('LIBRARY.SYNCING_FIRST_TIME') : t('LIBRARY.EMPTY')}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ol className="divide-y">
|
||||||
|
{games.map((g, index) => (
|
||||||
|
<li key={g.appid}>
|
||||||
|
<Link
|
||||||
|
to={`/library/${g.appid}`}
|
||||||
|
className="group flex items-center gap-3 sm:gap-4 px-4 sm:px-5 py-3 transition-colors hover:bg-muted/50"
|
||||||
|
>
|
||||||
|
<span className="w-4 text-right text-sm font-semibold text-muted-foreground tabular-nums">
|
||||||
|
{index + 1}
|
||||||
|
</span>
|
||||||
|
<GameCover appid={g.appid} imageUrl={g.imageUrl} className="w-16 sm:w-24 shrink-0"/>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="truncate font-medium group-hover:text-primary transition-colors">
|
||||||
|
{g.name ?? `App ${g.appid}`}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 h-1.5 rounded-full bg-muted">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-primary"
|
||||||
|
style={{width: `${Math.max(2, (g.playtimeMinutes / maxMinutes) * 100)}%`}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="w-12 sm:w-14 text-right text-sm font-medium text-muted-foreground tabular-nums">
|
||||||
|
{formatPlaytime(g.playtimeMinutes)}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -21,6 +21,8 @@ describe('PageContainer', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('is wider with the wide layout', () => {
|
it('is wider with the wide layout', () => {
|
||||||
|
// The cached look of a logged-in user; without a session it is reset.
|
||||||
|
localStorage.setItem('tbp_session', '1');
|
||||||
localStorage.setItem('tbp_appearance', JSON.stringify({wideLayout: true}));
|
localStorage.setItem('tbp_appearance', JSON.stringify({wideLayout: true}));
|
||||||
|
|
||||||
const container = renderContainer();
|
const container = renderContainer();
|
||||||
|
|||||||
@@ -5,8 +5,7 @@ import {Gamepad2, Loader2} from 'lucide-react';
|
|||||||
|
|
||||||
import {Button} from '@/components/ui/button';
|
import {Button} from '@/components/ui/button';
|
||||||
import {Card} from '@/components/ui/card';
|
import {Card} from '@/components/ui/card';
|
||||||
import useSessionExpired from '../hooks/useSessionExpired.js';
|
import {saveGameStatus} from '../utils/backlog.js';
|
||||||
import {saveBacklogEntry} from '../utils/backlog.js';
|
|
||||||
import {formatPlaytime} from '../utils/games.js';
|
import {formatPlaytime} from '../utils/games.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -16,7 +15,6 @@ import {formatPlaytime} from '../utils/games.js';
|
|||||||
*/
|
*/
|
||||||
export default function PlayingHint({game, onChanged}) {
|
export default function PlayingHint({game, onChanged}) {
|
||||||
const {t} = useTranslation();
|
const {t} = useTranslation();
|
||||||
const expireSession = useSessionExpired();
|
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
if (game.status !== 'BACKLOG' || !(game.playtime2Weeks > 0)) return null;
|
if (game.status !== 'BACKLOG' || !(game.playtime2Weeks > 0)) return null;
|
||||||
@@ -24,13 +22,8 @@ export default function PlayingHint({game, onChanged}) {
|
|||||||
const setPlaying = async () => {
|
const setPlaying = async () => {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
const response = await saveBacklogEntry(game.appid, {
|
const response = await saveGameStatus(game.appid, 'PLAYING');
|
||||||
status: 'PLAYING', rating: game.rating ?? null, notes: game.notes ?? null, priority: game.priority ?? null,
|
if (response.status === 401) return;
|
||||||
});
|
|
||||||
if (response.status === 401) {
|
|
||||||
expireSession();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
toast.error(t('BACKLOG.SAVE_FAILED'));
|
toast.error(t('BACKLOG.SAVE_FAILED'));
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||||
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
|
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
|
||||||
import {MemoryRouter} from 'react-router-dom';
|
import {MemoryRouter} from 'react-router-dom';
|
||||||
|
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
||||||
import PlayingHint from './PlayingHint.jsx';
|
import PlayingHint from './PlayingHint.jsx';
|
||||||
|
|
||||||
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||||
@@ -13,7 +14,8 @@ const renderHint = (props) => render(<MemoryRouter><PlayingHint game={game} onCh
|
|||||||
describe('PlayingHint', () => {
|
describe('PlayingHint', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
localStorage.setItem('tbp_session', '1');
|
localStorage.setItem('tbp_session', '1');
|
||||||
globalThis.fetch = vi.fn(async (url, init) => ({ok: true, status: 200, json: async () => JSON.parse(init.body)}));
|
// Core changes only the status and answers the whole entry.
|
||||||
|
fetchRoutes({'PUT /api/library/games/220/status': (url, init) => json(200, {...game, ...JSON.parse(init.body)})});
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -30,15 +32,15 @@ describe('PlayingHint', () => {
|
|||||||
expect(screen.queryByRole('button')).toBeNull();
|
expect(screen.queryByRole('button')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('sets the game to PLAYING and keeps the other fields', async () => {
|
it('sends only the status, so Core keeps the other fields', async () => {
|
||||||
const onChanged = vi.fn();
|
const onChanged = vi.fn();
|
||||||
renderHint({onChanged});
|
renderHint({onChanged});
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', {name: 'GAME_DETAIL.SET_PLAYING'}));
|
fireEvent.click(screen.getByRole('button', {name: 'GAME_DETAIL.SET_PLAYING'}));
|
||||||
|
|
||||||
await waitFor(() => expect(onChanged).toHaveBeenCalledWith({status: 'PLAYING', rating: 3, notes: 'keep', priority: 'HIGH'}));
|
await waitFor(() => expect(onChanged).toHaveBeenCalledWith(expect.objectContaining({status: 'PLAYING'})));
|
||||||
const [url, init] = fetch.mock.calls[0];
|
const [url, init] = fetch.mock.calls[0];
|
||||||
expect(url).toMatch(/\/api\/library\/games\/220\/backlog$/);
|
expect(url).toMatch(/\/api\/library\/games\/220\/status$/);
|
||||||
expect(JSON.parse(init.body)).toEqual({status: 'PLAYING', rating: 3, notes: 'keep', priority: 'HIGH'});
|
expect(JSON.parse(init.body)).toEqual({status: 'PLAYING'});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import {useTranslation} from 'react-i18next';
|
||||||
|
import {SlidersHorizontal} from 'lucide-react';
|
||||||
|
import {toast} from 'sonner';
|
||||||
|
|
||||||
|
import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
|
||||||
|
import {Label} from '@/components/ui/label';
|
||||||
|
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/components/ui/select';
|
||||||
|
import {Switch} from '@/components/ui/switch';
|
||||||
|
import {useCurrentUserContext} from '../context/CurrentUserContext.jsx';
|
||||||
|
import {usePreferences} from '../context/PreferencesContext.jsx';
|
||||||
|
import usePersistSettings from '../hooks/usePersistSettings.js';
|
||||||
|
import useSaveResponse from '../hooks/useSaveResponse.js';
|
||||||
|
import {FAVORITE_PLATFORMS, saveFavoritePlatform} from '../utils/platforms.js';
|
||||||
|
import {LANGUAGES, saveSettings} from '../utils/settings.js';
|
||||||
|
|
||||||
|
// Radix Select has no empty value.
|
||||||
|
const NO_FAVORITE = 'NONE';
|
||||||
|
|
||||||
|
export default function PreferencesCard() {
|
||||||
|
const {t, i18n} = useTranslation();
|
||||||
|
const {user: profile, updateUser: updateProfile} = useCurrentUserContext();
|
||||||
|
const {isDarkMode, setDarkMode, setLanguage} = usePreferences();
|
||||||
|
const persistSettings = usePersistSettings();
|
||||||
|
const runSave = useSaveResponse();
|
||||||
|
|
||||||
|
const handleDarkThemeToggle = async () => {
|
||||||
|
const next = !isDarkMode;
|
||||||
|
setDarkMode(next); // apply on the fly
|
||||||
|
await persistSettings(() => saveSettings({useDarkTheme: next}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLanguageChange = async (next) => {
|
||||||
|
await persistSettings(() => setLanguage(next));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFavoritePlatformChange = async (value) => {
|
||||||
|
const platform = value === NO_FAVORITE ? null : value;
|
||||||
|
if (await runSave(() => saveFavoritePlatform(platform))) {
|
||||||
|
updateProfile((p) => ({...p, settings: {...p.settings, favoritePlatform: platform}}));
|
||||||
|
toast.success(t('SETTINGS.SAVED'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2"><SlidersHorizontal className="size-4 text-primary"/>{t('SETTINGS.PREFERENCES')}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<Label htmlFor="useDarkTheme">{t('SETTINGS.DARK_THEME')}</Label>
|
||||||
|
<Switch id="useDarkTheme" checked={isDarkMode} onCheckedChange={handleDarkThemeToggle}/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<Label htmlFor="language">{t('SETTINGS.LANGUAGE')}</Label>
|
||||||
|
<Select value={i18n.language} onValueChange={handleLanguageChange}>
|
||||||
|
<SelectTrigger id="language" className="w-40">
|
||||||
|
<SelectValue/>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{LANGUAGES.map((lang) => (
|
||||||
|
<SelectItem key={lang} value={lang}>
|
||||||
|
{t(`SETTINGS.LANGUAGE_OPTIONS.${lang.toUpperCase()}`)}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="favoritePlatform">{t('SETTINGS.FAVORITE_PLATFORM')}</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">{t('SETTINGS.FAVORITE_PLATFORM_HINT')}</p>
|
||||||
|
</div>
|
||||||
|
<Select value={profile?.settings?.favoritePlatform ?? NO_FAVORITE} onValueChange={handleFavoritePlatformChange}>
|
||||||
|
<SelectTrigger id="favoritePlatform" className="w-48">
|
||||||
|
<SelectValue/>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value={NO_FAVORITE}>{t('SETTINGS.FAVORITE_PLATFORM_NONE')}</SelectItem>
|
||||||
|
{FAVORITE_PLATFORMS.map((platform) => (
|
||||||
|
<SelectItem key={platform.value} value={platform.value}>{platform.label}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import {useState} from 'react';
|
||||||
|
import {useTranslation} from 'react-i18next';
|
||||||
|
import {useForm} from 'react-hook-form';
|
||||||
|
import {zodResolver} from '@hookform/resolvers/zod';
|
||||||
|
import {Copy, Gamepad2, Loader2, Save} from 'lucide-react';
|
||||||
|
import {toast} from 'sonner';
|
||||||
|
|
||||||
|
import {Button} from '@/components/ui/button';
|
||||||
|
import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
|
||||||
|
import {Input} from '@/components/ui/input';
|
||||||
|
import {Label} from '@/components/ui/label';
|
||||||
|
import {Switch} from '@/components/ui/switch';
|
||||||
|
import FormField from './FormField.jsx';
|
||||||
|
import ProfileStyleSettings from './ProfileStyleSettings.jsx';
|
||||||
|
import {useCurrentUserContext} from '../context/CurrentUserContext.jsx';
|
||||||
|
import usePersistSettings from '../hooks/usePersistSettings.js';
|
||||||
|
import useSaveProfileStyle from '../hooks/useSaveProfileStyle.js';
|
||||||
|
import {normalizeProfileStyle} from '../utils/profileStyle.js';
|
||||||
|
import {saveSettings} from '../utils/settings.js';
|
||||||
|
import {settingsSchema} from '../utils/validation.js';
|
||||||
|
import {MAX_DISPLAY_NAME_LENGTH} from '../utils/limits.js';
|
||||||
|
|
||||||
|
/** `displayName` fills the name field once, at mount. */
|
||||||
|
export default function ProfileCard({displayName}) {
|
||||||
|
const {t} = useTranslation();
|
||||||
|
const {user: profile, updateUser: updateProfile} = useCurrentUserContext();
|
||||||
|
const persistSettings = usePersistSettings();
|
||||||
|
const saveStyle = useSaveProfileStyle();
|
||||||
|
const [savingProfileStyle, setSavingProfileStyle] = useState(false);
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
formState: {errors, isSubmitting},
|
||||||
|
} = useForm({
|
||||||
|
resolver: zodResolver(settingsSchema),
|
||||||
|
defaultValues: {displayName},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handlePublicProfileToggle = async () => {
|
||||||
|
const next = !profile?.settings?.publicProfile;
|
||||||
|
const result = await persistSettings(() => saveSettings({publicProfile: next}));
|
||||||
|
if (result.ok) {
|
||||||
|
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) => updateProfile((p) => ({...p, settings: {...p.settings, profileStyle: next}}));
|
||||||
|
showProfileStyle({...previous, ...changes});
|
||||||
|
setSavingProfileStyle(true);
|
||||||
|
showProfileStyle(await saveStyle(changes, previous) ?? previous);
|
||||||
|
setSavingProfileStyle(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyProfileLink = async (link) => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(link);
|
||||||
|
toast.success(t('SETTINGS.PUBLIC_PROFILE_COPIED'));
|
||||||
|
} catch {
|
||||||
|
toast.error(link);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSubmitProfile = async (values) => {
|
||||||
|
// Without a name the profile has no link, so removing the name also makes it private.
|
||||||
|
const publicProfile = !!profile?.settings?.publicProfile && !!values.displayName;
|
||||||
|
const result = await persistSettings(() => saveSettings({displayName: values.displayName, publicProfile}));
|
||||||
|
if (result.ok) {
|
||||||
|
updateProfile((p) => ({...p, displayName: values.displayName, settings: {...p.settings, publicProfile}}));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const publicProfile = !!profile?.settings?.publicProfile;
|
||||||
|
const profileLink = profile?.displayName
|
||||||
|
? `${window.location.origin}/u/${encodeURIComponent(profile.displayName)}`
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2"><Gamepad2 className="size-4 text-primary"/>{t('SETTINGS.PROFILE')}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleSubmit(onSubmitProfile)} className="space-y-4" noValidate>
|
||||||
|
<FormField id="displayName" label={t('SETTINGS.DISPLAY_NAME')} error={errors.displayName} t={t}>
|
||||||
|
<Input
|
||||||
|
id="displayName"
|
||||||
|
type="text"
|
||||||
|
maxLength={MAX_DISPLAY_NAME_LENGTH}
|
||||||
|
placeholder={t('SETTINGS.DISPLAY_NAME_PLACEHOLDER')}
|
||||||
|
aria-invalid={!!errors.displayName}
|
||||||
|
{...register('displayName')}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">{t('SETTINGS.DISPLAY_NAME_HINT')}</p>
|
||||||
|
</FormField>
|
||||||
|
<Button type="submit" disabled={isSubmitting}>
|
||||||
|
{isSubmitting ? <Loader2 className="animate-spin"/> : <Save/>}
|
||||||
|
{t('SETTINGS.SAVE_PROFILE')}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="mt-6 space-y-3 border-t pt-6">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="publicProfile">{t('SETTINGS.PUBLIC_PROFILE')}</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{profileLink ? t('SETTINGS.PUBLIC_PROFILE_HINT') : t('SETTINGS.PUBLIC_PROFILE_NEEDS_NAME')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch id="publicProfile" checked={publicProfile} disabled={!profileLink}
|
||||||
|
onCheckedChange={handlePublicProfileToggle}/>
|
||||||
|
</div>
|
||||||
|
{publicProfile && profileLink && (
|
||||||
|
<div className="flex items-center gap-2 rounded-md bg-muted px-3 py-2 text-sm">
|
||||||
|
<a href={profileLink} target="_blank" rel="noopener noreferrer"
|
||||||
|
className="min-w-0 flex-1 truncate font-mono text-primary hover:underline">{profileLink}</a>
|
||||||
|
<Button type="button" variant="ghost" size="icon" className="size-7"
|
||||||
|
aria-label={t('SETTINGS.PUBLIC_PROFILE_COPY')} onClick={() => copyProfileLink(profileLink)}>
|
||||||
|
<Copy/>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{publicProfile && profileLink && (
|
||||||
|
<p className="text-xs text-muted-foreground">{t('SETTINGS.PROFILE_TILES_HINT')}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 border-t pt-6">
|
||||||
|
<ProfileStyleSettings style={normalizeProfileStyle(profile?.settings?.profileStyle)}
|
||||||
|
disabled={!publicProfile || savingProfileStyle}
|
||||||
|
onChange={handleProfileStyleChange}/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import {Navigate, Outlet, useLocation} from 'react-router-dom';
|
||||||
|
import useSession from '../hooks/useSession.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Layout route of the pages that need a login. Without a session it opens the login page, which comes back here.
|
||||||
|
* After a logout it only opens the login page, so the next login does not land on the page of the last user.
|
||||||
|
*/
|
||||||
|
export default function RequireSession() {
|
||||||
|
const {pathname, search} = useLocation();
|
||||||
|
const {active, endReason} = useSession();
|
||||||
|
|
||||||
|
if (active) return <Outlet/>;
|
||||||
|
const from = pathname + search;
|
||||||
|
if (endReason === 'logout') return <Navigate to="/login" replace/>;
|
||||||
|
return <Navigate to="/login" replace state={endReason === 'expired' ? {sessionExpired: true, from} : {from}}/>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import {afterEach, describe, expect, it} from 'vitest';
|
||||||
|
import {act, cleanup, render, screen} from '@testing-library/react';
|
||||||
|
import {createMemoryRouter, RouterProvider} from 'react-router-dom';
|
||||||
|
import RequireSession from './RequireSession.jsx';
|
||||||
|
import {clearSession, expireSession, markSession} from '../utils/auth.js';
|
||||||
|
|
||||||
|
function renderAt(entry) {
|
||||||
|
const router = createMemoryRouter([
|
||||||
|
{element: <RequireSession/>, children: [{path: '/library', element: <p>library</p>}]},
|
||||||
|
{path: '/u/:name', element: <p>profile</p>},
|
||||||
|
{path: '/login', element: <p>login</p>},
|
||||||
|
], {initialEntries: [entry]});
|
||||||
|
render(<RouterProvider router={router}/>);
|
||||||
|
return router;
|
||||||
|
}
|
||||||
|
|
||||||
|
const at = (router) => ({path: router.state.location.pathname, state: router.state.location.state});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('RequireSession', () => {
|
||||||
|
it('sends a deep link without a session to the login page, which comes back to it', async () => {
|
||||||
|
const router = renderAt('/library?status=PLAYING');
|
||||||
|
|
||||||
|
expect(await screen.findByText('login')).toBeTruthy();
|
||||||
|
expect(at(router)).toEqual({path: '/login', state: {from: '/library?status=PLAYING'}});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the page with a session', () => {
|
||||||
|
markSession();
|
||||||
|
renderAt('/library');
|
||||||
|
|
||||||
|
expect(screen.getByText('library')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says the session expired and comes back after a 401', async () => {
|
||||||
|
markSession();
|
||||||
|
const router = renderAt('/library?status=PLAYING');
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
expireSession();
|
||||||
|
expireSession();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await screen.findByText('login')).toBeTruthy();
|
||||||
|
expect(at(router)).toEqual({path: '/login', state: {sessionExpired: true, from: '/library?status=PLAYING'}});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('comes back to no page after a logout, so the next user starts on their own', async () => {
|
||||||
|
markSession();
|
||||||
|
const router = renderAt('/library');
|
||||||
|
|
||||||
|
act(() => clearSession());
|
||||||
|
|
||||||
|
expect(await screen.findByText('login')).toBeTruthy();
|
||||||
|
expect(router.state.location.state).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens the login page when another tab logs out', async () => {
|
||||||
|
markSession();
|
||||||
|
renderAt('/library');
|
||||||
|
|
||||||
|
localStorage.removeItem('tbp_session');
|
||||||
|
act(() => window.dispatchEvent(new StorageEvent('storage', {key: 'tbp_session'})));
|
||||||
|
|
||||||
|
expect(await screen.findByText('login')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves a public page open when the session ends there', () => {
|
||||||
|
markSession();
|
||||||
|
renderAt('/u/someone');
|
||||||
|
|
||||||
|
act(() => expireSession());
|
||||||
|
|
||||||
|
expect(screen.getByText('profile')).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,7 +6,6 @@ import {toast} from 'sonner';
|
|||||||
import {Badge} from '@/components/ui/badge';
|
import {Badge} from '@/components/ui/badge';
|
||||||
import {Button} from '@/components/ui/button';
|
import {Button} from '@/components/ui/button';
|
||||||
import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
|
import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
|
||||||
import useSessionExpired from '../hooks/useSessionExpired.js';
|
|
||||||
import {markSession} from '../utils/auth.js';
|
import {markSession} from '../utils/auth.js';
|
||||||
import {startSteamLink, unlinkSteam} from '../utils/steamLink.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}) {
|
export default function SteamAccountCard({steamId, verified, onUnlinked}) {
|
||||||
const {t} = useTranslation();
|
const {t} = useTranslation();
|
||||||
const expireSession = useSessionExpired();
|
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
const link = async () => {
|
const link = async () => {
|
||||||
@@ -24,11 +22,7 @@ export default function SteamAccountCard({steamId, verified, onUnlinked}) {
|
|||||||
try {
|
try {
|
||||||
const response = await startSteamLink();
|
const response = await startSteamLink();
|
||||||
if (response.ok) return; // on the way to Steam: the button stays busy
|
if (response.ok) return; // on the way to Steam: the button stays busy
|
||||||
if (response.status === 401) {
|
if (response.status !== 401) toast.error(t('SETTINGS.STEAM_LINK.ERROR'));
|
||||||
expireSession();
|
|
||||||
} else {
|
|
||||||
toast.error(t('SETTINGS.STEAM_LINK.ERROR'));
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Steam link error:', err);
|
console.error('Steam link error:', err);
|
||||||
toast.error(t('SETTINGS.STEAM_LINK.ERROR'));
|
toast.error(t('SETTINGS.STEAM_LINK.ERROR'));
|
||||||
@@ -40,10 +34,7 @@ export default function SteamAccountCard({steamId, verified, onUnlinked}) {
|
|||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
const response = await unlinkSteam();
|
const response = await unlinkSteam();
|
||||||
if (response.status === 401) {
|
if (response.status === 401) return;
|
||||||
expireSession();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
toast.error(t('SETTINGS.STEAM_LINK.ERROR'));
|
toast.error(t('SETTINGS.STEAM_LINK.ERROR'));
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {useTranslation} from 'react-i18next';
|
import {useTranslation} from 'react-i18next';
|
||||||
import {CircleAlert, CircleCheck, RefreshCw} from 'lucide-react';
|
import {CircleAlert, CircleCheck, Clock, RefreshCw} from 'lucide-react';
|
||||||
|
|
||||||
const formatRelative = (iso, locale) => {
|
const formatRelative = (iso, locale) => {
|
||||||
if (!iso) return null;
|
if (!iso) return null;
|
||||||
@@ -12,7 +12,7 @@ const formatRelative = (iso, locale) => {
|
|||||||
return rtf.format(-Math.round(diffSec / 86400), 'day');
|
return rtf.format(-Math.round(diffSec / 86400), 'day');
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function SyncStatusBadge({status, lastSyncedAt, isRunning}) {
|
export default function SyncStatusBadge({status, lastSyncedAt, isRunning, stale}) {
|
||||||
const {t, i18n} = useTranslation();
|
const {t, i18n} = useTranslation();
|
||||||
|
|
||||||
if (isRunning) {
|
if (isRunning) {
|
||||||
@@ -24,6 +24,15 @@ export default function SyncStatusBadge({status, lastSyncedAt, isRunning}) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (stale) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Clock className="size-4"/>
|
||||||
|
<span>{t('LIBRARY.SYNC_STALE')}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (status === 'FAILED') {
|
if (status === 'FAILED') {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 text-sm text-destructive">
|
<div className="flex items-center gap-2 text-sm text-destructive">
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import {useState} from 'react';
|
||||||
|
import {useTranslation} from 'react-i18next';
|
||||||
|
import {toast} from 'sonner';
|
||||||
|
import {RotateCcw} from 'lucide-react';
|
||||||
|
|
||||||
|
import {Badge} from '@/components/ui/badge';
|
||||||
|
import {Button} from '@/components/ui/button';
|
||||||
|
import {Card} from '@/components/ui/card';
|
||||||
|
import {checkUndo, undoImport} from '../../utils/imports.js';
|
||||||
|
|
||||||
|
export default function ImportHistory({imports, onOpen, onChanged}) {
|
||||||
|
const {t, i18n} = useTranslation();
|
||||||
|
const [confirm, setConfirm] = useState(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const run = async (call, onOk) => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const response = await call();
|
||||||
|
if (response.status === 401) return;
|
||||||
|
if (!response.ok) {
|
||||||
|
toast.error(t('IMPORT.UNDO_FAILED'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await onOk(response);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Import undo error:', err);
|
||||||
|
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const askUndo = (id) => run(() => checkUndo(id), async (response) => setConfirm({id, ...await response.json()}));
|
||||||
|
const undo = () => run(() => undoImport(confirm.id), () => {
|
||||||
|
toast.success(t('IMPORT.UNDONE', {count: confirm.games}));
|
||||||
|
setConfirm(null);
|
||||||
|
onChanged();
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="gap-0 p-0">
|
||||||
|
<h2 className="border-b px-5 py-3 font-semibold">{t('IMPORT.HISTORY')}</h2>
|
||||||
|
<ul className="divide-y">
|
||||||
|
{imports.map((entry) => (
|
||||||
|
<li key={entry.id} className="flex flex-wrap items-center gap-3 px-5 py-3 text-sm">
|
||||||
|
<span className="tabular-nums text-muted-foreground">
|
||||||
|
{new Date(entry.createdAt).toLocaleDateString(i18n.language)}
|
||||||
|
</span>
|
||||||
|
<span className="flex-1">
|
||||||
|
{t(`IMPORT.SOURCE_${entry.source}`)} · {t('IMPORT.LINES', {count: entry.total})}
|
||||||
|
</span>
|
||||||
|
<Badge variant={entry.status === 'FAILED' ? 'destructive' : 'outline'}>{t(`IMPORT.STATUS.${entry.status}`)}</Badge>
|
||||||
|
{entry.status !== 'SAVED' && entry.status !== 'UNDONE' && (
|
||||||
|
<Button variant="outline" size="sm" onClick={() => onOpen(entry.id)}>{t('IMPORT.OPEN_IMPORT')}</Button>
|
||||||
|
)}
|
||||||
|
{(entry.status === 'SAVED' || (entry.status === 'FAILED' && entry.added > 0))
|
||||||
|
&& confirm?.id !== entry.id && (
|
||||||
|
<Button variant="outline" size="sm" disabled={busy} onClick={() => askUndo(entry.id)}>
|
||||||
|
<RotateCcw/>{t('IMPORT.UNDO')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{confirm?.id === entry.id && (
|
||||||
|
<div className="flex w-full flex-wrap items-center gap-2">
|
||||||
|
<span>{t('IMPORT.UNDO_CONFIRM', {count: confirm.games, changed: confirm.changed})}</span>
|
||||||
|
<Button variant="destructive" size="sm" disabled={busy} onClick={undo}>{t('IMPORT.UNDO_YES')}</Button>
|
||||||
|
<Button variant="ghost" size="sm" disabled={busy} onClick={() => setConfirm(null)}>
|
||||||
|
{t('MANUAL_GAME.REMOVE_NO')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import {useState} from 'react';
|
||||||
|
import {useTranslation} from 'react-i18next';
|
||||||
|
import {Loader2, Search} from 'lucide-react';
|
||||||
|
|
||||||
|
import {Button} from '@/components/ui/button';
|
||||||
|
import {Card} from '@/components/ui/card';
|
||||||
|
import {Input} from '@/components/ui/input';
|
||||||
|
import {Textarea} from '@/components/ui/textarea';
|
||||||
|
import useStartImport from '../../hooks/useStartImport.js';
|
||||||
|
import {parseCsv, parseTextList} from '../../utils/imports.js';
|
||||||
|
import {MAX_IMPORT_ROWS} from '../../utils/limits.js';
|
||||||
|
|
||||||
|
export default function ImportInput({onCreated}) {
|
||||||
|
const {t} = useTranslation();
|
||||||
|
const [source, setSource] = useState('TEXT');
|
||||||
|
const [text, setText] = useState('');
|
||||||
|
const [csv, setCsv] = useState(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const rows = source === 'TEXT' ? parseTextList(text) : csv?.rows ?? [];
|
||||||
|
const tooMany = rows.length > MAX_IMPORT_ROWS;
|
||||||
|
|
||||||
|
const readFile = async (file) => setCsv(file ? parseCsv(await file.text()) : null);
|
||||||
|
|
||||||
|
const startImport = useStartImport();
|
||||||
|
const start = async () => {
|
||||||
|
setBusy(true);
|
||||||
|
const id = await startImport(source, rows);
|
||||||
|
setBusy(false);
|
||||||
|
if (id) onCreated(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="gap-4 p-5">
|
||||||
|
<p className="text-sm text-muted-foreground">{t('IMPORT.HINT')}</p>
|
||||||
|
<div role="group" aria-label={t('IMPORT.SOURCE')} className="flex gap-2">
|
||||||
|
{['TEXT', 'CSV'].map((value) => (
|
||||||
|
<Button key={value} size="sm" variant={source === value ? 'default' : 'outline'} aria-pressed={source === value}
|
||||||
|
onClick={() => setSource(value)}>{t(`IMPORT.SOURCE_${value}`)}</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{source === 'TEXT' ? (
|
||||||
|
<Textarea rows={10} value={text} aria-label={t('IMPORT.TEXT_LABEL')} placeholder={t('IMPORT.TEXT_PLACEHOLDER')}
|
||||||
|
onChange={(e) => setText(e.target.value)}/>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Input type="file" accept=".csv,text/csv" aria-label={t('IMPORT.CSV_LABEL')}
|
||||||
|
onChange={(e) => readFile(e.target.files?.[0])}/>
|
||||||
|
<p className="text-xs text-muted-foreground">{t('IMPORT.CSV_HINT')}</p>
|
||||||
|
{csv?.error && <p className="text-sm text-destructive">{t(csv.error)}</p>}
|
||||||
|
{csv?.rows?.length > 0 && (
|
||||||
|
<ul className="text-sm text-muted-foreground">
|
||||||
|
{csv.rows.slice(0, 5).map((row, index) => (
|
||||||
|
<li key={index} className="truncate">
|
||||||
|
{[row.title, row.platform, row.status].filter(Boolean).join(' · ')}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<span className={tooMany ? 'text-sm text-destructive' : 'text-sm text-muted-foreground'}>
|
||||||
|
{t(tooMany ? 'IMPORT.TOO_MANY' : 'IMPORT.LINES', {count: rows.length, max: MAX_IMPORT_ROWS})}
|
||||||
|
</span>
|
||||||
|
<Button onClick={start} disabled={busy || rows.length === 0 || tooMany}>
|
||||||
|
{busy ? <Loader2 className="animate-spin"/> : <Search/>}{t('IMPORT.START')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import {memo, useCallback, useState} from 'react';
|
||||||
|
import {useTranslation} from 'react-i18next';
|
||||||
|
import {toast} from 'sonner';
|
||||||
|
import {Loader2, Search} from 'lucide-react';
|
||||||
|
|
||||||
|
import {Badge} from '@/components/ui/badge';
|
||||||
|
import {Button} from '@/components/ui/button';
|
||||||
|
import {Card} from '@/components/ui/card';
|
||||||
|
import {Input} from '@/components/ui/input';
|
||||||
|
import GameSearch from '../GameSearch.jsx';
|
||||||
|
import useUnsavedChanges from '../../hooks/useUnsavedChanges.js';
|
||||||
|
import {initialChoice, saveImport} from '../../utils/imports.js';
|
||||||
|
import {GAME_STATUSES, MAX_PLATFORM_LENGTH} from '../../utils/limits.js';
|
||||||
|
import {hoursToMinutes} from '../../utils/manualGames.js';
|
||||||
|
import {preselectPlatform} from '../../utils/platforms.js';
|
||||||
|
|
||||||
|
// The native select: a review of 1000 lines would mount 3000 Radix selects.
|
||||||
|
const SELECT_CLASS = 'h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-2 text-sm dark:bg-input/30';
|
||||||
|
|
||||||
|
export default function ImportReview({data, favorite, onSaved, onDone}) {
|
||||||
|
const {t} = useTranslation();
|
||||||
|
const [initialChoices] = useState(() => data.rows.map((row) => initialChoice(row, favorite)));
|
||||||
|
const [choices, setChoices] = useState(initialChoices);
|
||||||
|
useUnsavedChanges(choices !== initialChoices);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
// Stable, so a change in one line re-renders only that line (up to 1000 lines).
|
||||||
|
const change = useCallback((index, changes) => setChoices((current) =>
|
||||||
|
current.map((choice, i) => (i === index ? {...choice, ...changes} : choice))), []);
|
||||||
|
const pick = useCallback((index, candidate) => setChoices((current) => current.map((choice, i) => {
|
||||||
|
if (i !== index) return choice;
|
||||||
|
const candidates = choice.candidates.some((known) => known.igdbId === candidate.igdbId)
|
||||||
|
? choice.candidates : [...choice.candidates, candidate];
|
||||||
|
return {...choice, candidates, igdbId: candidate.igdbId,
|
||||||
|
platform: preselectPlatform(data.rows[index].platform, candidate.platforms, favorite)};
|
||||||
|
})), [data.rows, favorite]);
|
||||||
|
// A line without a match cannot be checked, one in the library only by hand; a hand check stays.
|
||||||
|
const checkAll = (checked) => setChoices((current) => current.map((choice) => {
|
||||||
|
const candidate = choice.candidates.find((known) => known.igdbId === choice.igdbId);
|
||||||
|
const next = checked && (choice.checked || (!!candidate && !candidate.inLibrary));
|
||||||
|
return choice.checked === next ? choice : {...choice, checked: next};
|
||||||
|
}));
|
||||||
|
const chosen = choices.map((choice, index) => ({choice, row: data.rows[index]}))
|
||||||
|
.filter(({choice}) => choice.checked && choice.igdbId && choice.platform.trim());
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const response = await saveImport(data.id, chosen.map(({choice, row}) => ({
|
||||||
|
lineNo: row.lineNo, igdbId: choice.igdbId, platform: choice.platform.trim(), status: choice.status,
|
||||||
|
playtimeMinutes: hoursToMinutes(choice.hours),
|
||||||
|
})));
|
||||||
|
if (response.status === 401) return;
|
||||||
|
if (!response.ok) {
|
||||||
|
toast.error(t('IMPORT.SAVE_FAILED'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onSaved();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Import save error:', err);
|
||||||
|
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="gap-0 overflow-hidden p-0">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3 border-b px-5 py-4">
|
||||||
|
<p className="text-sm text-muted-foreground">{t('IMPORT.REVIEW_HINT')}</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button variant="outline" onClick={() => checkAll(true)} disabled={busy}>{t('IMPORT.SELECT_ALL')}</Button>
|
||||||
|
<Button variant="outline" onClick={() => checkAll(false)} disabled={busy}>{t('IMPORT.SELECT_NONE')}</Button>
|
||||||
|
<Button variant="ghost" onClick={onDone} disabled={busy}>{t('IMPORT.NEW')}</Button>
|
||||||
|
<Button onClick={save} disabled={busy || chosen.length === 0}>
|
||||||
|
{busy && <Loader2 className="animate-spin"/>}{t('IMPORT.SAVE', {count: chosen.length})}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ul className="divide-y">
|
||||||
|
{choices.map((choice, index) => (
|
||||||
|
<ReviewRow key={data.rows[index].lineNo} index={index} row={data.rows[index]} choice={choice}
|
||||||
|
onChange={change} onPick={pick}/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ReviewRow = memo(function ReviewRow({index, row, choice, onChange: changeLine, onPick: pickLine}) {
|
||||||
|
const {t} = useTranslation();
|
||||||
|
const onChange = (changes) => changeLine(index, changes);
|
||||||
|
const onPick = (candidate) => pickLine(index, candidate);
|
||||||
|
const [searching, setSearching] = useState(false);
|
||||||
|
const [query, setQuery] = useState(row.title ?? '');
|
||||||
|
const candidate = choice.candidates.find((known) => known.igdbId === choice.igdbId);
|
||||||
|
const label = `${t('IMPORT.LINE', {line: row.lineNo})}: ${row.title ?? ''}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li className="space-y-2 px-5 py-3" data-line={row.lineNo}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input type="checkbox" className="size-4 accent-primary" aria-label={label} checked={choice.checked}
|
||||||
|
disabled={!candidate} onChange={(e) => onChange({checked: e.target.checked})}/>
|
||||||
|
<span className="min-w-0 flex-1 truncate text-sm">{label}</span>
|
||||||
|
{candidate?.inLibrary && <Badge variant="secondary">{t('MANUAL_GAME.IN_LIBRARY')}</Badge>}
|
||||||
|
{row.searchFailed && <Badge variant="destructive">{t('IMPORT.SEARCH_FAILED')}</Badge>}
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setSearching(!searching)}>
|
||||||
|
<Search/>{t('IMPORT.SEARCH_AGAIN')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{searching && (
|
||||||
|
<GameSearch query={query} onQueryChange={setQuery} hide={(hit) => !hit.igdbId} onPick={(hit) => {
|
||||||
|
onPick(hit);
|
||||||
|
setSearching(false);
|
||||||
|
}}/>
|
||||||
|
)}
|
||||||
|
{choice.candidates.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">{t('IMPORT.NO_MATCH')}</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-2 sm:grid-cols-[2fr_1fr_1fr_6rem]">
|
||||||
|
<select className={SELECT_CLASS} aria-label={t('IMPORT.MATCH', {line: row.lineNo})} value={choice.igdbId ?? ''}
|
||||||
|
onChange={(e) => onPick(choice.candidates.find((known) => String(known.igdbId) === e.target.value))}>
|
||||||
|
{choice.candidates.map((known) => (
|
||||||
|
<option key={known.igdbId} value={known.igdbId}>
|
||||||
|
{known.name}{known.year ? ` (${known.year})` : ''}{known.inLibrary ? ` · ${t('MANUAL_GAME.IN_LIBRARY')}` : ''}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{candidate?.platforms.length > 0 ? (
|
||||||
|
<select className={SELECT_CLASS} aria-label={t('IMPORT.PLATFORM', {line: row.lineNo})} value={choice.platform}
|
||||||
|
onChange={(e) => onChange({platform: e.target.value})}>
|
||||||
|
<option value="">{t('IMPORT.PICK_PLATFORM')}</option>
|
||||||
|
{!candidate.platforms.includes(choice.platform) && choice.platform && (
|
||||||
|
<option value={choice.platform}>{choice.platform}</option>
|
||||||
|
)}
|
||||||
|
{candidate.platforms.map((platform) => <option key={platform} value={platform}>{platform}</option>)}
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<Input value={choice.platform} maxLength={MAX_PLATFORM_LENGTH} aria-label={t('IMPORT.PLATFORM', {line: row.lineNo})}
|
||||||
|
onChange={(e) => onChange({platform: e.target.value})}/>
|
||||||
|
)}
|
||||||
|
<select className={SELECT_CLASS} aria-label={t('IMPORT.STATUS_LABEL', {line: row.lineNo})} value={choice.status}
|
||||||
|
onChange={(e) => onChange({status: e.target.value})}>
|
||||||
|
{GAME_STATUSES.map((status) => <option key={status} value={status}>{t(`BACKLOG.STATUS.${status}`)}</option>)}
|
||||||
|
</select>
|
||||||
|
<Input type="number" min="0" step="0.5" inputMode="decimal" value={choice.hours}
|
||||||
|
aria-label={t('IMPORT.HOURS', {line: row.lineNo})} onChange={(e) => onChange({hours: e.target.value})}/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import {useEffect, useRef, useState} from 'react';
|
||||||
|
import {useTranslation} from 'react-i18next';
|
||||||
|
import {toast} from 'sonner';
|
||||||
|
import {CheckCircle2, Circle, Loader2, RotateCcw, XCircle} from 'lucide-react';
|
||||||
|
|
||||||
|
import {Button} from '@/components/ui/button';
|
||||||
|
import {Card} from '@/components/ui/card';
|
||||||
|
import {cn} from '@/lib/utils';
|
||||||
|
import ImportReview from './ImportReview.jsx';
|
||||||
|
import {useCurrentUserContext} from '../../context/CurrentUserContext.jsx';
|
||||||
|
import {getImport, retryImport, RUNNING, STEPS, stepOf} from '../../utils/imports.js';
|
||||||
|
|
||||||
|
const POLL_MS = 2000;
|
||||||
|
|
||||||
|
// Polls while a worker has the import.
|
||||||
|
export default function ImportView({id, onDone, onChanged}) {
|
||||||
|
const {t} = useTranslation();
|
||||||
|
const [data, setData] = useState(null);
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
// Grows after a save or a retry: the import is back with a worker, so polling starts again.
|
||||||
|
const [version, setVersion] = useState(0);
|
||||||
|
const reload = () => setVersion((current) => current + 1);
|
||||||
|
const lastStatus = useRef(null);
|
||||||
|
// undefined while loading: the review preselects with it, so it waits.
|
||||||
|
const {user} = useCurrentUserContext();
|
||||||
|
const favorite = user === undefined ? undefined : user?.settings?.favoritePlatform ?? null;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let timer;
|
||||||
|
let cancelled = false;
|
||||||
|
const load = async () => {
|
||||||
|
try {
|
||||||
|
const response = await getImport(id);
|
||||||
|
if (cancelled) 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);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
timer = setTimeout(load, POLL_MS);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const next = await response.json();
|
||||||
|
if (cancelled) return;
|
||||||
|
// A new status also shows in the history.
|
||||||
|
if (lastStatus.current && lastStatus.current !== next.status) onChanged();
|
||||||
|
lastStatus.current = next.status;
|
||||||
|
setData(next);
|
||||||
|
if (RUNNING.includes(next.status)) timer = setTimeout(load, POLL_MS);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Import load error:', err);
|
||||||
|
if (!cancelled) timer = setTimeout(load, POLL_MS);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
load();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
};
|
||||||
|
}, [id, version, onChanged]);
|
||||||
|
|
||||||
|
if (failed) return <p className="text-sm text-destructive">{t('IMPORT.LOAD_FAILED')}</p>;
|
||||||
|
if (!data || favorite === undefined) return <Loader2 className="animate-spin text-muted-foreground"/>;
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<ImportSteps data={data}/>
|
||||||
|
{data.status === 'READY' ? (
|
||||||
|
<ImportReview data={data} favorite={favorite} onSaved={reload} onDone={onDone}/>
|
||||||
|
) : (
|
||||||
|
<ImportProgress data={data} onRetried={reload} onDone={onDone}/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The steps in a row: done ones checked, the current one marked (red when the import failed there).
|
||||||
|
function ImportSteps({data}) {
|
||||||
|
const {t} = useTranslation();
|
||||||
|
const current = STEPS.indexOf(stepOf(data));
|
||||||
|
const failedHere = data.status === 'FAILED';
|
||||||
|
return (
|
||||||
|
<ol className="flex flex-wrap gap-x-4 gap-y-2 text-sm" aria-label={t('IMPORT.STEPS')}>
|
||||||
|
{STEPS.map((step, index) => {
|
||||||
|
const done = index < current || (index === current && step === 'DONE');
|
||||||
|
const now = index === current && !done;
|
||||||
|
return (
|
||||||
|
<li key={step} aria-current={now ? 'step' : undefined}
|
||||||
|
className={cn('flex items-center gap-1.5', done && 'text-muted-foreground',
|
||||||
|
now && (failedHere ? 'font-medium text-destructive' : 'font-medium text-primary'),
|
||||||
|
!done && !now && 'text-muted-foreground/60')}>
|
||||||
|
{done ? <CheckCircle2 className="size-4"/> : now && failedHere ? <XCircle className="size-4"/>
|
||||||
|
: now && RUNNING.includes(data.status) ? <Loader2 className="size-4 animate-spin"/>
|
||||||
|
: <Circle className="size-4"/>}
|
||||||
|
{t(`IMPORT.STEP.${step}`)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// What the current step does, with its numbers; Retry for a FAILED import.
|
||||||
|
function ImportProgress({data, onRetried, onDone}) {
|
||||||
|
const {t} = useTranslation();
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const step = stepOf(data);
|
||||||
|
const waiting = data.ahead != null;
|
||||||
|
|
||||||
|
const retry = async () => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const response = await retryImport(data.id);
|
||||||
|
if (response.status === 401) return;
|
||||||
|
if (response.status === 409) {
|
||||||
|
toast.error(t('IMPORT.RUNNING'));
|
||||||
|
} else if (!response.ok) {
|
||||||
|
toast.error(t('IMPORT.RETRY_FAILED'));
|
||||||
|
} else {
|
||||||
|
onRetried();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Import retry error:', err);
|
||||||
|
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let text;
|
||||||
|
let progress = null;
|
||||||
|
if (data.status === 'FAILED') {
|
||||||
|
text = t('IMPORT.FAILED', {count: data.attempts});
|
||||||
|
} else if (waiting) {
|
||||||
|
text = data.ahead > 0 ? t('IMPORT.WAITING', {count: data.ahead}) : t('IMPORT.WAITING_NEXT');
|
||||||
|
} else if (step === 'MATCHING') {
|
||||||
|
text = t('IMPORT.MATCHING', {matched: data.matched, total: data.total});
|
||||||
|
progress = [data.matched, data.total];
|
||||||
|
} else if (step === 'SAVING') {
|
||||||
|
text = t('IMPORT.SAVING', {saved: data.saved, chosen: data.chosen});
|
||||||
|
progress = [data.saved, data.chosen];
|
||||||
|
} else if (step === 'DETAILS') {
|
||||||
|
text = t('IMPORT.DETAILS');
|
||||||
|
} else {
|
||||||
|
text = t(data.status === 'UNDONE' ? 'IMPORT.STATUS.UNDONE' : 'IMPORT.DONE',
|
||||||
|
{added: data.added, skipped: data.saved - data.added});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="gap-3 p-5">
|
||||||
|
<p className={cn('font-medium', data.status === 'FAILED' && 'text-destructive')}>{text}</p>
|
||||||
|
{progress && <progress className="w-full accent-primary" value={progress[0]} max={progress[1]}/>}
|
||||||
|
{data.attempts > 0 && RUNNING.includes(data.status) && (
|
||||||
|
<p className="text-sm text-muted-foreground">{t('IMPORT.ATTEMPT', {attempt: data.attempts + 1})}</p>
|
||||||
|
)}
|
||||||
|
{RUNNING.includes(data.status) && <p className="text-sm text-muted-foreground">{t('IMPORT.MATCHING_HINT')}</p>}
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{data.status === 'FAILED' && (
|
||||||
|
<Button onClick={retry} disabled={busy}>
|
||||||
|
{busy ? <Loader2 className="animate-spin"/> : <RotateCcw/>}{t('IMPORT.RETRY')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{!RUNNING.includes(data.status) && (
|
||||||
|
<Button variant="outline" onClick={onDone}>{t('IMPORT.NEW')}</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import * as React from "react"
|
|
||||||
import { cva } from "class-variance-authority";
|
import { cva } from "class-variance-authority";
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import * as React from "react"
|
|
||||||
import { cva } from "class-variance-authority";
|
import { cva } from "class-variance-authority";
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { Slot } from "radix-ui"
|
import { Slot } from "radix-ui"
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import * as React from "react"
|
|
||||||
import { cva } from "class-variance-authority";
|
import { cva } from "class-variance-authority";
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { Slot } from "radix-ui"
|
import { Slot } from "radix-ui"
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import * as React from "react"
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
function Card({
|
function Card({
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import * as React from "react"
|
|
||||||
import { XIcon } from "lucide-react"
|
import { XIcon } from "lucide-react"
|
||||||
import { Dialog as DialogPrimitive } from "radix-ui"
|
import { Dialog as DialogPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import * as React from "react"
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
||||||
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
|
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import * as React from "react"
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
function Input({
|
function Input({
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import * as React from "react"
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { Label as LabelPrimitive } from "radix-ui"
|
import { Label as LabelPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import * as React from "react"
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
||||||
import { Select as SelectPrimitive } from "radix-ui"
|
import { Select as SelectPrimitive } from "radix-ui"
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import * as React from "react"
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { Separator as SeparatorPrimitive } from "radix-ui"
|
import { Separator as SeparatorPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import * as React from "react"
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { Switch as SwitchPrimitive } from "radix-ui"
|
import { Switch as SwitchPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import * as React from "react"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
function Textarea({ className, ...props }) {
|
function Textarea({ className, ...props }) {
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import * as React from "react"
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { Tooltip as TooltipPrimitive } from "radix-ui"
|
import { Tooltip as TooltipPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import {createContext, useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react';
|
||||||
|
import {request} from '../utils/api.js';
|
||||||
|
import {hasSession} from '../utils/auth.js';
|
||||||
|
import useSession from '../hooks/useSession.js';
|
||||||
|
import {ME_URL, REQUEST_TIMEOUT_MS} from '../utils/settings.js';
|
||||||
|
|
||||||
|
const CurrentUserContext = 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. It follows the
|
||||||
|
* session. 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;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// A new stamp is a login, a "view as" or a settings save, also in another tab.
|
||||||
|
const {stamp} = useSession();
|
||||||
|
useEffect(() => {
|
||||||
|
if (stamp) {
|
||||||
|
reload();
|
||||||
|
} else {
|
||||||
|
// A /me answer still on the way must not bring the user back.
|
||||||
|
latest.current += 1;
|
||||||
|
setUser(null);
|
||||||
|
}
|
||||||
|
}, [stamp, reload]);
|
||||||
|
|
||||||
|
const updateUser = useCallback((change) => setUser((current) => current && change(current)), []);
|
||||||
|
const value = useMemo(() => ({user, reload, updateUser}), [user, reload, updateUser]);
|
||||||
|
|
||||||
|
return <CurrentUserContext.Provider value={value}>{children}</CurrentUserContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `{user, reload, updateUser}`.
|
||||||
|
*/
|
||||||
|
export function useCurrentUserContext() {
|
||||||
|
const value = useContext(CurrentUserContext);
|
||||||
|
if (!value) throw new Error('useCurrentUserContext must be used inside <CurrentUserProvider>');
|
||||||
|
return value;
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
// @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 {clearSession, markSession} from '../utils/auth.js';
|
||||||
|
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
||||||
|
|
||||||
|
const context = {current: null};
|
||||||
|
|
||||||
|
function Role({name}) {
|
||||||
|
const user = useCurrentUser(true);
|
||||||
|
return <p>{name}: {user?.activeRole ?? 'none'}</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Spy() {
|
||||||
|
context.current = useCurrentUserContext();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderTwo = () => render(
|
||||||
|
<CurrentUserProvider><Role name="header"/><Role name="page"/><Spy/></CurrentUserProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
||||||
|
act(() => markSession());
|
||||||
|
|
||||||
|
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('does not bring the user back when /me answers after a logout', async () => {
|
||||||
|
markSession();
|
||||||
|
const answers = [];
|
||||||
|
fetchRoutes({'GET /api/users/me': () => new Promise((resolve) => answers.push(resolve))});
|
||||||
|
renderTwo();
|
||||||
|
await waitFor(() => expect(answers).toHaveLength(1));
|
||||||
|
|
||||||
|
act(() => clearSession());
|
||||||
|
await act(async () => answers[0](json(200, {role: 'PLATFORM_ADMIN'})));
|
||||||
|
|
||||||
|
expect(screen.getByText('header: none')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
saveAppearance,
|
saveAppearance,
|
||||||
} from '../utils/appearance.js';
|
} from '../utils/appearance.js';
|
||||||
import {hasSession} from '../utils/auth.js';
|
import {hasSession} from '../utils/auth.js';
|
||||||
|
import useSession from '../hooks/useSession.js';
|
||||||
import {LANGUAGES, saveSettings} from '../utils/settings.js';
|
import {LANGUAGES, saveSettings} from '../utils/settings.js';
|
||||||
|
|
||||||
const DARK_MODE_STORAGE_KEY = 'tbp_dark_mode';
|
const DARK_MODE_STORAGE_KEY = 'tbp_dark_mode';
|
||||||
@@ -124,6 +125,12 @@ export function PreferencesProvider({children}) {
|
|||||||
setAppearanceState(DEFAULT_APPEARANCE);
|
setAppearanceState(DEFAULT_APPEARANCE);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Without a session the next user must not get this look.
|
||||||
|
const {active} = useSession();
|
||||||
|
useEffect(() => {
|
||||||
|
if (!active) resetAppearance();
|
||||||
|
}, [active, resetAppearance]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Applies the language at once and saves it to Auth while logged in.
|
* Applies the language at once and saves it to Auth while logged in.
|
||||||
*
|
*
|
||||||
@@ -161,12 +168,3 @@ export function usePreferences() {
|
|||||||
}
|
}
|
||||||
return ctx;
|
return ctx;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* resetAppearance, or a no-op outside a PreferencesProvider, so hooks used in bare component tests keep working.
|
|
||||||
*/
|
|
||||||
export function useResetAppearance() {
|
|
||||||
return useContext(PreferencesContext)?.resetAppearance ?? noop;
|
|
||||||
}
|
|
||||||
|
|
||||||
const noop = () => {};
|
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||||
import {act, cleanup, render} from '@testing-library/react';
|
import {act, cleanup, render} from '@testing-library/react';
|
||||||
import {PreferencesProvider, usePreferences} from './PreferencesContext.jsx';
|
import {PreferencesProvider, usePreferences} from './PreferencesContext.jsx';
|
||||||
|
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
||||||
|
|
||||||
const i18n = vi.hoisted(() => ({language: 'de'}));
|
const i18n = vi.hoisted(() => ({language: 'de'}));
|
||||||
vi.mock('react-i18next', async () => {
|
vi.mock('react-i18next', async () => {
|
||||||
@@ -31,11 +32,10 @@ afterEach(() => {
|
|||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
});
|
});
|
||||||
|
|
||||||
const answerMe = (settings) => {
|
const answerMe = (settings) => fetchRoutes({
|
||||||
globalThis.fetch = vi.fn(async (url, init) => (init?.method === 'PUT'
|
'GET /api/users/me': json(200, {displayName: 'Lobo', settings}),
|
||||||
? {ok: true, status: 204}
|
'PUT /api/users/settings': json(204),
|
||||||
: {ok: true, status: 200, json: async () => ({displayName: 'Lobo', settings})}));
|
});
|
||||||
};
|
|
||||||
|
|
||||||
describe('PreferencesProvider language', () => {
|
describe('PreferencesProvider language', () => {
|
||||||
it('takes the language saved in Auth', async () => {
|
it('takes the language saved in Auth', async () => {
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import {useCallback, useEffect, useRef} from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns `nextSignal()` for a load that can run again before the last one answered: it aborts the last request and
|
||||||
|
* gives the signal for the next one. Unmounting aborts too. After the await, a load stops when its signal is aborted.
|
||||||
|
*/
|
||||||
|
export default function useAbortPrevious() {
|
||||||
|
const controller = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => () => controller.current?.abort(), []);
|
||||||
|
|
||||||
|
return useCallback(() => {
|
||||||
|
controller.current?.abort();
|
||||||
|
controller.current = new AbortController();
|
||||||
|
return controller.current.signal;
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
+10
-29
@@ -1,37 +1,18 @@
|
|||||||
import {useEffect, useState} from 'react';
|
import {useMemo} from 'react';
|
||||||
import {apiFetch} from '../utils/api.js';
|
import {useCurrentUserContext} from '../context/CurrentUserContext.jsx';
|
||||||
import {sessionStamp} from '../utils/auth.js';
|
|
||||||
import {normalizeProfileStyle} from '../utils/profileStyle.js';
|
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
|
* 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.
|
* user, or null while loading, on any error and when not `enabled`.
|
||||||
* Loads again after a new login or a role switch (see sessionStamp) on the next render.
|
|
||||||
*/
|
*/
|
||||||
export default function useCurrentUser(enabled) {
|
export default function useCurrentUser(enabled) {
|
||||||
const [user, setUser] = useState(null);
|
const {user} = useCurrentUserContext();
|
||||||
const stamp = sessionStamp();
|
|
||||||
|
|
||||||
useEffect(() => {
|
return useMemo(() => (enabled && user ? {
|
||||||
if (!enabled || !stamp) {
|
role: user.role,
|
||||||
setUser(null);
|
activeRole: user.activeRole ?? user.role,
|
||||||
return undefined;
|
displayName: user.displayName,
|
||||||
}
|
profileStyle: normalizeProfileStyle(user.settings?.profileStyle),
|
||||||
let cancelled = false;
|
} : null), [enabled, user]);
|
||||||
(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;
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import {afterEach, describe, expect, it} from 'vitest';
|
||||||
|
import {cleanup, renderHook, waitFor} from '@testing-library/react';
|
||||||
|
import useCurrentUser from './useCurrentUser.js';
|
||||||
|
import {CurrentUserProvider, useCurrentUserContext} from '../context/CurrentUserContext.jsx';
|
||||||
|
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
||||||
|
|
||||||
|
const renderUser = () => renderHook(() => useCurrentUser(true), {wrapper: CurrentUserProvider});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useCurrentUser', () => {
|
||||||
|
it('maps the user from /me and normalizes the profile style', async () => {
|
||||||
|
localStorage.setItem('tbp_session', '1');
|
||||||
|
fetchRoutes({'GET /api/users/me': json(200, {role: 'ADMIN', activeRole: 'USER', displayName: 'Lobo',
|
||||||
|
settings: {profileStyle: {accent: 'gold', corners: 'round', bannerAppid: 220, backgroundAppid: -5}}})});
|
||||||
|
const {result} = renderUser();
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current).not.toBeNull());
|
||||||
|
expect(result.current).toEqual({role: 'ADMIN', activeRole: 'USER', displayName: 'Lobo',
|
||||||
|
profileStyle: {accent: 'violet', corners: 'round', bannerAppid: 220, backgroundAppid: null}});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the role as active role when there is no "view as"', async () => {
|
||||||
|
localStorage.setItem('tbp_session', '1');
|
||||||
|
fetchRoutes({'GET /api/users/me': json(200, {role: 'USER', displayName: 'Lobo', settings: {}})});
|
||||||
|
const {result} = renderUser();
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current?.activeRole).toBe('USER'));
|
||||||
|
expect(result.current.profileStyle).toEqual({accent: 'violet', corners: 'default', bannerAppid: null,
|
||||||
|
backgroundAppid: null});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when not enabled', async () => {
|
||||||
|
localStorage.setItem('tbp_session', '1');
|
||||||
|
fetchRoutes({'GET /api/users/me': json(200, {role: 'USER', settings: {}})});
|
||||||
|
const {result} = renderHook(() => ({mapped: useCurrentUser(false), user: useCurrentUserContext().user}),
|
||||||
|
{wrapper: CurrentUserProvider});
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.user?.role).toBe('USER'));
|
||||||
|
expect(result.current.mapped).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null without a session and asks for no user', () => {
|
||||||
|
const fetch = fetchRoutes({});
|
||||||
|
const {result} = renderUser();
|
||||||
|
|
||||||
|
expect(result.current).toBeNull();
|
||||||
|
expect(fetch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import {useCallback, useEffect, useState} from 'react';
|
||||||
|
import {useCurrentUserContext} from '../context/CurrentUserContext.jsx';
|
||||||
|
import {request} from '../utils/api.js';
|
||||||
|
import {PLAYING_GAMES, TOP_GAMES} from '../utils/tiles.js';
|
||||||
|
|
||||||
|
// The games endpoint accepts only the page sizes 20, 40 and 80.
|
||||||
|
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';
|
||||||
|
|
||||||
|
// 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));
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function useDashboardData() {
|
||||||
|
const {reload: reloadUser} = useCurrentUserContext();
|
||||||
|
const [steamData, setSteamData] = useState({count: 0, hasSteamId: false});
|
||||||
|
const [topGames, setTopGames] = useState([]);
|
||||||
|
const [playing, setPlaying] = useState([]);
|
||||||
|
const [summary, setSummary] = useState(null);
|
||||||
|
const [activity, setActivity] = useState(null);
|
||||||
|
const [achievements, setAchievements] = useState(null);
|
||||||
|
const [summaryFailed, setSummaryFailed] = useState(false);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
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; true on a 401, which ends the session without a "failed" message
|
||||||
|
*/
|
||||||
|
const fetchSummary = useCallback(async (excludeAppid) => {
|
||||||
|
const query = excludeAppid ? `?exclude=${excludeAppid}` : '';
|
||||||
|
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 reloadLibrary = () => {
|
||||||
|
fetchSteamCount();
|
||||||
|
fetchTopGames();
|
||||||
|
fetchPlaying();
|
||||||
|
fetchSummary();
|
||||||
|
fetchActivity();
|
||||||
|
fetchAchievements();
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchUserData = async () => {
|
||||||
|
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');
|
||||||
|
} else if (status !== 401) {
|
||||||
|
setError('INFO.ERROR.LOAD_PROFILE');
|
||||||
|
}
|
||||||
|
setIsLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchUserData();
|
||||||
|
}, [reloadUser, fetchSteamCount, fetchTopGames, fetchPlaying, fetchSummary, fetchActivity, fetchAchievements]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
steamData, topGames, playing, summary, summaryFailed, activity, achievements, isLoading, error,
|
||||||
|
fetchSummary, reloadLibrary,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,37 +1,23 @@
|
|||||||
import {useState} from 'react';
|
import {useState} from 'react';
|
||||||
import {useNavigate} from 'react-router-dom';
|
|
||||||
import {useTranslation} from 'react-i18next';
|
import {useTranslation} from 'react-i18next';
|
||||||
import {toast} from 'sonner';
|
import {toast} from 'sonner';
|
||||||
import useSessionExpired from './useSessionExpired.js';
|
|
||||||
import {apiFetch} from '../utils/api.js';
|
import {apiFetch} from '../utils/api.js';
|
||||||
import {hasSession} from '../utils/auth.js';
|
|
||||||
|
|
||||||
const RELOAD_DELAY_MS = 4000;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Starts a library sync (POST /api/library/sync), reports the result as a toast and calls
|
* Starts a library sync (POST /api/library/sync), reports the result as a toast and calls onStarted, e.g. the
|
||||||
* onStarted after a short delay, so the caller can reload once Steam data has arrived.
|
* `watch` of useLibrarySync, which reloads the data when the run is over.
|
||||||
*/
|
*/
|
||||||
export default function useForceSync(onStarted) {
|
export default function useForceSync(onStarted) {
|
||||||
const {t} = useTranslation();
|
const {t} = useTranslation();
|
||||||
const navigate = useNavigate();
|
|
||||||
const expireSession = useSessionExpired();
|
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
const trigger = async () => {
|
const trigger = async () => {
|
||||||
if (!hasSession()) {
|
|
||||||
navigate('/login');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
const response = await apiFetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/sync`, {
|
const response = await apiFetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/sync`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
});
|
});
|
||||||
if (response.status === 401) {
|
if (response.status === 401) return;
|
||||||
expireSession();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (response.status === 429) {
|
if (response.status === 429) {
|
||||||
const json = await response.json().catch(() => ({}));
|
const json = await response.json().catch(() => ({}));
|
||||||
toast.warning(t('LIBRARY.SYNC_COOLDOWN', {seconds: json.retryAfterSeconds ?? 60}));
|
toast.warning(t('LIBRARY.SYNC_COOLDOWN', {seconds: json.retryAfterSeconds ?? 60}));
|
||||||
@@ -42,7 +28,7 @@ export default function useForceSync(onStarted) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
toast.success(t('LIBRARY.SYNC_STARTED'));
|
toast.success(t('LIBRARY.SYNC_STARTED'));
|
||||||
setTimeout(onStarted, RELOAD_DELAY_MS);
|
onStarted();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('sync trigger error:', err);
|
console.error('sync trigger error:', err);
|
||||||
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
|
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||||
|
import {act, cleanup, renderHook} from '@testing-library/react';
|
||||||
|
import {toast} from 'sonner';
|
||||||
|
import useForceSync from './useForceSync.js';
|
||||||
|
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
||||||
|
|
||||||
|
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||||
|
vi.mock('sonner', () => ({toast: {success: vi.fn(), warning: vi.fn(), error: vi.fn()}}));
|
||||||
|
|
||||||
|
const SYNC = 'POST /api/library/sync';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
localStorage.setItem('tbp_session', '1');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
const trigger = async (onStarted) => {
|
||||||
|
const {result} = renderHook(() => useForceSync(onStarted));
|
||||||
|
await act(() => result.current.trigger());
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('useForceSync', () => {
|
||||||
|
it('calls onStarted at once after the start, and not again later', async () => {
|
||||||
|
fetchRoutes({[SYNC]: json(202, {})});
|
||||||
|
const onStarted = vi.fn();
|
||||||
|
|
||||||
|
await trigger(onStarted);
|
||||||
|
expect(onStarted).toHaveBeenCalledTimes(1);
|
||||||
|
await act(() => vi.advanceTimersByTimeAsync(10_000));
|
||||||
|
|
||||||
|
expect(onStarted).toHaveBeenCalledTimes(1);
|
||||||
|
expect(toast.success).toHaveBeenCalledWith('LIBRARY.SYNC_STARTED');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says how long to wait on a 429', async () => {
|
||||||
|
fetchRoutes({[SYNC]: json(429, {retryAfterSeconds: 42})});
|
||||||
|
const onStarted = vi.fn();
|
||||||
|
|
||||||
|
await trigger(onStarted);
|
||||||
|
|
||||||
|
expect(toast.warning).toHaveBeenCalledWith('LIBRARY.SYNC_COOLDOWN');
|
||||||
|
expect(onStarted).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says so when the start fails', async () => {
|
||||||
|
fetchRoutes({[SYNC]: json(500, {})});
|
||||||
|
await trigger(vi.fn());
|
||||||
|
expect(toast.error).toHaveBeenCalledWith('LIBRARY.SYNC_FAILED');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says so when the network is down', async () => {
|
||||||
|
vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||||
|
fetchRoutes({[SYNC]: () => Promise.reject(new TypeError('Failed to fetch'))});
|
||||||
|
await trigger(vi.fn());
|
||||||
|
expect(toast.error).toHaveBeenCalledWith('INFO.ERROR.NETWORK_FAILURE');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import {useCallback, useEffect, useState} from 'react';
|
||||||
|
import {request} from '../utils/api.js';
|
||||||
|
import useAbortPrevious from './useAbortPrevious.js';
|
||||||
|
|
||||||
|
export default function useGame(appid) {
|
||||||
|
const [data, setData] = useState(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [reloads, setReloads] = useState(0);
|
||||||
|
|
||||||
|
// Another game starts empty, already in this render. A reload of the same game keeps the old data, so the edit
|
||||||
|
// cards keep what is typed.
|
||||||
|
const [dataAppid, setDataAppid] = useState(appid);
|
||||||
|
if (dataAppid !== appid) {
|
||||||
|
setDataAppid(appid);
|
||||||
|
setData(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextSignal = useAbortPrevious();
|
||||||
|
useEffect(() => {
|
||||||
|
const signal = nextSignal();
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
(async () => {
|
||||||
|
const {ok, status, data: json} = await request(
|
||||||
|
`${import.meta.env.VITE_CORE_API_URL}/api/library/games/${appid}`, {signal});
|
||||||
|
if (signal.aborted) return;
|
||||||
|
setIsLoading(false);
|
||||||
|
if (status === 401) return;
|
||||||
|
if (status === 404) setError('GAME_DETAIL.NOT_FOUND');
|
||||||
|
else if (!ok || !json) setError(status === 0 ? 'INFO.ERROR.NETWORK_FAILURE' : 'GAME_DETAIL.ERROR_LOAD');
|
||||||
|
else setData(json);
|
||||||
|
})();
|
||||||
|
}, [nextSignal, appid, reloads]);
|
||||||
|
|
||||||
|
const reload = useCallback(() => setReloads((n) => n + 1), []);
|
||||||
|
|
||||||
|
return {data, setData, isLoading, error, reload};
|
||||||
|
}
|
||||||
+43
-30
@@ -1,4 +1,4 @@
|
|||||||
import {useEffect, useRef, useState} from 'react';
|
import {useCallback, useEffect, useRef, useState} from 'react';
|
||||||
import {apiFetch} from '../utils/api.js';
|
import {apiFetch} from '../utils/api.js';
|
||||||
import {hasSession} from '../utils/auth.js';
|
import {hasSession} from '../utils/auth.js';
|
||||||
|
|
||||||
@@ -7,31 +7,42 @@ const MAX_POLL_DURATION_MS = 120_000;
|
|||||||
const INITIAL_SYNC_GRACE_MS = 15_000;
|
const INITIAL_SYNC_GRACE_MS = 15_000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Polls /api/library/sync-status while a sync is running (or expected),
|
* Polls /api/library/sync-status while a sync is running (or expected), and fires onComplete once when the run ends.
|
||||||
* and fires onComplete once when the run transitions to OK / FAILED.
|
* A run that still runs after 2 minutes is shown as `stale` and polling stops. `watch()` polls again after the
|
||||||
|
* user started a sync: that run counts as ended when `lastSyncedAt` changes, also when no poll saw it running.
|
||||||
*
|
*
|
||||||
* @param {Object} opts
|
* @param {Object} opts
|
||||||
* @param {boolean} opts.enabled - skip work entirely when false (e.g. user has no steamId)
|
* @param {boolean} opts.enabled - skip work entirely when false (e.g. user has no steamId)
|
||||||
* @param {boolean} opts.expectInitialSync - if true and no row yet, keep polling (covers the
|
* @param {boolean} opts.expectInitialSync - if true and no row yet, keep polling (covers the
|
||||||
* gap between async sync dispatch and first DB write)
|
* gap between async sync dispatch and first DB write)
|
||||||
* @param {() => void} opts.onComplete - called once per transition to OK/FAILED
|
* @param {() => void} opts.onComplete - called once per run that ends
|
||||||
* @param {() => void} [opts.onUnauthorized] - called on a 401; polling stops
|
* @returns {{status: string|null, lastSyncedAt: string|null, isRunning: boolean, stale: boolean, error: string|null,
|
||||||
* @returns {{status: string|null, lastSyncedAt: string|null, isRunning: boolean, error: string|null}}
|
* watch: () => void}}
|
||||||
*/
|
*/
|
||||||
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 [state, setState] = useState({status: null, lastSyncedAt: null, error: null, stale: false});
|
||||||
const previousRunningRef = useRef(null);
|
// count starts polling again; since is the last known end, so a new one shows that the started run is over
|
||||||
|
const [watched, setWatched] = useState({count: 0, since: undefined});
|
||||||
|
const lastSyncedAtRef = useRef(undefined);
|
||||||
|
// The count of the last watch whose run ended; a later restart of the polling must not report that run again.
|
||||||
|
const endedWatch = useRef(0);
|
||||||
const onCompleteRef = useRef(onComplete);
|
const onCompleteRef = useRef(onComplete);
|
||||||
onCompleteRef.current = onComplete;
|
onCompleteRef.current = onComplete;
|
||||||
const onUnauthorizedRef = useRef(onUnauthorized);
|
|
||||||
onUnauthorizedRef.current = onUnauthorized;
|
const watch = useCallback(() => {
|
||||||
|
setWatched((current) => ({count: current.count + 1, since: lastSyncedAtRef.current}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!enabled) return undefined;
|
if (!enabled) return undefined;
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
let timer = null;
|
let timer = null;
|
||||||
|
let wasRunning = false;
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
|
const watching = watched.count > endedWatch.current;
|
||||||
|
// Before the first answer the last end is unknown; then the first answer is the one to compare with.
|
||||||
|
let since = watched.since;
|
||||||
|
|
||||||
const fetchOnce = async () => {
|
const fetchOnce = async () => {
|
||||||
if (!hasSession()) return;
|
if (!hasSession()) return;
|
||||||
@@ -42,10 +53,8 @@ export default function useLibrarySync({enabled, expectInitialSync = false, onCo
|
|||||||
);
|
);
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
|
|
||||||
if (response.status === 401) {
|
// The session is over; polling stops.
|
||||||
onUnauthorizedRef.current?.();
|
if (response.status === 401) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
setState((s) => ({...s, error: 'sync-status fetch failed'}));
|
setState((s) => ({...s, error: 'sync-status fetch failed'}));
|
||||||
return;
|
return;
|
||||||
@@ -56,22 +65,24 @@ export default function useLibrarySync({enabled, expectInitialSync = false, onCo
|
|||||||
|
|
||||||
const status = json.lastSyncStatus;
|
const status = json.lastSyncStatus;
|
||||||
const lastSyncedAt = json.lastSyncedAt;
|
const lastSyncedAt = json.lastSyncedAt;
|
||||||
setState({status, lastSyncedAt, error: null});
|
lastSyncedAtRef.current = lastSyncedAt;
|
||||||
|
|
||||||
const isRunning = status === 'RUNNING';
|
const isRunning = status === 'RUNNING';
|
||||||
const wasRunning = previousRunningRef.current;
|
|
||||||
|
|
||||||
if (wasRunning === true && !isRunning) {
|
|
||||||
onCompleteRef.current?.();
|
|
||||||
}
|
|
||||||
previousRunningRef.current = isRunning;
|
|
||||||
|
|
||||||
const elapsed = Date.now() - startedAt;
|
const elapsed = Date.now() - startedAt;
|
||||||
const waitingForInitial =
|
const stale = isRunning && elapsed >= MAX_POLL_DURATION_MS;
|
||||||
expectInitialSync && status == null && elapsed < INITIAL_SYNC_GRACE_MS;
|
setState({status, lastSyncedAt, error: null, stale});
|
||||||
const stillWaiting = isRunning || waitingForInitial;
|
|
||||||
|
|
||||||
if (stillWaiting && elapsed < MAX_POLL_DURATION_MS) {
|
if (since === undefined) since = lastSyncedAt;
|
||||||
|
const ended = !isRunning && (wasRunning || (watching && lastSyncedAt !== since));
|
||||||
|
if (ended) {
|
||||||
|
endedWatch.current = watched.count;
|
||||||
|
onCompleteRef.current?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
wasRunning = isRunning;
|
||||||
|
|
||||||
|
const waitingForInitial = expectInitialSync && status == null;
|
||||||
|
const waitingForStart = (watching || waitingForInitial) && elapsed < INITIAL_SYNC_GRACE_MS;
|
||||||
|
if ((isRunning && !stale) || waitingForStart) {
|
||||||
timer = setTimeout(fetchOnce, POLL_INTERVAL_MS);
|
timer = setTimeout(fetchOnce, POLL_INTERVAL_MS);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -87,12 +98,14 @@ export default function useLibrarySync({enabled, expectInitialSync = false, onCo
|
|||||||
cancelled = true;
|
cancelled = true;
|
||||||
if (timer) clearTimeout(timer);
|
if (timer) clearTimeout(timer);
|
||||||
};
|
};
|
||||||
}, [enabled, expectInitialSync]);
|
}, [enabled, expectInitialSync, watched]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status: state.status,
|
status: state.status,
|
||||||
lastSyncedAt: state.lastSyncedAt,
|
lastSyncedAt: state.lastSyncedAt,
|
||||||
isRunning: state.status === 'RUNNING',
|
isRunning: state.status === 'RUNNING' && !state.stale,
|
||||||
|
stale: state.stale,
|
||||||
error: state.error,
|
error: state.error,
|
||||||
|
watch,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||||
|
import {act, cleanup, renderHook} from '@testing-library/react';
|
||||||
|
import useLibrarySync from './useLibrarySync.js';
|
||||||
|
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
||||||
|
|
||||||
|
const status = (lastSyncStatus, lastSyncedAt) => json(200, {lastSyncStatus, lastSyncedAt});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
localStorage.setItem('tbp_session', '1');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.useRealTimers();
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useLibrarySync', () => {
|
||||||
|
it('shows a sync that runs longer than the poll limit as stale, not as running forever', async () => {
|
||||||
|
const fetch = fetchRoutes({'GET /api/library/sync-status': status('RUNNING', null)});
|
||||||
|
const {result} = renderHook(() => useLibrarySync({enabled: true, onComplete: () => {}}));
|
||||||
|
await act(() => vi.advanceTimersByTimeAsync(0));
|
||||||
|
expect(result.current.isRunning).toBe(true);
|
||||||
|
|
||||||
|
await act(() => vi.advanceTimersByTimeAsync(125_000));
|
||||||
|
|
||||||
|
expect(result.current.isRunning).toBe(false);
|
||||||
|
expect(result.current.stale).toBe(true);
|
||||||
|
const calls = fetch.mock.calls.length;
|
||||||
|
await act(() => vi.advanceTimersByTimeAsync(30_000));
|
||||||
|
expect(fetch.mock.calls.length).toBe(calls);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('after watch() reports the end of a sync once, also when it never saw it running', async () => {
|
||||||
|
let answer = status('OK', '2026-09-30T08:00:00Z');
|
||||||
|
fetchRoutes({'GET /api/library/sync-status': () => answer});
|
||||||
|
const onComplete = vi.fn();
|
||||||
|
const {result} = renderHook(() => useLibrarySync({enabled: true, onComplete}));
|
||||||
|
await act(() => vi.advanceTimersByTimeAsync(0));
|
||||||
|
|
||||||
|
act(() => result.current.watch());
|
||||||
|
await act(() => vi.advanceTimersByTimeAsync(0));
|
||||||
|
// The sync was so fast that the next poll already sees it done.
|
||||||
|
answer = status('OK', '2026-09-30T08:05:00Z');
|
||||||
|
await act(() => vi.advanceTimersByTimeAsync(3_000));
|
||||||
|
await act(() => vi.advanceTimersByTimeAsync(30_000));
|
||||||
|
|
||||||
|
expect(onComplete).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports the end of a running sync once', async () => {
|
||||||
|
let answer = status('RUNNING', '2026-09-30T08:00:00Z');
|
||||||
|
fetchRoutes({'GET /api/library/sync-status': () => answer});
|
||||||
|
const onComplete = vi.fn();
|
||||||
|
const {result} = renderHook(() => useLibrarySync({enabled: true, onComplete}));
|
||||||
|
await act(() => vi.advanceTimersByTimeAsync(0));
|
||||||
|
|
||||||
|
answer = status('FAILED', '2026-09-30T08:01:00Z');
|
||||||
|
await act(() => vi.advanceTimersByTimeAsync(3_000));
|
||||||
|
await act(() => vi.advanceTimersByTimeAsync(30_000));
|
||||||
|
|
||||||
|
expect(onComplete).toHaveBeenCalledTimes(1);
|
||||||
|
expect(result.current.status).toBe('FAILED');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fires onComplete only once when expectInitialSync flips after the watched run ended', async () => {
|
||||||
|
let answer = status('OK', '2026-09-30T08:00:00Z');
|
||||||
|
fetchRoutes({'GET /api/library/sync-status': () => answer});
|
||||||
|
const onComplete = vi.fn();
|
||||||
|
const {result, rerender} = renderHook(
|
||||||
|
({expectInitialSync}) => useLibrarySync({enabled: true, expectInitialSync, onComplete}),
|
||||||
|
{initialProps: {expectInitialSync: false}});
|
||||||
|
await act(() => vi.advanceTimersByTimeAsync(0));
|
||||||
|
|
||||||
|
act(() => result.current.watch());
|
||||||
|
await act(() => vi.advanceTimersByTimeAsync(0));
|
||||||
|
answer = status('OK', '2026-09-30T08:05:00Z');
|
||||||
|
await act(() => vi.advanceTimersByTimeAsync(3_000));
|
||||||
|
expect(onComplete).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// onComplete loads the list: isLoading goes true and back to false, and with 0 games expectInitialSync follows.
|
||||||
|
rerender({expectInitialSync: true});
|
||||||
|
await act(() => vi.advanceTimersByTimeAsync(0));
|
||||||
|
rerender({expectInitialSync: false});
|
||||||
|
await act(() => vi.advanceTimersByTimeAsync(0));
|
||||||
|
|
||||||
|
expect(onComplete).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import {useCallback} from 'react';
|
||||||
|
import {useTranslation} from 'react-i18next';
|
||||||
|
import {toast} from 'sonner';
|
||||||
|
|
||||||
|
const BACKEND_ERROR_KEY = {
|
||||||
|
DISPLAY_NAME_ALREADY_IN_USE: 'SETTINGS.ERROR_DISPLAY_NAME_IN_USE',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Like `useSaveResponse`, but maps backend error codes, shows "saved" and resolves to `{ok}`. */
|
||||||
|
export default function usePersistSettings() {
|
||||||
|
const {t} = useTranslation();
|
||||||
|
|
||||||
|
return useCallback(async (send) => {
|
||||||
|
try {
|
||||||
|
const response = await send();
|
||||||
|
|
||||||
|
if (response.status === 401) return {ok: false};
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
const mappedKey = BACKEND_ERROR_KEY[data.code];
|
||||||
|
toast.error(t(mappedKey ?? 'SETTINGS.ERROR_SAVE'));
|
||||||
|
return {ok: false};
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success(t('SETTINGS.SAVED'));
|
||||||
|
return {ok: true};
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Settings save error:', err);
|
||||||
|
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
|
||||||
|
return {ok: false};
|
||||||
|
}
|
||||||
|
}, [t]);
|
||||||
|
}
|
||||||
@@ -2,25 +2,19 @@ import {useCallback} from 'react';
|
|||||||
import {useTranslation} from 'react-i18next';
|
import {useTranslation} from 'react-i18next';
|
||||||
import {toast} from 'sonner';
|
import {toast} from 'sonner';
|
||||||
import {usePreferences} from '../context/PreferencesContext.jsx';
|
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
|
* Returns `save(changes)`: changeAppearance plus the error messages. Resolves to true when saved.
|
||||||
* true when saved.
|
|
||||||
*/
|
*/
|
||||||
export default function useSaveAppearance() {
|
export default function useSaveAppearance() {
|
||||||
const {t} = useTranslation();
|
const {t} = useTranslation();
|
||||||
const {changeAppearance} = usePreferences();
|
const {changeAppearance} = usePreferences();
|
||||||
const expireSession = useSessionExpired();
|
|
||||||
|
|
||||||
return useCallback(async (changes) => {
|
return useCallback(async (changes) => {
|
||||||
try {
|
try {
|
||||||
const response = await changeAppearance(changes);
|
const response = await changeAppearance(changes);
|
||||||
if (!response) return false;
|
if (!response) return false;
|
||||||
if (response.status === 401) {
|
if (response.status === 401) return false;
|
||||||
expireSession();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
toast.error(t('SETTINGS.ERROR_SAVE'));
|
toast.error(t('SETTINGS.ERROR_SAVE'));
|
||||||
return false;
|
return false;
|
||||||
@@ -31,5 +25,5 @@ export default function useSaveAppearance() {
|
|||||||
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
|
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}, [changeAppearance, expireSession, t]);
|
}, [changeAppearance, t]);
|
||||||
}
|
}
|
||||||
@@ -1,19 +1,30 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||||
import {act, cleanup, renderHook} from '@testing-library/react';
|
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 {toast} from 'sonner';
|
||||||
import {PreferencesProvider, usePreferences} from '../context/PreferencesContext.jsx';
|
import {PreferencesProvider, usePreferences} from '../context/PreferencesContext.jsx';
|
||||||
import useSaveAppearance from './useSaveAppearance.js';
|
import useSaveAppearance from './useSaveAppearance.js';
|
||||||
|
import RequireSession from '../components/RequireSession.jsx';
|
||||||
import {DEFAULT_APPEARANCE} from '../utils/appearance.js';
|
import {DEFAULT_APPEARANCE} from '../utils/appearance.js';
|
||||||
|
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
||||||
|
|
||||||
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||||
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
|
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}) => (
|
const wrapper = ({children}) => (
|
||||||
<MemoryRouter><PreferencesProvider>{children}</PreferencesProvider></MemoryRouter>
|
<MemoryRouter><PreferencesProvider>
|
||||||
|
<Routes>
|
||||||
|
<Route element={<RequireSession/>}><Route path="/" element={children}/></Route>
|
||||||
|
<Route path="/login" element={children}/>
|
||||||
|
</Routes>
|
||||||
|
</PreferencesProvider></MemoryRouter>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const PUT = 'PUT /api/users/settings/appearance';
|
||||||
|
const ME = 'GET /api/users/me';
|
||||||
|
|
||||||
const renderSave = () => renderHook(() => ({save: useSaveAppearance(), preferences: usePreferences()}), {wrapper});
|
const renderSave = () => renderHook(() => ({save: useSaveAppearance(), preferences: usePreferences()}), {wrapper});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -28,7 +39,7 @@ afterEach(() => {
|
|||||||
|
|
||||||
describe('useSaveAppearance', () => {
|
describe('useSaveAppearance', () => {
|
||||||
it('applies the change at once and sends the whole appearance', async () => {
|
it('applies the change at once and sends the whole appearance', async () => {
|
||||||
globalThis.fetch = vi.fn(async () => ({ok: true, status: 204}));
|
fetchRoutes({[PUT]: json(204)});
|
||||||
const {result} = renderSave();
|
const {result} = renderSave();
|
||||||
|
|
||||||
let saved;
|
let saved;
|
||||||
@@ -46,7 +57,7 @@ describe('useSaveAppearance', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('puts the old value back when Auth refuses', async () => {
|
it('puts the old value back when Auth refuses', async () => {
|
||||||
globalThis.fetch = vi.fn(async () => ({ok: false, status: 400}));
|
fetchRoutes({[PUT]: json(400, {})});
|
||||||
const {result} = renderSave();
|
const {result} = renderSave();
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
@@ -59,11 +70,9 @@ describe('useSaveAppearance', () => {
|
|||||||
|
|
||||||
it('keeps a later change when an earlier save fails', async () => {
|
it('keeps a later change when an earlier save fails', async () => {
|
||||||
let failFirst;
|
let failFirst;
|
||||||
globalThis.fetch = vi.fn()
|
fetchRoutes({[PUT]: () => (failFirst ? json(204) : new Promise((resolve) => {
|
||||||
.mockImplementationOnce(() => new Promise((resolve) => {
|
failFirst = () => resolve(json(500, {}));
|
||||||
failFirst = () => resolve({ok: false, status: 500});
|
}))});
|
||||||
}))
|
|
||||||
.mockImplementation(async () => ({ok: true, status: 204}));
|
|
||||||
const {result} = renderSave();
|
const {result} = renderSave();
|
||||||
|
|
||||||
let first;
|
let first;
|
||||||
@@ -85,7 +94,7 @@ describe('useSaveAppearance', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('ends the session on a 401', async () => {
|
it('ends the session on a 401', async () => {
|
||||||
globalThis.fetch = vi.fn(async () => ({ok: false, status: 401}));
|
fetchRoutes({[PUT]: json(401, {}), 'POST /api/auth/refresh': json(401, {})});
|
||||||
const {result} = renderSave();
|
const {result} = renderSave();
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
@@ -97,7 +106,7 @@ describe('useSaveAppearance', () => {
|
|||||||
|
|
||||||
it('ends the session with the default look on a 401, so the next user does not get this one', async () => {
|
it('ends the session with the default look on a 401, so the next user does not get this one', async () => {
|
||||||
localStorage.setItem('tbp_appearance', JSON.stringify({accent: 'green', corners: 'round', wideLayout: true, backgroundAppid: 620}));
|
localStorage.setItem('tbp_appearance', JSON.stringify({accent: 'green', corners: 'round', wideLayout: true, backgroundAppid: 620}));
|
||||||
globalThis.fetch = vi.fn(async () => ({ok: false, status: 401}));
|
fetchRoutes({[PUT]: json(401, {}), 'POST /api/auth/refresh': json(401, {})});
|
||||||
const {result} = renderSave();
|
const {result} = renderSave();
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
@@ -111,11 +120,12 @@ describe('useSaveAppearance', () => {
|
|||||||
it('waits for a running load from Auth, then keeps its values for the fields not changed here', async () => {
|
it('waits for a running load from Auth, then keeps its values for the fields not changed here', async () => {
|
||||||
const fromServer = {accent: 'green', corners: 'default', wideLayout: false, backgroundAppid: null, bannerAppid: 620, showBanner: false};
|
const fromServer = {accent: 'green', corners: 'default', wideLayout: false, backgroundAppid: null, bannerAppid: 620, showBanner: false};
|
||||||
let answerMe;
|
let answerMe;
|
||||||
globalThis.fetch = vi.fn((url, init) => (init?.method === 'PUT'
|
fetchRoutes({
|
||||||
? Promise.resolve({ok: true, status: 204})
|
[PUT]: json(204),
|
||||||
: new Promise((resolve) => {
|
[ME]: () => new Promise((resolve) => {
|
||||||
answerMe = () => resolve({ok: true, status: 200, json: async () => ({settings: {appearance: fromServer}})});
|
answerMe = () => resolve(json(200, {settings: {appearance: fromServer}}));
|
||||||
})));
|
}),
|
||||||
|
});
|
||||||
const {result} = renderSave();
|
const {result} = renderSave();
|
||||||
|
|
||||||
let sync;
|
let sync;
|
||||||
@@ -142,9 +152,9 @@ describe('useSaveAppearance', () => {
|
|||||||
|
|
||||||
it('does not bring back the look of the user before after a reset', async () => {
|
it('does not bring back the look of the user before after a reset', async () => {
|
||||||
let answerMe;
|
let answerMe;
|
||||||
globalThis.fetch = vi.fn(() => new Promise((resolve) => {
|
fetchRoutes({[ME]: () => new Promise((resolve) => {
|
||||||
answerMe = () => resolve({ok: true, status: 200, json: async () => ({settings: {appearance: {accent: 'red', corners: 'sharp'}}})});
|
answerMe = () => resolve(json(200, {settings: {appearance: {accent: 'red', corners: 'sharp'}}}));
|
||||||
}));
|
})});
|
||||||
const {result} = renderSave();
|
const {result} = renderSave();
|
||||||
|
|
||||||
let sync;
|
let sync;
|
||||||
@@ -162,9 +172,7 @@ describe('useSaveAppearance', () => {
|
|||||||
|
|
||||||
it('sends saves one at a time, so a later save never carries an older value', async () => {
|
it('sends saves one at a time, so a later save never carries an older value', async () => {
|
||||||
const answers = [];
|
const answers = [];
|
||||||
globalThis.fetch = vi.fn((url, init) => (init?.method === 'PUT'
|
fetchRoutes({[PUT]: () => new Promise((resolve) => answers.push(() => resolve(json(204))))});
|
||||||
? new Promise((resolve) => answers.push(() => resolve({ok: true, status: 204})))
|
|
||||||
: Promise.resolve({ok: true, status: 200, json: async () => ({})})));
|
|
||||||
const puts = () => fetch.mock.calls.filter(([, init]) => init?.method === 'PUT').map(([, init]) => JSON.parse(init.body));
|
const puts = () => fetch.mock.calls.filter(([, init]) => init?.method === 'PUT').map(([, init]) => JSON.parse(init.body));
|
||||||
const {result} = renderSave();
|
const {result} = renderSave();
|
||||||
|
|
||||||
@@ -201,11 +209,12 @@ describe('useSaveAppearance', () => {
|
|||||||
|
|
||||||
it('sends nothing for a change that waited for the load when a reset came first', async () => {
|
it('sends nothing for a change that waited for the load when a reset came first', async () => {
|
||||||
let answerMe;
|
let answerMe;
|
||||||
globalThis.fetch = vi.fn((url, init) => (init?.method === 'PUT'
|
fetchRoutes({
|
||||||
? Promise.resolve({ok: true, status: 204})
|
[PUT]: json(204),
|
||||||
: new Promise((resolve) => {
|
[ME]: () => new Promise((resolve) => {
|
||||||
answerMe = () => resolve({ok: true, status: 200, json: async () => ({})});
|
answerMe = () => resolve(json(200, {}));
|
||||||
})));
|
}),
|
||||||
|
});
|
||||||
const {result} = renderSave();
|
const {result} = renderSave();
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
|
|||||||
@@ -1,23 +1,18 @@
|
|||||||
import {useCallback} from 'react';
|
import {useCallback} from 'react';
|
||||||
import {useTranslation} from 'react-i18next';
|
import {useTranslation} from 'react-i18next';
|
||||||
import {toast} from 'sonner';
|
import {toast} from 'sonner';
|
||||||
import useSessionExpired from './useSessionExpired.js';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns `run(send)` for a settings save: it awaits `send()` (a fetch) and shows the error message, or ends the
|
* Returns `run(send)` for a settings save: it awaits `send()` (a fetch) and shows the error message. Resolves to
|
||||||
* session on a 401. Resolves to true when saved.
|
* true when saved.
|
||||||
*/
|
*/
|
||||||
export default function useSaveResponse() {
|
export default function useSaveResponse() {
|
||||||
const {t} = useTranslation();
|
const {t} = useTranslation();
|
||||||
const expireSession = useSessionExpired();
|
|
||||||
|
|
||||||
return useCallback(async (send) => {
|
return useCallback(async (send) => {
|
||||||
try {
|
try {
|
||||||
const response = await send();
|
const response = await send();
|
||||||
if (response.status === 401) {
|
if (response.status === 401) return false;
|
||||||
expireSession();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
toast.error(t('SETTINGS.ERROR_SAVE'));
|
toast.error(t('SETTINGS.ERROR_SAVE'));
|
||||||
return false;
|
return false;
|
||||||
@@ -28,5 +23,5 @@ export default function useSaveResponse() {
|
|||||||
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
|
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}, [expireSession, t]);
|
}, [t]);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import {useMemo, useSyncExternalStore} from 'react';
|
||||||
|
import {sessionEndReason, sessionStamp, subscribeSession} from '../utils/auth.js';
|
||||||
|
|
||||||
|
// A string, so the same session gives the same snapshot.
|
||||||
|
const snapshot = () => JSON.stringify([sessionStamp(), sessionEndReason()]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The session of this tab: `active`, its `stamp` (new on every login and settings save) and `endReason`
|
||||||
|
* (null, 'logout' or 'expired'). Renders again when it changes, also through another tab.
|
||||||
|
*/
|
||||||
|
export default function useSession() {
|
||||||
|
const current = useSyncExternalStore(subscribeSession, snapshot);
|
||||||
|
return useMemo(() => {
|
||||||
|
const [stamp, endReason] = JSON.parse(current);
|
||||||
|
return {active: stamp !== null, stamp, endReason};
|
||||||
|
}, [current]);
|
||||||
|
}
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
import {useCallback, useRef} from 'react';
|
|
||||||
import {useLocation, useNavigate} from 'react-router-dom';
|
|
||||||
import {useResetAppearance} from '../context/PreferencesContext.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.
|
|
||||||
*/
|
|
||||||
export default function useSessionExpired() {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const resetAppearance = useResetAppearance();
|
|
||||||
const location = useLocation();
|
|
||||||
const locationRef = useRef(location);
|
|
||||||
locationRef.current = location;
|
|
||||||
|
|
||||||
return useCallback(() => {
|
|
||||||
const {pathname, search} = locationRef.current;
|
|
||||||
clearSession();
|
|
||||||
resetAppearance();
|
|
||||||
navigate('/login', {replace: true, state: {sessionExpired: true, from: pathname + search}});
|
|
||||||
}, [navigate, resetAppearance]);
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
// @vitest-environment jsdom
|
|
||||||
import {afterEach, describe, expect, it} from 'vitest';
|
|
||||||
import {cleanup, fireEvent, render, screen} from '@testing-library/react';
|
|
||||||
import {MemoryRouter, Route, Routes, useLocation} from 'react-router-dom';
|
|
||||||
import useSessionExpired from './useSessionExpired.js';
|
|
||||||
|
|
||||||
function ExpireButton() {
|
|
||||||
const expireSession = useSessionExpired();
|
|
||||||
return <button onClick={expireSession}>expire</button>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function LoginState() {
|
|
||||||
return <div data-testid="state">{JSON.stringify(useLocation().state)}</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('useSessionExpired', () => {
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup();
|
|
||||||
localStorage.clear();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('removes the token and opens the login page with the current page as return path', () => {
|
|
||||||
localStorage.setItem('tbp_jwt', 'expired');
|
|
||||||
render(
|
|
||||||
<MemoryRouter initialEntries={['/library?status=PLAYING']}>
|
|
||||||
<Routes>
|
|
||||||
<Route path="/library" element={<ExpireButton/>}/>
|
|
||||||
<Route path="/login" element={<LoginState/>}/>
|
|
||||||
</Routes>
|
|
||||||
</MemoryRouter>,
|
|
||||||
);
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', {name: 'expire'}));
|
|
||||||
|
|
||||||
expect(JSON.parse(screen.getByTestId('state').textContent))
|
|
||||||
.toEqual({sessionExpired: true, from: '/library?status=PLAYING'});
|
|
||||||
expect(localStorage.getItem('tbp_jwt')).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,23 +1,20 @@
|
|||||||
import {useCallback} from 'react';
|
import {useCallback} from 'react';
|
||||||
import {useTranslation} from 'react-i18next';
|
import {useTranslation} from 'react-i18next';
|
||||||
import {toast} from 'sonner';
|
import {toast} from 'sonner';
|
||||||
import useSessionExpired from './useSessionExpired.js';
|
|
||||||
import {createImport} from '../utils/imports.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
|
* Returns `start(source, rows)`: it creates an import in Core and resolves to its id, or shows why not and
|
||||||
* session on a 401) and resolves to null.
|
* resolves to null.
|
||||||
*/
|
*/
|
||||||
export default function useStartImport() {
|
export default function useStartImport() {
|
||||||
const {t} = useTranslation();
|
const {t} = useTranslation();
|
||||||
const expireSession = useSessionExpired();
|
|
||||||
|
|
||||||
return useCallback(async (source, rows) => {
|
return useCallback(async (source, rows) => {
|
||||||
try {
|
try {
|
||||||
const response = await createImport(source, rows);
|
const response = await createImport(source, rows);
|
||||||
if (response.status === 401) {
|
if (response.status === 401) return null;
|
||||||
expireSession();
|
if (response.status === 409) {
|
||||||
} else if (response.status === 409) {
|
|
||||||
toast.error(t('IMPORT.RUNNING'));
|
toast.error(t('IMPORT.RUNNING'));
|
||||||
} else if (response.status === 503) {
|
} else if (response.status === 503) {
|
||||||
toast.error(t('MANUAL_GAME.UNAVAILABLE'));
|
toast.error(t('MANUAL_GAME.UNAVAILABLE'));
|
||||||
@@ -31,5 +28,5 @@ export default function useStartImport() {
|
|||||||
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
|
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}, [expireSession, t]);
|
}, [t]);
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
import {afterEach, beforeEach, describe, expect, it} from 'vitest';
|
||||||
import {cleanup, renderHook, waitFor} from '@testing-library/react';
|
import {cleanup, renderHook, waitFor} from '@testing-library/react';
|
||||||
import useSteamProfile from './useSteamProfile.js';
|
import useSteamProfile from './useSteamProfile.js';
|
||||||
|
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
||||||
const ok = (body) => ({ok: true, status: 200, json: async () => body});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
localStorage.setItem('tbp_session', 'first-login');
|
localStorage.setItem('tbp_session', 'first-login');
|
||||||
@@ -17,7 +16,8 @@ afterEach(() => {
|
|||||||
describe('useSteamProfile', () => {
|
describe('useSteamProfile', () => {
|
||||||
it('loads again after the session stamp changed, e.g. with a new Steam ID', async () => {
|
it('loads again after the session stamp changed, e.g. with a new Steam ID', async () => {
|
||||||
let steamName = 'old';
|
let steamName = 'old';
|
||||||
globalThis.fetch = vi.fn(async () => ok({personaName: steamName, avatarUrl: `https://avatars.steamstatic.com/${steamName}_full.jpg`}));
|
fetchRoutes({'GET /api/library/steam-profile': () =>
|
||||||
|
json(200, {personaName: steamName, avatarUrl: `https://avatars.steamstatic.com/${steamName}_full.jpg`})});
|
||||||
const {result, rerender} = renderHook(() => useSteamProfile(true));
|
const {result, rerender} = renderHook(() => useSteamProfile(true));
|
||||||
await waitFor(() => expect(result.current?.avatarUrl).toContain('old_full'));
|
await waitFor(() => expect(result.current?.avatarUrl).toContain('old_full'));
|
||||||
|
|
||||||
@@ -30,7 +30,7 @@ describe('useSteamProfile', () => {
|
|||||||
|
|
||||||
it('sends nothing without a session, e.g. right after it expired', async () => {
|
it('sends nothing without a session, e.g. right after it expired', async () => {
|
||||||
localStorage.removeItem('tbp_session');
|
localStorage.removeItem('tbp_session');
|
||||||
globalThis.fetch = vi.fn(async () => ({ok: false, status: 401, json: async () => ({})}));
|
fetchRoutes({});
|
||||||
renderHook(() => useSteamProfile(true));
|
renderHook(() => useSteamProfile(true));
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import {useEffect} from 'react';
|
||||||
|
import {useBlocker} from 'react-router-dom';
|
||||||
|
import {useTranslation} from 'react-i18next';
|
||||||
|
import {hasSession} from '../utils/auth.js';
|
||||||
|
|
||||||
|
// One entry per component with unsaved edits. A router allows only one blocker, and GameDetail shows two such cards.
|
||||||
|
const owners = new Set();
|
||||||
|
let discardNext = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The next navigation drops the unsaved edits without asking, e.g. after the game itself was removed. Only that one:
|
||||||
|
* a flag in the location state would also skip the question on a later "back" to that entry.
|
||||||
|
*/
|
||||||
|
export const discardChanges = () => {
|
||||||
|
discardNext = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks the calling component as having unsaved edits while `dirty`. UnsavedChangesGuard then asks before a
|
||||||
|
* navigation drops them.
|
||||||
|
*/
|
||||||
|
export default function useUnsavedChanges(dirty) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!dirty) return undefined;
|
||||||
|
const owner = {};
|
||||||
|
owners.add(owner);
|
||||||
|
return () => owners.delete(owner);
|
||||||
|
}, [dirty]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rendered once, inside a data router. Asks with the browser's own confirm; a reload or a closed tab gets the
|
||||||
|
* browser's own warning. Without a session nothing is held back, so the login page always opens.
|
||||||
|
*/
|
||||||
|
export function UnsavedChangesGuard() {
|
||||||
|
const {t} = useTranslation();
|
||||||
|
const blocker = useBlocker(() => {
|
||||||
|
const discard = discardNext;
|
||||||
|
discardNext = false;
|
||||||
|
return owners.size > 0 && hasSession() && !discard;
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (blocker.state !== 'blocked') return;
|
||||||
|
if (window.confirm(t('UNSAVED_CHANGES.CONFIRM'))) blocker.proceed();
|
||||||
|
else blocker.reset();
|
||||||
|
}, [blocker, t]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const warn = (event) => {
|
||||||
|
if (owners.size > 0) event.preventDefault();
|
||||||
|
};
|
||||||
|
window.addEventListener('beforeunload', warn);
|
||||||
|
return () => window.removeEventListener('beforeunload', warn);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||||
|
import {act, cleanup, fireEvent, render, screen} from '@testing-library/react';
|
||||||
|
import {createMemoryRouter, Link, Outlet, RouterProvider, useNavigate} from 'react-router-dom';
|
||||||
|
import useUnsavedChanges, {discardChanges, UnsavedChangesGuard} from './useUnsavedChanges.js';
|
||||||
|
import {expireSession, markSession} from '../utils/auth.js';
|
||||||
|
|
||||||
|
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||||
|
|
||||||
|
function Editor({dirty = true}) {
|
||||||
|
useUnsavedChanges(dirty);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<p>editor</p>
|
||||||
|
<Link to="/other">leave</Link>
|
||||||
|
<button onClick={() => {
|
||||||
|
discardChanges();
|
||||||
|
navigate('/other');
|
||||||
|
}}>remove</button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEditor(dirty) {
|
||||||
|
const router = createMemoryRouter([{
|
||||||
|
element: <><UnsavedChangesGuard/><Outlet/></>,
|
||||||
|
children: [
|
||||||
|
{path: '/edit', element: <Editor dirty={dirty}/>},
|
||||||
|
{path: '/other', element: <p>other page</p>},
|
||||||
|
{path: '/login', element: <p>login</p>},
|
||||||
|
],
|
||||||
|
}], {initialEntries: ['/edit']});
|
||||||
|
render(<RouterProvider router={router}/>);
|
||||||
|
return router;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
markSession();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useUnsavedChanges', () => {
|
||||||
|
it('asks before a link leaves unsaved edits, and stays when the user says no', async () => {
|
||||||
|
const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false);
|
||||||
|
renderEditor(true);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('leave'));
|
||||||
|
|
||||||
|
expect(confirm).toHaveBeenCalledWith('UNSAVED_CHANGES.CONFIRM');
|
||||||
|
expect(await screen.findByText('editor')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves when the user says yes', async () => {
|
||||||
|
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||||
|
renderEditor(true);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('leave'));
|
||||||
|
|
||||||
|
expect(await screen.findByText('other page')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not ask without unsaved edits, or when the page drops them on purpose', async () => {
|
||||||
|
const confirm = vi.spyOn(window, 'confirm');
|
||||||
|
renderEditor(false);
|
||||||
|
fireEvent.click(screen.getByText('leave'));
|
||||||
|
expect(await screen.findByText('other page')).toBeTruthy();
|
||||||
|
cleanup();
|
||||||
|
|
||||||
|
renderEditor(true);
|
||||||
|
fireEvent.click(screen.getByText('remove'));
|
||||||
|
expect(await screen.findByText('other page')).toBeTruthy();
|
||||||
|
expect(confirm).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never holds back the login page when the session ended', async () => {
|
||||||
|
const confirm = vi.spyOn(window, 'confirm');
|
||||||
|
const router = renderEditor(true);
|
||||||
|
|
||||||
|
act(() => expireSession());
|
||||||
|
await act(() => router.navigate('/login'));
|
||||||
|
|
||||||
|
expect(screen.getByText('login')).toBeTruthy();
|
||||||
|
expect(confirm).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets the browser warn before a reload or close only while there are unsaved edits', () => {
|
||||||
|
renderEditor(true);
|
||||||
|
const warned = new Event('beforeunload', {cancelable: true});
|
||||||
|
window.dispatchEvent(warned);
|
||||||
|
expect(warned.defaultPrevented).toBe(true);
|
||||||
|
cleanup();
|
||||||
|
|
||||||
|
renderEditor(false);
|
||||||
|
const free = new Event('beforeunload', {cancelable: true});
|
||||||
|
window.dispatchEvent(free);
|
||||||
|
expect(free.defaultPrevented).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('asks before going back to an entry that once dropped changes on purpose', async () => {
|
||||||
|
const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false);
|
||||||
|
const router = renderEditor(true);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('remove'));
|
||||||
|
await screen.findByText('other page');
|
||||||
|
await act(() => router.navigate('/edit'));
|
||||||
|
await screen.findByText('editor');
|
||||||
|
await act(() => router.navigate(-1));
|
||||||
|
|
||||||
|
expect(confirm).toHaveBeenCalledWith('UNSAVED_CHANGES.CONFIRM');
|
||||||
|
expect(router.state.location.pathname).toBe('/edit');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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.
|
* 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 [stats, setStats] = useState(null);
|
||||||
const [failed, setFailed] = useState(false);
|
const [failed, setFailed] = useState(false);
|
||||||
const key = groups.join(',');
|
const key = groups.join(',');
|
||||||
@@ -16,10 +14,7 @@ export default function useUserStats(groups, onUnauthorized, enabled = true) {
|
|||||||
setFailed(false);
|
setFailed(false);
|
||||||
try {
|
try {
|
||||||
const response = await apiFetch(statsUrl(key));
|
const response = await apiFetch(statsUrl(key));
|
||||||
if (response.status === 401) {
|
if (response.status === 401) return;
|
||||||
onUnauthorized();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
setFailed(true);
|
setFailed(true);
|
||||||
return;
|
return;
|
||||||
@@ -29,7 +24,7 @@ export default function useUserStats(groups, onUnauthorized, enabled = true) {
|
|||||||
console.error('Stats load error:', err);
|
console.error('Stats load error:', err);
|
||||||
setFailed(true);
|
setFailed(true);
|
||||||
}
|
}
|
||||||
}, [key, onUnauthorized]);
|
}, [key]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (enabled) load();
|
if (enabled) load();
|
||||||
|
|||||||
+15
-6
@@ -1,17 +1,26 @@
|
|||||||
import {StrictMode} from 'react';
|
import {StrictMode} from 'react';
|
||||||
import {createRoot} from 'react-dom/client';
|
import {createRoot} from 'react-dom/client';
|
||||||
import {BrowserRouter} from 'react-router-dom';
|
import {createBrowserRouter, RouterProvider} from 'react-router-dom';
|
||||||
import App from './App.jsx';
|
import App from './App.jsx';
|
||||||
import {PreferencesProvider} from './context/PreferencesContext.jsx';
|
import {PreferencesProvider} from './context/PreferencesContext.jsx';
|
||||||
|
import {CurrentUserProvider} from './context/CurrentUserContext.jsx';
|
||||||
import './i18n';
|
import './i18n';
|
||||||
import './style.css';
|
import './style.css';
|
||||||
|
|
||||||
|
// A data router for useBlocker; App keeps its own <Routes> below the one splat route.
|
||||||
|
const router = createBrowserRouter([{
|
||||||
|
path: '*',
|
||||||
|
element: (
|
||||||
|
<PreferencesProvider>
|
||||||
|
<CurrentUserProvider>
|
||||||
|
<App/>
|
||||||
|
</CurrentUserProvider>
|
||||||
|
</PreferencesProvider>
|
||||||
|
),
|
||||||
|
}]);
|
||||||
|
|
||||||
createRoot(document.getElementById('root')).render(
|
createRoot(document.getElementById('root')).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<BrowserRouter>
|
<RouterProvider router={router}/>
|
||||||
<PreferencesProvider>
|
|
||||||
<App/>
|
|
||||||
</PreferencesProvider>
|
|
||||||
</BrowserRouter>
|
|
||||||
</StrictMode>
|
</StrictMode>
|
||||||
);
|
);
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import {useCallback, useEffect, useState} from 'react';
|
import {useCallback, useEffect, useState} from 'react';
|
||||||
import {useNavigate} from 'react-router-dom';
|
|
||||||
import {useTranslation} from 'react-i18next';
|
import {useTranslation} from 'react-i18next';
|
||||||
import {Loader2, Send, Square} from 'lucide-react';
|
import {Loader2, Send, Square} from 'lucide-react';
|
||||||
import {toast} from 'sonner';
|
import {toast} from 'sonner';
|
||||||
@@ -12,8 +11,6 @@ import {Label} from '@/components/ui/label';
|
|||||||
import {Textarea} from '@/components/ui/textarea';
|
import {Textarea} from '@/components/ui/textarea';
|
||||||
import HintCard from '../components/HintCard.jsx';
|
import HintCard from '../components/HintCard.jsx';
|
||||||
import PageContainer from '../components/PageContainer.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 {createHint, endHint, fetchAdminHints} from '../utils/hints.js';
|
||||||
import {MAX_HINT_LENGTH} from '../utils/limits.js';
|
import {MAX_HINT_LENGTH} from '../utils/limits.js';
|
||||||
|
|
||||||
@@ -35,8 +32,6 @@ const emptyForm = () => {
|
|||||||
*/
|
*/
|
||||||
export default function AdminHintsPage() {
|
export default function AdminHintsPage() {
|
||||||
const {t, i18n} = useTranslation();
|
const {t, i18n} = useTranslation();
|
||||||
const navigate = useNavigate();
|
|
||||||
const expireSession = useSessionExpired();
|
|
||||||
const [hints, setHints] = useState([]);
|
const [hints, setHints] = useState([]);
|
||||||
const [status, setStatus] = useState('loading');
|
const [status, setStatus] = useState('loading');
|
||||||
const [form, setForm] = useState(emptyForm);
|
const [form, setForm] = useState(emptyForm);
|
||||||
@@ -45,10 +40,7 @@ export default function AdminHintsPage() {
|
|||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetchAdminHints();
|
const response = await fetchAdminHints();
|
||||||
if (response.status === 401) {
|
if (response.status === 401) return;
|
||||||
expireSession();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (response.status === 403) {
|
if (response.status === 403) {
|
||||||
setStatus('forbidden');
|
setStatus('forbidden');
|
||||||
return;
|
return;
|
||||||
@@ -63,24 +55,17 @@ export default function AdminHintsPage() {
|
|||||||
console.error('Admin hints error:', err);
|
console.error('Admin hints error:', err);
|
||||||
setStatus('error');
|
setStatus('error');
|
||||||
}
|
}
|
||||||
}, [expireSession]);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!hasSession()) {
|
|
||||||
navigate('/login');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
load();
|
load();
|
||||||
}, [load, navigate]);
|
}, [load]);
|
||||||
|
|
||||||
const run = async (request, doneKey, onOk) => {
|
const run = async (request, doneKey, onOk) => {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
const response = await request();
|
const response = await request();
|
||||||
if (response.status === 401) {
|
if (response.status === 401) return;
|
||||||
expireSession();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
toast.success(t(doneKey));
|
toast.success(t(doneKey));
|
||||||
onOk?.();
|
onOk?.();
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {MemoryRouter} from 'react-router-dom';
|
|||||||
import {toast} from 'sonner';
|
import {toast} from 'sonner';
|
||||||
import AdminHintsPage from './AdminHintsPage.jsx';
|
import AdminHintsPage from './AdminHintsPage.jsx';
|
||||||
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
|
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
|
||||||
|
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
||||||
|
|
||||||
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||||
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
|
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
|
||||||
@@ -15,7 +16,12 @@ const ACTIVE = {id: 2, textEn: 'Server update tonight', textDe: null, kind: 'DIS
|
|||||||
createdBy: 1, createdAt: at(-1), doneCount: 3};
|
createdBy: 1, createdAt: at(-1), doneCount: 3};
|
||||||
const ENDED = {...ACTIVE, id: 1, textEn: 'Old news', startsAt: at(-3), endsAt: at(-2), status: 'ENDED', doneCount: 0};
|
const ENDED = {...ACTIVE, id: 1, textEn: 'Old news', startsAt: at(-3), endsAt: at(-2), status: 'ENDED', doneCount: 0};
|
||||||
|
|
||||||
const answer = (status, body) => ({ok: status < 300, status, headers: new Headers(), json: async () => body});
|
const withRoutes = (routes) => fetchRoutes({
|
||||||
|
'GET /api/admin/hints': json(200, {results: [ACTIVE, ENDED]}),
|
||||||
|
'POST /api/admin/hints': json(201, {id: 3}),
|
||||||
|
'PUT /api/admin/hints/2/end': json(204),
|
||||||
|
...routes,
|
||||||
|
});
|
||||||
const calls = (method) => fetch.mock.calls.filter(([, init]) => (init?.method ?? 'GET') === method);
|
const calls = (method) => fetch.mock.calls.filter(([, init]) => (init?.method ?? 'GET') === method);
|
||||||
|
|
||||||
const renderPage = () => render(<MemoryRouter><PreferencesProvider><AdminHintsPage/></PreferencesProvider></MemoryRouter>);
|
const renderPage = () => render(<MemoryRouter><PreferencesProvider><AdminHintsPage/></PreferencesProvider></MemoryRouter>);
|
||||||
@@ -23,11 +29,7 @@ const renderPage = () => render(<MemoryRouter><PreferencesProvider><AdminHintsPa
|
|||||||
describe('AdminHintsPage', () => {
|
describe('AdminHintsPage', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
localStorage.setItem('tbp_session', '1');
|
localStorage.setItem('tbp_session', '1');
|
||||||
globalThis.fetch = vi.fn(async (url, init) => {
|
withRoutes({});
|
||||||
if (init?.method === 'POST') return answer(201, {id: 3});
|
|
||||||
if (init?.method === 'PUT') return answer(204);
|
|
||||||
return answer(200, {results: [ACTIVE, ENDED]});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -68,8 +70,7 @@ describe('AdminHintsPage', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('explains an invalid time range', async () => {
|
it('explains an invalid time range', async () => {
|
||||||
fetch.mockImplementation(async (url, init) =>
|
withRoutes({'GET /api/admin/hints': json(200, {results: []}), 'POST /api/admin/hints': json(400, {code: 'INVALID_HINT'})});
|
||||||
(init?.method === 'POST' ? answer(400, {code: 'INVALID_HINT'}) : answer(200, {results: []})));
|
|
||||||
renderPage();
|
renderPage();
|
||||||
await screen.findByText('ADMIN_HINTS.EMPTY');
|
await screen.findByText('ADMIN_HINTS.EMPTY');
|
||||||
|
|
||||||
@@ -82,7 +83,7 @@ describe('AdminHintsPage', () => {
|
|||||||
it('takes the status from the server, not from the browser clock', async () => {
|
it('takes the status from the server, not from the browser clock', async () => {
|
||||||
// The server ended the hint by its own clock; by the browser clock the end is still 5 s away.
|
// The server ended the hint by its own clock; by the browser clock the end is still 5 s away.
|
||||||
const endedOnServer = {...ACTIVE, endsAt: new Date(Date.now() + 5_000).toISOString(), status: 'ENDED'};
|
const endedOnServer = {...ACTIVE, endsAt: new Date(Date.now() + 5_000).toISOString(), status: 'ENDED'};
|
||||||
fetch.mockImplementation(async () => answer(200, {results: [endedOnServer]}));
|
withRoutes({'GET /api/admin/hints': json(200, {results: [endedOnServer]})});
|
||||||
renderPage();
|
renderPage();
|
||||||
|
|
||||||
await screen.findByText('Server update tonight');
|
await screen.findByText('Server update tonight');
|
||||||
@@ -103,7 +104,7 @@ describe('AdminHintsPage', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('shows "no access" to a non-admin', async () => {
|
it('shows "no access" to a non-admin', async () => {
|
||||||
fetch.mockImplementation(async () => answer(403));
|
withRoutes({'GET /api/admin/hints': json(403, {})});
|
||||||
renderPage();
|
renderPage();
|
||||||
|
|
||||||
expect(await screen.findByText('ADMIN_HINTS.NO_ACCESS')).toBeTruthy();
|
expect(await screen.findByText('ADMIN_HINTS.NO_ACCESS')).toBeTruthy();
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import {useCallback, useEffect, useRef, useState} from 'react';
|
import {useCallback, useEffect, useState} from 'react';
|
||||||
import {useNavigate} from 'react-router-dom';
|
|
||||||
import {useTranslation} from 'react-i18next';
|
import {useTranslation} from 'react-i18next';
|
||||||
import {Ban, ChevronLeft, ChevronRight, Loader2, Search, ShieldCheck, Trash2} from 'lucide-react';
|
import {Ban, ChevronLeft, ChevronRight, Loader2, Search, ShieldCheck, Trash2} from 'lucide-react';
|
||||||
import {toast} from 'sonner';
|
import {toast} from 'sonner';
|
||||||
@@ -19,9 +18,8 @@ import {
|
|||||||
import {Input} from '@/components/ui/input';
|
import {Input} from '@/components/ui/input';
|
||||||
import {Label} from '@/components/ui/label';
|
import {Label} from '@/components/ui/label';
|
||||||
import PageContainer from '../components/PageContainer.jsx';
|
import PageContainer from '../components/PageContainer.jsx';
|
||||||
import useSessionExpired from '../hooks/useSessionExpired.js';
|
|
||||||
import {deleteUser, fetchUsers, setUserBlocked} from '../utils/admin.js';
|
import {deleteUser, fetchUsers, setUserBlocked} from '../utils/admin.js';
|
||||||
import {hasSession} from '../utils/auth.js';
|
import useAbortPrevious from '../hooks/useAbortPrevious.js';
|
||||||
|
|
||||||
const PAGE_SIZE = 20;
|
const PAGE_SIZE = 20;
|
||||||
const REFUSAL_KEY = {
|
const REFUSAL_KEY = {
|
||||||
@@ -37,8 +35,6 @@ const formatDate = (value, locale) => (value ? new Date(value).toLocaleDateStrin
|
|||||||
*/
|
*/
|
||||||
export default function AdminUsersPage() {
|
export default function AdminUsersPage() {
|
||||||
const {t, i18n} = useTranslation();
|
const {t, i18n} = useTranslation();
|
||||||
const navigate = useNavigate();
|
|
||||||
const expireSession = useSessionExpired();
|
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [page, setPage] = useState(0);
|
const [page, setPage] = useState(0);
|
||||||
@@ -49,17 +45,14 @@ export default function AdminUsersPage() {
|
|||||||
const [typedEmail, setTypedEmail] = useState('');
|
const [typedEmail, setTypedEmail] = useState('');
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
// Only the newest load may show its answer: a slow answer to an older search must not replace the list.
|
// Only the newest load may show its answer: a slow answer to an older search must not replace the list.
|
||||||
const loadCount = useRef(0);
|
const nextSignal = useAbortPrevious();
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
const current = ++loadCount.current;
|
const signal = nextSignal();
|
||||||
try {
|
try {
|
||||||
const response = await fetchUsers(search, page, PAGE_SIZE);
|
const response = await fetchUsers(search, page, PAGE_SIZE, {signal});
|
||||||
if (current !== loadCount.current) return;
|
if (signal.aborted) return;
|
||||||
if (response.status === 401) {
|
if (response.status === 401) return;
|
||||||
expireSession();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (response.status === 403) {
|
if (response.status === 403) {
|
||||||
setStatus('forbidden');
|
setStatus('forbidden');
|
||||||
return;
|
return;
|
||||||
@@ -69,22 +62,19 @@ export default function AdminUsersPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (current !== loadCount.current) return;
|
if (signal.aborted) return;
|
||||||
setResult(data);
|
setResult(data);
|
||||||
setStatus('ready');
|
setStatus('ready');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (signal.aborted) return;
|
||||||
console.error('Admin users error:', err);
|
console.error('Admin users error:', err);
|
||||||
setStatus('error');
|
setStatus('error');
|
||||||
}
|
}
|
||||||
}, [search, page, expireSession]);
|
}, [nextSignal, search, page]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!hasSession()) {
|
|
||||||
navigate('/login');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
load();
|
load();
|
||||||
}, [load, navigate]);
|
}, [load]);
|
||||||
|
|
||||||
const closeDialog = () => {
|
const closeDialog = () => {
|
||||||
setPending(null);
|
setPending(null);
|
||||||
@@ -96,10 +86,7 @@ export default function AdminUsersPage() {
|
|||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
const response = kind === 'delete' ? await deleteUser(user.id) : await setUserBlocked(user.id, !user.blocked);
|
const response = kind === 'delete' ? await deleteUser(user.id) : await setUserBlocked(user.id, !user.blocked);
|
||||||
if (response.status === 401) {
|
if (response.status === 401) return;
|
||||||
expireSession();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
toast.success(t(kind === 'delete' ? 'ADMIN.DONE_DELETE' : user.blocked ? 'ADMIN.DONE_UNBLOCK' : 'ADMIN.DONE_BLOCK'));
|
toast.success(t(kind === 'delete' ? 'ADMIN.DONE_DELETE' : user.blocked ? 'ADMIN.DONE_UNBLOCK' : 'ADMIN.DONE_BLOCK'));
|
||||||
closeDialog();
|
closeDialog();
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {MemoryRouter} from 'react-router-dom';
|
|||||||
import {toast} from 'sonner';
|
import {toast} from 'sonner';
|
||||||
import AdminUsersPage from './AdminUsersPage.jsx';
|
import AdminUsersPage from './AdminUsersPage.jsx';
|
||||||
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
|
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
|
||||||
|
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
||||||
|
|
||||||
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||||
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
|
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
|
||||||
@@ -16,7 +17,12 @@ const PLAYER = {
|
|||||||
const ADMIN = {...PLAYER, id: 1, email: 'admin@example.com', role: 'PLATFORM_ADMIN', steamLinked: false};
|
const ADMIN = {...PLAYER, id: 1, email: 'admin@example.com', role: 'PLATFORM_ADMIN', steamLinked: false};
|
||||||
|
|
||||||
const page = (content, totalPages = 1) => ({content, page: 0, size: 20, totalElements: content.length, totalPages});
|
const page = (content, totalPages = 1) => ({content, page: 0, size: 20, totalElements: content.length, totalPages});
|
||||||
const answer = (status, body) => ({ok: status < 300, status, headers: new Headers(), json: async () => body});
|
const withRoutes = (routes) => fetchRoutes({
|
||||||
|
'GET /api/admin/users': json(200, page([ADMIN, PLAYER])),
|
||||||
|
'PUT /api/admin/users/7/blocked': json(204),
|
||||||
|
'DELETE /api/admin/users/7': json(204),
|
||||||
|
...routes,
|
||||||
|
});
|
||||||
|
|
||||||
const renderPage = () => render(<MemoryRouter><PreferencesProvider><AdminUsersPage/></PreferencesProvider></MemoryRouter>);
|
const renderPage = () => render(<MemoryRouter><PreferencesProvider><AdminUsersPage/></PreferencesProvider></MemoryRouter>);
|
||||||
const calls = (method) => fetch.mock.calls.filter(([, init]) => (init?.method ?? 'GET') === method);
|
const calls = (method) => fetch.mock.calls.filter(([, init]) => (init?.method ?? 'GET') === method);
|
||||||
@@ -24,7 +30,7 @@ const calls = (method) => fetch.mock.calls.filter(([, init]) => (init?.method ??
|
|||||||
describe('AdminUsersPage', () => {
|
describe('AdminUsersPage', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
localStorage.setItem('tbp_session', '1');
|
localStorage.setItem('tbp_session', '1');
|
||||||
globalThis.fetch = vi.fn(async (url, init) => (init?.method ? answer(204) : answer(200, page([ADMIN, PLAYER]))));
|
withRoutes({});
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -78,8 +84,10 @@ describe('AdminUsersPage', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('shows the reason when Auth refuses', async () => {
|
it('shows the reason when Auth refuses', async () => {
|
||||||
fetch.mockImplementation(async (url, init) => (init?.method
|
withRoutes({
|
||||||
? answer(409, {code: 'TARGET_IS_ADMIN'}) : answer(200, page([PLAYER]))));
|
'GET /api/admin/users': json(200, page([PLAYER])),
|
||||||
|
'PUT /api/admin/users/7/blocked': json(409, {code: 'TARGET_IS_ADMIN'}),
|
||||||
|
});
|
||||||
renderPage();
|
renderPage();
|
||||||
fireEvent.click(await screen.findByRole('button', {name: 'ADMIN.BLOCK'}));
|
fireEvent.click(await screen.findByRole('button', {name: 'ADMIN.BLOCK'}));
|
||||||
|
|
||||||
@@ -89,7 +97,7 @@ describe('AdminUsersPage', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('says no access to a user without the admin role', async () => {
|
it('says no access to a user without the admin role', async () => {
|
||||||
fetch.mockResolvedValue(answer(403, {}));
|
withRoutes({'GET /api/admin/users': json(403, {})});
|
||||||
renderPage();
|
renderPage();
|
||||||
|
|
||||||
expect(await screen.findByText('ADMIN.NO_ACCESS')).toBeTruthy();
|
expect(await screen.findByText('ADMIN.NO_ACCESS')).toBeTruthy();
|
||||||
|
|||||||
+23
-291
@@ -1,10 +1,8 @@
|
|||||||
import React, {useCallback, useEffect, useState} from 'react';
|
import {useState} from 'react';
|
||||||
import {Link, useNavigate} from 'react-router-dom';
|
|
||||||
import {useTranslation} from 'react-i18next';
|
import {useTranslation} from 'react-i18next';
|
||||||
import {toast} from 'sonner';
|
import {toast} from 'sonner';
|
||||||
import {ArrowRight, Hash, LayoutGrid, Link2, RefreshCw, Trophy} from 'lucide-react';
|
import {RefreshCw} from 'lucide-react';
|
||||||
|
|
||||||
import {Badge} from '@/components/ui/badge';
|
|
||||||
import {Button} from '@/components/ui/button';
|
import {Button} from '@/components/ui/button';
|
||||||
import {Card} from '@/components/ui/card';
|
import {Card} from '@/components/ui/card';
|
||||||
import {Skeleton} from '@/components/ui/skeleton';
|
import {Skeleton} from '@/components/ui/skeleton';
|
||||||
@@ -12,51 +10,33 @@ import AchievementsCard from '../components/AchievementsCard.jsx';
|
|||||||
import ActivityCard from '../components/ActivityCard.jsx';
|
import ActivityCard from '../components/ActivityCard.jsx';
|
||||||
import CurrentlyPlayingCard from '../components/CurrentlyPlayingCard.jsx';
|
import CurrentlyPlayingCard from '../components/CurrentlyPlayingCard.jsx';
|
||||||
import DashboardHint from '../components/DashboardHint.jsx';
|
import DashboardHint from '../components/DashboardHint.jsx';
|
||||||
import GameCover from '../components/GameCover.jsx';
|
import DashboardWelcomeCard from '../components/DashboardWelcomeCard.jsx';
|
||||||
|
import MostPlayedCard from '../components/MostPlayedCard.jsx';
|
||||||
import PileOfShameCard from '../components/PileOfShameCard.jsx';
|
import PileOfShameCard from '../components/PileOfShameCard.jsx';
|
||||||
import SteamGameCount from '../components/SteamGameCount';
|
import SteamGameCount from '../components/SteamGameCount';
|
||||||
import SyncStatusBadge from '../components/SyncStatusBadge.jsx';
|
import useDashboardData from '../hooks/useDashboardData.js';
|
||||||
import useLibrarySync from '../hooks/useLibrarySync.js';
|
import useLibrarySync from '../hooks/useLibrarySync.js';
|
||||||
import useForceSync from '../hooks/useForceSync.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 PageContainer from '../components/PageContainer.jsx';
|
||||||
import HeroBanner from '../components/HeroBanner.jsx';
|
|
||||||
import SteamAvatar from '../components/SteamAvatar.jsx';
|
|
||||||
import useSteamProfile from '../hooks/useSteamProfile.js';
|
import useSteamProfile from '../hooks/useSteamProfile.js';
|
||||||
import useUserStats from '../hooks/useUserStats.js';
|
import useUserStats from '../hooks/useUserStats.js';
|
||||||
import useSaveResponse from '../hooks/useSaveResponse.js';
|
import useSaveResponse from '../hooks/useSaveResponse.js';
|
||||||
import TileGrid from '../components/TileGrid.jsx';
|
import TileGrid from '../components/TileGrid.jsx';
|
||||||
import statGroupTile from '../components/stats/statGroupTile.jsx';
|
import statGroupTile from '../components/stats/statGroupTile.jsx';
|
||||||
import {DASHBOARD_TILES, DASHBOARD_TITLES, DEFAULT_DASHBOARD, normalizeLayout, saveTiles, statGroup} from '../utils/tiles.js';
|
import {
|
||||||
|
DASHBOARD_TILES, DASHBOARD_TITLES, DEFAULT_DASHBOARD, normalizeLayout, PLAYING_GAMES, saveTiles, statGroup, TOP_GAMES,
|
||||||
|
} from '../utils/tiles.js';
|
||||||
import {usePreferences} from '../context/PreferencesContext.jsx';
|
import {usePreferences} from '../context/PreferencesContext.jsx';
|
||||||
import {apiFetch} from '../utils/api.js';
|
import {useCurrentUserContext} from '../context/CurrentUserContext.jsx';
|
||||||
import {hasSession} from '../utils/auth.js';
|
|
||||||
|
|
||||||
// Games per tile size; the page loads the most for the large tile.
|
|
||||||
const TOP_GAMES = {S: 3, M: 5, L: 10};
|
|
||||||
const PLAYING_GAMES = {S: 2, M: 4, L: 8};
|
|
||||||
// The games endpoint accepts only the page sizes 20, 40 and 80.
|
|
||||||
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'};
|
|
||||||
|
|
||||||
const Dashboard = () => {
|
const Dashboard = () => {
|
||||||
const {t} = useTranslation();
|
const {t} = useTranslation();
|
||||||
const navigate = useNavigate();
|
const {user: userData, updateUser} = useCurrentUserContext();
|
||||||
const expireSession = useSessionExpired();
|
const {
|
||||||
const [userData, setUserData] = useState(null);
|
steamData, topGames, playing, summary, summaryFailed, activity, achievements, isLoading, error,
|
||||||
const [steamData, setSteamData] = useState({count: 0, hasSteamId: false});
|
fetchSummary, reloadLibrary,
|
||||||
const [topGames, setTopGames] = useState([]);
|
} = useDashboardData();
|
||||||
const [playing, setPlaying] = useState([]);
|
|
||||||
const [summary, setSummary] = useState(null);
|
|
||||||
const [activity, setActivity] = useState(null);
|
|
||||||
const [achievements, setAchievements] = useState(null);
|
|
||||||
const [summaryFailed, setSummaryFailed] = useState(false);
|
|
||||||
const [shuffling, setShuffling] = useState(false);
|
const [shuffling, setShuffling] = useState(false);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const [error, setError] = useState(null);
|
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const runSave = useSaveResponse();
|
const runSave = useSaveResponse();
|
||||||
const layout = normalizeLayout(userData?.settings?.dashboardTiles, DASHBOARD_TILES, DEFAULT_DASHBOARD);
|
const layout = normalizeLayout(userData?.settings?.dashboardTiles, DASHBOARD_TILES, DEFAULT_DASHBOARD);
|
||||||
@@ -67,109 +47,7 @@ const Dashboard = () => {
|
|||||||
])].sort();
|
])].sort();
|
||||||
// No groups would mean "all groups" to the stats endpoint.
|
// No groups would mean "all groups" to the stats endpoint.
|
||||||
const hasStatGroups = statGroups.length > 0;
|
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]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param excludeAppid the current suggestion, so the new pick differs from it
|
|
||||||
* @returns false when the request failed
|
|
||||||
*/
|
|
||||||
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 shuffleSuggestion = async () => {
|
const shuffleSuggestion = async () => {
|
||||||
setShuffling(true);
|
setShuffling(true);
|
||||||
@@ -179,53 +57,6 @@ const Dashboard = () => {
|
|||||||
setShuffling(false);
|
setShuffling(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const reloadLibrary = useCallback(() => {
|
|
||||||
fetchSteamCount();
|
|
||||||
fetchTopGames();
|
|
||||||
fetchPlaying();
|
|
||||||
fetchSummary();
|
|
||||||
fetchActivity();
|
|
||||||
fetchAchievements();
|
|
||||||
if (hasStatGroups) reloadStats();
|
|
||||||
}, [fetchSteamCount, fetchTopGames, fetchPlaying, fetchSummary, fetchActivity, fetchAchievements, hasStatGroups, reloadStats]);
|
|
||||||
|
|
||||||
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);
|
|
||||||
setError('INFO.ERROR.NETWORK_FAILURE');
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchUserData();
|
|
||||||
}, [navigate, expireSession, fetchSteamCount, fetchTopGames, fetchPlaying, fetchSummary, fetchActivity, fetchAchievements]);
|
|
||||||
|
|
||||||
const hasSteamId = !!userData?.steamId;
|
const hasSteamId = !!userData?.steamId;
|
||||||
const showLibrary = hasSteamId || steamData.count > 0;
|
const showLibrary = hasSteamId || steamData.count > 0;
|
||||||
const {appearance} = usePreferences();
|
const {appearance} = usePreferences();
|
||||||
@@ -236,10 +67,12 @@ const Dashboard = () => {
|
|||||||
const sync = useLibrarySync({
|
const sync = useLibrarySync({
|
||||||
enabled: hasSteamId,
|
enabled: hasSteamId,
|
||||||
expectInitialSync: hasSteamId && steamData.count === 0,
|
expectInitialSync: hasSteamId && steamData.count === 0,
|
||||||
onComplete: reloadLibrary,
|
onComplete: () => {
|
||||||
onUnauthorized: expireSession,
|
reloadLibrary();
|
||||||
|
if (hasStatGroups) stats.reload();
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const forceSync = useForceSync(reloadLibrary);
|
const forceSync = useForceSync(sync.watch);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <DashboardSkeleton/>;
|
return <DashboardSkeleton/>;
|
||||||
@@ -253,13 +86,11 @@ const Dashboard = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const syncBusy = forceSync.busy || sync.isRunning;
|
|
||||||
|
|
||||||
const saveLayout = async (next) => {
|
const saveLayout = async (next) => {
|
||||||
// The default is saved as "never changed", so a later change of the default reaches this user too.
|
// 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;
|
const tiles = JSON.stringify(next) === JSON.stringify(DEFAULT_DASHBOARD) ? null : next;
|
||||||
if (!await runSave(() => saveTiles('dashboard-tiles', tiles))) return;
|
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);
|
setEditing(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -294,53 +125,8 @@ const Dashboard = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContainer className="p-4 md:p-8 space-y-6">
|
<PageContainer className="p-4 md:p-8 space-y-6">
|
||||||
<Card className="relative overflow-hidden p-6 md:p-8 flex flex-col md:flex-row md:items-center md:justify-between gap-6">
|
<DashboardWelcomeCard bannerAppid={bannerAppid} avatarUrl={steamProfile?.avatarUrl}
|
||||||
{/* A picked game, else the game played most in the last two weeks. */}
|
sync={sync} forceSync={forceSync} editing={editing} onCustomize={() => setEditing(true)}/>
|
||||||
<HeroBanner appid={bannerAppid}/>
|
|
||||||
<div className="relative flex min-w-0 items-center gap-4">
|
|
||||||
{/* The slot is there before the avatar loads, so the title does not jump. */}
|
|
||||||
{hasSteamId && (
|
|
||||||
<span className="flex size-16 shrink-0 items-center justify-center rounded-full bg-muted ring-2 ring-primary/60">
|
|
||||||
<SteamAvatar url={steamProfile?.avatarUrl} className="size-16" iconClassName="size-8 text-muted-foreground"/>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="text-sm font-medium text-muted-foreground">{t('DASHBOARD.WELCOME_BACK')}</p>
|
|
||||||
<h1 className="mt-1 text-3xl md:text-4xl font-bold tracking-tight truncate">
|
|
||||||
{t('DASHBOARD.HELLO')} {userData?.displayName ?? userData?.email?.split('@')[0]} 👋
|
|
||||||
</h1>
|
|
||||||
{hasSteamId ? (
|
|
||||||
<Badge variant="outline" className="mt-3 gap-1.5 font-mono text-muted-foreground">
|
|
||||||
<Hash className="size-3"/>
|
|
||||||
{userData.steamId}
|
|
||||||
</Badge>
|
|
||||||
) : (
|
|
||||||
<Button asChild variant="link" className="mt-2 h-auto p-0">
|
|
||||||
<Link to="/settings"><Link2 className="size-4"/>{t('DASHBOARD.NO_STEAM_ID')}</Link>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative flex flex-col items-start md:items-end gap-3 shrink-0">
|
|
||||||
{hasSteamId && (
|
|
||||||
<>
|
|
||||||
<SyncStatusBadge
|
|
||||||
status={sync.status}
|
|
||||||
lastSyncedAt={sync.lastSyncedAt}
|
|
||||||
isRunning={sync.isRunning}
|
|
||||||
/>
|
|
||||||
<Button onClick={forceSync.trigger} disabled={syncBusy}>
|
|
||||||
<RefreshCw className={syncBusy ? 'animate-spin' : ''}/>
|
|
||||||
{t('LIBRARY.RESYNC')}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<Button variant="ghost" size="sm" disabled={editing} onClick={() => setEditing(true)}>
|
|
||||||
<LayoutGrid/>{t('TILES.CUSTOMIZE')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<TileGrid tiles={DASHBOARD_TILES} titles={DASHBOARD_TITLES} layout={layout} renderTile={renderTile} editing={editing}
|
<TileGrid tiles={DASHBOARD_TILES} titles={DASHBOARD_TITLES} layout={layout} renderTile={renderTile} editing={editing}
|
||||||
defaultLayout={DEFAULT_DASHBOARD} onSave={saveLayout} onCancel={() => setEditing(false)}/>
|
defaultLayout={DEFAULT_DASHBOARD} onSave={saveLayout} onCancel={() => setEditing(false)}/>
|
||||||
@@ -350,60 +136,6 @@ const Dashboard = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
function MostPlayedCard({games, syncing}) {
|
|
||||||
const {t} = useTranslation();
|
|
||||||
const maxMinutes = games[0]?.playtimeMinutes || 1;
|
|
||||||
return (
|
|
||||||
<Card className="gap-0 py-0">
|
|
||||||
<div className="flex items-center justify-between px-5 py-4 border-b">
|
|
||||||
<h2 className="flex items-center gap-2 font-semibold">
|
|
||||||
<Trophy className="size-4 text-amber-500"/>
|
|
||||||
{t('DASHBOARD.MOST_PLAYED')}
|
|
||||||
</h2>
|
|
||||||
<Button asChild variant="ghost" size="sm">
|
|
||||||
<Link to="/library">{t('DASHBOARD.SHOW_ALL')}<ArrowRight/></Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{games.length === 0 ? (
|
|
||||||
<p className="px-5 py-10 text-center text-sm text-muted-foreground">
|
|
||||||
{syncing ? t('LIBRARY.SYNCING_FIRST_TIME') : t('LIBRARY.EMPTY')}
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<ol className="divide-y">
|
|
||||||
{games.map((g, index) => (
|
|
||||||
<li key={g.appid}>
|
|
||||||
<Link
|
|
||||||
to={`/library/${g.appid}`}
|
|
||||||
className="group flex items-center gap-3 sm:gap-4 px-4 sm:px-5 py-3 transition-colors hover:bg-muted/50"
|
|
||||||
>
|
|
||||||
<span className="w-4 text-right text-sm font-semibold text-muted-foreground tabular-nums">
|
|
||||||
{index + 1}
|
|
||||||
</span>
|
|
||||||
<GameCover appid={g.appid} imageUrl={g.imageUrl} className="w-16 sm:w-24 shrink-0"/>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="truncate font-medium group-hover:text-primary transition-colors">
|
|
||||||
{g.name ?? `App ${g.appid}`}
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 h-1.5 rounded-full bg-muted">
|
|
||||||
<div
|
|
||||||
className="h-full rounded-full bg-primary"
|
|
||||||
style={{width: `${Math.max(2, (g.playtimeMinutes / maxMinutes) * 100)}%`}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<span className="w-12 sm:w-14 text-right text-sm font-medium text-muted-foreground tabular-nums">
|
|
||||||
{formatPlaytime(g.playtimeMinutes)}
|
|
||||||
</span>
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ol>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DashboardSkeleton() {
|
function DashboardSkeleton() {
|
||||||
return (
|
return (
|
||||||
<PageContainer className="p-4 md:p-8 space-y-6">
|
<PageContainer className="p-4 md:p-8 space-y-6">
|
||||||
|
|||||||
@@ -1,23 +1,39 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||||
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
|
import {cleanup, fireEvent, screen, waitFor} from '@testing-library/react';
|
||||||
import {MemoryRouter, Route, Routes, useLocation} from 'react-router-dom';
|
|
||||||
import Dashboard from './Dashboard.jsx';
|
import Dashboard from './Dashboard.jsx';
|
||||||
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
|
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
||||||
|
import {renderWithProviders} from '../test/renderWithProviders.jsx';
|
||||||
|
|
||||||
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||||
vi.mock('../hooks/useLibrarySync.js', () => ({default: () => ({status: 'OK', lastSyncedAt: null, isRunning: false})}));
|
vi.mock('../hooks/useLibrarySync.js', () => ({default: () => ({status: 'OK', lastSyncedAt: null, isRunning: false})}));
|
||||||
vi.mock('../hooks/useForceSync.js', () => ({default: () => ({trigger: vi.fn(), busy: false})}));
|
vi.mock('../hooks/useForceSync.js', () => ({default: () => ({trigger: vi.fn(), busy: false})}));
|
||||||
|
|
||||||
const current = {location: null};
|
const avatar = 'https://avatars.steamstatic.com/abc_full.jpg';
|
||||||
|
const games = Array.from({length: 12}, (_, i) => ({appid: 100 + i, name: `Game ${i}`, playtimeMinutes: 1000 - i}));
|
||||||
|
const empty = {content: [], total: 0, byStatus: {}};
|
||||||
|
const mostPlayed = (url) => url.includes('sort=playtimeMinutes') && !url.includes('status=');
|
||||||
|
const quietRoutes = {
|
||||||
|
'GET /api/library/summary': json(200, empty),
|
||||||
|
'GET /api/hints': json(200, {results: []}),
|
||||||
|
};
|
||||||
|
|
||||||
function Spy() {
|
// Every request of the page with a Steam ID; the test of the 401 answers all but the user with 401.
|
||||||
current.location = useLocation();
|
const dashboardRoutes = (settings) => ({
|
||||||
return null;
|
...quietRoutes,
|
||||||
}
|
'GET /api/users/me': json(200, {email: 'a@b.de', displayName: 'Lobo', steamId: '76561198000000000', settings}),
|
||||||
|
'GET /api/library/games': (url) => json(200, mostPlayed(url) ? {content: games, total: 12} : empty),
|
||||||
|
'GET /api/library/stats': json(200, {trend: null, habits: {moonTrips: 1, longestDay: null, underOneHourGames: 0, underOneHourShare: null}}),
|
||||||
|
'GET /api/library/steam-profile': json(200, {personaName: 'Lobo', avatarUrl: avatar}),
|
||||||
|
'GET /api/library/activity': json(200, {totalMinutes: 90, days: [{day: '2026-09-23', minutes: 0}, {day: '2026-09-24', minutes: 90}], recentGames: [
|
||||||
|
{appid: 1086940, name: "Baldur's Gate 3", playtimeMinutes: 900, playtime2Weeks: 90, status: 'PLAYING'}]}),
|
||||||
|
'GET /api/library/games-count': json(200, {count: 1, hasSteamId: true}),
|
||||||
|
'GET /api/library/achievements/summary': json(200, {unlocked: 24, startedGames: 4, perfectGames: 2,
|
||||||
|
averageCompletion: 71.3, pendingGames: 0, hiddenGames: 0, closest: []}),
|
||||||
|
'PUT /api/users/settings/dashboard-tiles': json(204, {}),
|
||||||
|
});
|
||||||
|
|
||||||
const ok = (body) => ({ok: true, status: 200, json: async () => body});
|
const renderDashboard = () => renderWithProviders(<Dashboard/>, {entries: ['/dashboard']});
|
||||||
const unauthorized = () => ({ok: false, status: 401, json: async () => ({})});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
localStorage.setItem('tbp_session', '1');
|
localStorage.setItem('tbp_session', '1');
|
||||||
@@ -30,45 +46,24 @@ afterEach(() => {
|
|||||||
|
|
||||||
describe('Dashboard', () => {
|
describe('Dashboard', () => {
|
||||||
it('opens the login page when the core API answers 401', async () => {
|
it('opens the login page when the core API answers 401', async () => {
|
||||||
globalThis.fetch = vi.fn(async (url) =>
|
fetchRoutes({
|
||||||
url.includes('/api/users/me') ? ok({email: 'a@b.de', steamId: '76561198000000000'}) : unauthorized());
|
...Object.fromEntries(Object.keys(dashboardRoutes()).map((key) => [key, json(401, {})])),
|
||||||
render(
|
'GET /api/users/me': json(200, {email: 'a@b.de', steamId: '76561198000000000'}),
|
||||||
<MemoryRouter initialEntries={['/dashboard']}><PreferencesProvider>
|
'POST /api/auth/refresh': json(401, {}),
|
||||||
<Routes>
|
});
|
||||||
<Route path="/dashboard" element={<><Dashboard/><Spy/></>}/>
|
const {router} = renderWithProviders(<Dashboard/>,
|
||||||
<Route path="/login" element={<Spy/>}/>
|
{path: '/dashboard', entries: ['/dashboard'], guarded: true, routes: {'/login': <p>login</p>}});
|
||||||
</Routes>
|
|
||||||
</PreferencesProvider></MemoryRouter>,
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => expect(fetch.mock.calls.length).toBeGreaterThanOrEqual(5));
|
await waitFor(() => expect(fetch.mock.calls.length).toBeGreaterThanOrEqual(5));
|
||||||
|
|
||||||
await waitFor(() => expect(current.location.pathname).toBe('/login'));
|
await waitFor(() => expect(router.state.location.pathname).toBe('/login'));
|
||||||
expect(current.location.state).toEqual({sessionExpired: true, from: '/dashboard'});
|
expect(router.state.location.state).toEqual({sessionExpired: true, from: '/dashboard'});
|
||||||
expect(screen.queryByText('LIBRARY.EMPTY')).toBeNull();
|
expect(screen.queryByText('LIBRARY.EMPTY')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
const avatar = 'https://avatars.steamstatic.com/abc_full.jpg';
|
|
||||||
const games = Array.from({length: 12}, (_, i) => ({appid: 100 + i, name: `Game ${i}`, playtimeMinutes: 1000 - i}));
|
|
||||||
const mockDashboard = (settings) => {
|
const mockDashboard = (settings) => {
|
||||||
globalThis.fetch = vi.fn(async (url, init) => {
|
fetchRoutes(dashboardRoutes(settings));
|
||||||
if (init?.method === 'PUT') return {ok: true, status: 204, json: async () => ({})};
|
return renderDashboard().container;
|
||||||
if (url.includes('/api/users/me')) return ok({email: 'a@b.de', displayName: 'Lobo', steamId: '76561198000000000', settings});
|
|
||||||
if (url.includes('sort=playtimeMinutes') && !url.includes('status=')) return ok({content: games, total: 12});
|
|
||||||
if (url.includes('/api/library/stats')) return ok({trend: null, habits: {moonTrips: 1, longestDay: null, underOneHourGames: 0, underOneHourShare: null}});
|
|
||||||
if (url.includes('/api/library/steam-profile')) return ok({personaName: 'Lobo', avatarUrl: avatar});
|
|
||||||
if (url.includes('/api/library/activity')) return ok({totalMinutes: 90, days: [{day: '2026-09-23', minutes: 0}, {day: '2026-09-24', minutes: 90}], recentGames: [
|
|
||||||
{appid: 1086940, name: "Baldur's Gate 3", playtimeMinutes: 900, playtime2Weeks: 90, status: 'PLAYING'}]});
|
|
||||||
if (url.includes('/api/library/games-count')) return ok({count: 1, hasSteamId: true});
|
|
||||||
if (url.includes('/api/library/achievements/summary')) return ok({unlocked: 24, startedGames: 4, perfectGames: 2,
|
|
||||||
averageCompletion: 71.3, pendingGames: 0, hiddenGames: 0, closest: []});
|
|
||||||
return ok({content: [], total: 0, byStatus: {}});
|
|
||||||
});
|
|
||||||
return render(
|
|
||||||
<MemoryRouter initialEntries={['/dashboard']}><PreferencesProvider>
|
|
||||||
<Dashboard/>
|
|
||||||
</PreferencesProvider></MemoryRouter>,
|
|
||||||
).container;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
it('shows the Steam avatar and the most played game of the last two weeks as banner', async () => {
|
it('shows the Steam avatar and the most played game of the last two weeks as banner', async () => {
|
||||||
@@ -150,4 +145,20 @@ describe('Dashboard', () => {
|
|||||||
await waitFor(() => expect(container.querySelector(`img[src="${avatar}"]`)).not.toBeNull());
|
await waitFor(() => expect(container.querySelector(`img[src="${avatar}"]`)).not.toBeNull());
|
||||||
expect(container.querySelector('img[src*="library_hero"]')).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.
|
||||||
|
fetchRoutes({
|
||||||
|
...quietRoutes,
|
||||||
|
'GET /api/library/stats': json(200, empty),
|
||||||
|
'GET /api/users/me': json(200, {email: 'a@b.de', displayName: 'Lobo'}),
|
||||||
|
'GET /api/library/games-count': json(200, {count: 1, hasSteamId: true}),
|
||||||
|
'GET /api/library/games': (url) => (mostPlayed(url)
|
||||||
|
? {ok: true, status: 200, json: async () => { throw new SyntaxError('Unexpected token <'); }}
|
||||||
|
: json(200, empty)),
|
||||||
|
});
|
||||||
|
renderDashboard();
|
||||||
|
|
||||||
|
expect(await screen.findByText(/DASHBOARD.HELLO/)).toBeTruthy();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
+14
-287
@@ -1,83 +1,38 @@
|
|||||||
import {useEffect, useMemo, useRef, useState} from 'react';
|
import {useEffect, useRef, useState} from 'react';
|
||||||
import {Link, useNavigate, useParams} from 'react-router-dom';
|
import {Link, useNavigate, useParams} from 'react-router-dom';
|
||||||
import {useTranslation} from 'react-i18next';
|
import {useTranslation} from 'react-i18next';
|
||||||
import {ArrowLeft, Clock, ExternalLink, Eye, EyeOff, Gamepad2, Image as ImageIcon, ImageOff, Merge, PanelTop, PencilLine, RotateCcw, ShieldOff, Timer, Trophy} from 'lucide-react';
|
import {ArrowLeft, EyeOff, ShieldOff, Trophy} from 'lucide-react';
|
||||||
import {toast} from 'sonner';
|
|
||||||
|
|
||||||
import {Badge} from '@/components/ui/badge';
|
|
||||||
import {Button} from '@/components/ui/button';
|
import {Button} from '@/components/ui/button';
|
||||||
import {Card} from '@/components/ui/card';
|
import {Card} from '@/components/ui/card';
|
||||||
import {Skeleton} from '@/components/ui/skeleton';
|
import {Skeleton} from '@/components/ui/skeleton';
|
||||||
import {cn} from '@/lib/utils';
|
|
||||||
import BacklogCard from '../components/BacklogCard.jsx';
|
import BacklogCard from '../components/BacklogCard.jsx';
|
||||||
import Confetti from '../components/Confetti.jsx';
|
import Confetti from '../components/Confetti.jsx';
|
||||||
import GameCover from '../components/GameCover.jsx';
|
import GameAchievements from '../components/GameAchievements.jsx';
|
||||||
import GameStatusBadge from '../components/GameStatusBadge.jsx';
|
import GameHeroCard from '../components/GameHeroCard.jsx';
|
||||||
import ManualGameCard from '../components/ManualGameCard.jsx';
|
import ManualGameCard from '../components/ManualGameCard.jsx';
|
||||||
import CompletedHint from '../components/CompletedHint.jsx';
|
import CompletedHint from '../components/CompletedHint.jsx';
|
||||||
import FinishedHint from '../components/FinishedHint.jsx';
|
import FinishedHint from '../components/FinishedHint.jsx';
|
||||||
import GameCorrectionDialog from '../components/GameCorrectionDialog.jsx';
|
import GameCorrectionDialog from '../components/GameCorrectionDialog.jsx';
|
||||||
import GameMergeDialog from '../components/GameMergeDialog.jsx';
|
import GameMergeDialog from '../components/GameMergeDialog.jsx';
|
||||||
import PlayingHint from '../components/PlayingHint.jsx';
|
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 useCurrentUser from '../hooks/useCurrentUser.js';
|
||||||
import useSessionExpired from '../hooks/useSessionExpired.js';
|
import useGame from '../hooks/useGame.js';
|
||||||
import useSaveAppearance from '../hooks/useSaveAppearance.js';
|
|
||||||
import {usePreferences} from '../context/PreferencesContext.jsx';
|
|
||||||
import PageContainer from '../components/PageContainer.jsx';
|
import PageContainer from '../components/PageContainer.jsx';
|
||||||
import ProfileArtMenu from '../components/ProfileArtMenu.jsx';
|
|
||||||
import {apiFetch} from '../utils/api.js';
|
|
||||||
import {ACHIEVEMENT_ICONS, reportBrokenImage} from '../utils/brokenImages.js';
|
|
||||||
import {canCorrect} from '../utils/corrections.js';
|
import {canCorrect} from '../utils/corrections.js';
|
||||||
import {celebrationFor, celebrationLevel} from '../utils/confetti.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';
|
|
||||||
|
|
||||||
const SPEEDRUN_PREFIX = 'https://www.speedrun.com/';
|
|
||||||
|
|
||||||
const formatUnlockedAt = (instant, locale) => {
|
|
||||||
if (!instant) return '';
|
|
||||||
try {
|
|
||||||
return new Date(instant).toLocaleDateString(locale, {year: 'numeric', month: 'short', day: 'numeric'});
|
|
||||||
} catch {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
function AchievementIcon({appid, src, unlocked}) {
|
|
||||||
const [failed, setFailed] = useState(false);
|
|
||||||
if (!src || failed) return <div className="size-12 rounded-md bg-muted"/>;
|
|
||||||
return (
|
|
||||||
<img src={src} alt="" className={cn('size-12 rounded-md', !unlocked && 'grayscale')}
|
|
||||||
onError={() => {
|
|
||||||
reportBrokenImage(appid, ACHIEVEMENT_ICONS);
|
|
||||||
setFailed(true);
|
|
||||||
}}/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function GameDetail() {
|
export default function GameDetail() {
|
||||||
const {t, i18n} = useTranslation();
|
const {t} = useTranslation();
|
||||||
const {appid} = useParams();
|
const {appid} = useParams();
|
||||||
const {appearance} = usePreferences();
|
|
||||||
const saveAppearance = useSaveAppearance();
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const expireSession = useSessionExpired();
|
|
||||||
|
|
||||||
const [data, setData] = useState(null);
|
const {data, setData, isLoading, error, reload} = useGame(appid);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const [hiding, setHiding] = useState(false);
|
|
||||||
const [error, setError] = useState(null);
|
|
||||||
const [correcting, setCorrecting] = useState(false);
|
const [correcting, setCorrecting] = useState(false);
|
||||||
const [merging, setMerging] = useState(false);
|
const [merging, setMerging] = useState(false);
|
||||||
const [reloads, setReloads] = useState(0);
|
|
||||||
const currentUser = useCurrentUser(true);
|
const currentUser = useCurrentUser(true);
|
||||||
const auditor = canCorrect(currentUser);
|
const auditor = canCorrect(currentUser);
|
||||||
const [savedProfileStyle, setSavedProfileStyle] = useState(null);
|
const [savedProfileStyle, setSavedProfileStyle] = useState(null);
|
||||||
const profileStyle = savedProfileStyle ?? currentUser?.profileStyle;
|
|
||||||
const [celebration, setCelebration] = useState(null);
|
const [celebration, setCelebration] = useState(null);
|
||||||
const shownLevel = useRef(null);
|
const shownLevel = useRef(null);
|
||||||
|
|
||||||
@@ -89,106 +44,7 @@ export default function GameDetail() {
|
|||||||
shownLevel.current = {appid: data.appid, level: celebrationLevel(data)};
|
shownLevel.current = {appid: data.appid, level: celebrationLevel(data)};
|
||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
// Another game starts empty, already in this render. A reload of the same game keeps the old data, so the edit
|
|
||||||
// cards keep what is typed.
|
|
||||||
const [dataAppid, setDataAppid] = useState(appid);
|
|
||||||
if (dataAppid !== appid) {
|
|
||||||
setDataAppid(appid);
|
|
||||||
setData(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false;
|
|
||||||
if (!hasSession()) {
|
|
||||||
navigate('/login');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
(async () => {
|
|
||||||
try {
|
|
||||||
const url = `${import.meta.env.VITE_CORE_API_URL}/api/library/games/${appid}`;
|
|
||||||
const response = await apiFetch(url, {
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (cancelled) return;
|
|
||||||
if (response.status === 401) {
|
|
||||||
expireSession();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (response.status === 404) {
|
|
||||||
setError('GAME_DETAIL.NOT_FOUND');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!response.ok) {
|
|
||||||
setError('GAME_DETAIL.ERROR_LOAD');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const json = await response.json();
|
|
||||||
setData(json);
|
|
||||||
} catch (err) {
|
|
||||||
if (!cancelled) {
|
|
||||||
console.error('GameDetail load error:', err);
|
|
||||||
setError('INFO.ERROR.NETWORK_FAILURE');
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (!cancelled) setIsLoading(false);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [appid, navigate, expireSession, reloads]);
|
|
||||||
|
|
||||||
const sortedAchievements = useMemo(() => {
|
|
||||||
if (!data?.achievements) return [];
|
|
||||||
return [...data.achievements].sort((a, b) => {
|
|
||||||
if (a.unlocked !== b.unlocked) return a.unlocked ? -1 : 1;
|
|
||||||
if (a.unlocked && b.unlocked) {
|
|
||||||
return (b.unlockedAt ?? '').localeCompare(a.unlockedAt ?? '');
|
|
||||||
}
|
|
||||||
return (a.displayName ?? a.apiName).localeCompare(b.displayName ?? b.apiName);
|
|
||||||
});
|
|
||||||
}, [data]);
|
|
||||||
|
|
||||||
const manual = data?.source === 'MANUAL';
|
const manual = data?.source === 'MANUAL';
|
||||||
const onSteam = !!data && isSteamKey(data.appid);
|
|
||||||
// Core checks the link too; a link from the API must never run script.
|
|
||||||
const speedrunUrl = typeof data?.speedrunUrl === 'string' && data.speedrunUrl.startsWith(SPEEDRUN_PREFIX)
|
|
||||||
? data.speedrunUrl : null;
|
|
||||||
const isBackground = !!data && appearance.backgroundAppid === data.appid;
|
|
||||||
const isBanner = !!data && appearance.showBanner && appearance.bannerAppid === data.appid;
|
|
||||||
|
|
||||||
const toggleHidden = async () => {
|
|
||||||
const hidden = !data.hidden;
|
|
||||||
setHiding(true);
|
|
||||||
try {
|
|
||||||
const response = await setGameHidden(data.appid, hidden);
|
|
||||||
if (response.status === 401) {
|
|
||||||
expireSession();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!response.ok) {
|
|
||||||
toast.error(t('GAME_DETAIL.HIDE_ERROR'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setData((d) => ({...d, hidden}));
|
|
||||||
toast.success(t(hidden ? 'GAME_DETAIL.HIDDEN_TOAST' : 'GAME_DETAIL.SHOWN_TOAST'));
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Hide game error:', err);
|
|
||||||
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
|
|
||||||
} finally {
|
|
||||||
setHiding(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const stats = data ? [
|
|
||||||
[t('GAME_DETAIL.UNLOCKED'), data.achievementsUnlocked],
|
|
||||||
[t('GAME_DETAIL.REMAINING'), data.achievementsRemaining],
|
|
||||||
[t('GAME_DETAIL.COMPLETION'), `${data.completionPercent}%`],
|
|
||||||
] : [];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContainer className="p-4 md:p-8 space-y-6">
|
<PageContainer className="p-4 md:p-8 space-y-6">
|
||||||
@@ -217,83 +73,10 @@ export default function GameDetail() {
|
|||||||
<EyeOff className="size-4 shrink-0"/>{t('GAME_DETAIL.HIDDEN_NOTE')}
|
<EyeOff className="size-4 shrink-0"/>{t('GAME_DETAIL.HIDDEN_NOTE')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Card className="relative overflow-hidden p-0 gap-0 md:flex-row">
|
<GameHeroCard game={data} manual={manual} auditor={auditor} profileStyle={savedProfileStyle ?? currentUser?.profileStyle}
|
||||||
<GameCover appid={data.appid} imageUrl={data.imageUrl} className="w-full rounded-none md:w-96 md:shrink-0"/>
|
onCorrect={() => setCorrecting(true)} onMerge={() => setMerging(true)}
|
||||||
<div className="flex flex-1 flex-col justify-between gap-4 p-6">
|
onHiddenChanged={(hidden) => setData((d) => ({...d, hidden}))}
|
||||||
<div>
|
onProfileStyleSaved={setSavedProfileStyle}/>
|
||||||
<h1 className="text-2xl md:text-3xl font-bold tracking-tight">
|
|
||||||
{data.name ?? `App ${data.appid}`}
|
|
||||||
</h1>
|
|
||||||
<div className="mt-3 flex flex-wrap gap-2">
|
|
||||||
<Badge variant="secondary" className="tabular-nums">
|
|
||||||
<Clock className="size-3"/>
|
|
||||||
{t('GAME_DETAIL.PLAYTIME', {time: formatPlaytime(data.playtimeMinutes)})}
|
|
||||||
</Badge>
|
|
||||||
<GameStatusBadge status={data.status}/>
|
|
||||||
{platformLabel(data) && (
|
|
||||||
<Badge variant="outline"><Gamepad2 className="size-3"/>{platformLabel(data)}</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="mt-3">
|
|
||||||
<TimeToBeat time={data.timeToBeat}/>
|
|
||||||
</div>
|
|
||||||
{speedrunUrl && (
|
|
||||||
<Button asChild variant="outline" size="sm" className="mt-3">
|
|
||||||
<a href={speedrunUrl} target="_blank" rel="noopener noreferrer">
|
|
||||||
<Timer/>{t('GAME_DETAIL.OPEN_IN_SPEEDRUN')}<ExternalLink/>
|
|
||||||
</a>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{auditor && (
|
|
||||||
<Button variant="outline" size="sm" className="mt-3 ml-2" onClick={() => setCorrecting(true)}>
|
|
||||||
<PencilLine/>{t('CORRECTION.OPEN')}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{auditor && (
|
|
||||||
<Button variant="outline" size="sm" className="mt-3 ml-2" onClick={() => setMerging(true)}>
|
|
||||||
<Merge/>{t('MERGE.OPEN')}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{/* Store page, background and banner need Steam art, so a game off Steam has none of them.
|
|
||||||
Hide is here too: only a Steam game can be hidden. */}
|
|
||||||
{onSteam && <div className="flex flex-wrap gap-2">
|
|
||||||
<Button asChild variant="outline" size="sm">
|
|
||||||
<a href={`https://store.steampowered.com/app/${data.appid}`} target="_blank" rel="noopener noreferrer">
|
|
||||||
{t('GAME_DETAIL.OPEN_IN_STEAM')}<ExternalLink/>
|
|
||||||
</a>
|
|
||||||
</Button>
|
|
||||||
{isBackground ? (
|
|
||||||
<Button variant="outline" size="sm" onClick={() => saveAppearance({backgroundAppid: null})}>
|
|
||||||
<ImageOff/>{t('GAME_DETAIL.REMOVE_BACKGROUND')}
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button variant="outline" size="sm" onClick={() => saveAppearance({backgroundAppid: data.appid})}>
|
|
||||||
<ImageIcon/>{t('GAME_DETAIL.SET_BACKGROUND')}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{isBanner ? (
|
|
||||||
<Button variant="outline" size="sm" onClick={() => saveAppearance({bannerAppid: null})}>
|
|
||||||
<RotateCcw/>{t('SETTINGS.BANNER_AUTO')}
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button variant="outline" size="sm"
|
|
||||||
onClick={() => saveAppearance({bannerAppid: data.appid, showBanner: true})}>
|
|
||||||
<PanelTop/>{t('GAME_DETAIL.SET_BANNER')}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{profileStyle && (
|
|
||||||
<ProfileArtMenu appid={data.appid} style={profileStyle} onSaved={setSavedProfileStyle}/>
|
|
||||||
)}
|
|
||||||
{!manual && (
|
|
||||||
<Button variant="outline" size="sm" onClick={toggleHidden} disabled={hiding}>
|
|
||||||
{data.hidden ? <Eye/> : <EyeOff/>}
|
|
||||||
{t(data.hidden ? 'GAME_DETAIL.UNHIDE' : 'GAME_DETAIL.HIDE')}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* The finished question also answers "are you playing it?". */}
|
{/* The finished question also answers "are you playing it?". */}
|
||||||
{!(data.finishedHint && ['BACKLOG', 'PLAYING'].includes(data.status)) && (
|
{!(data.finishedHint && ['BACKLOG', 'PLAYING'].includes(data.status)) && (
|
||||||
@@ -326,75 +109,19 @@ export default function GameDetail() {
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{data.hasAchievements && data.statsVisible && (
|
{data.hasAchievements && data.statsVisible && <GameAchievements game={data}/>}
|
||||||
<>
|
|
||||||
<Card className="p-6 gap-5">
|
|
||||||
<div className="flex items-baseline justify-between">
|
|
||||||
<h2 className="flex items-center gap-2 text-lg font-semibold">
|
|
||||||
<Trophy className="size-4 text-amber-500"/>
|
|
||||||
{t('GAME_DETAIL.ACHIEVEMENTS')}
|
|
||||||
</h2>
|
|
||||||
<span className="text-sm text-muted-foreground tabular-nums">
|
|
||||||
{data.achievementsUnlocked} / {data.achievementsTotal}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="h-2.5 w-full overflow-hidden rounded-full bg-muted">
|
|
||||||
<div
|
|
||||||
className="h-full rounded-full bg-primary transition-all"
|
|
||||||
style={{width: `${Math.min(100, data.completionPercent)}%`}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-3 gap-3 text-center">
|
|
||||||
{stats.map(([label, value]) => (
|
|
||||||
<div key={label} className="rounded-lg bg-muted/50 p-3">
|
|
||||||
<div className="text-2xl font-bold tabular-nums">{value}</div>
|
|
||||||
<div className="text-xs text-muted-foreground">{label}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<ul className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
|
||||||
{sortedAchievements.map((a) => {
|
|
||||||
const icon = a.unlocked ? a.iconUrl : a.iconGrayUrl;
|
|
||||||
return (
|
|
||||||
<li
|
|
||||||
key={a.apiName}
|
|
||||||
className={cn(
|
|
||||||
'flex items-start gap-3 rounded-xl border bg-card p-3',
|
|
||||||
a.unlocked ? 'border-primary/40' : 'opacity-60',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<AchievementIcon key={icon} appid={data.appid} src={icon} unlocked={a.unlocked}/>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="truncate font-medium">{a.displayName ?? a.apiName}</div>
|
|
||||||
{a.description && (
|
|
||||||
<div className="mt-1 line-clamp-2 text-xs text-muted-foreground">{a.description}</div>
|
|
||||||
)}
|
|
||||||
{a.unlocked && a.unlockedAt && (
|
|
||||||
<div className="mt-1 text-xs text-primary tabular-nums">
|
|
||||||
{formatUnlockedAt(a.unlockedAt, i18n.language)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{celebration && <Confetti key={celebration.id} size={celebration.size} onDone={() => setCelebration(null)}/>}
|
{celebration && <Confetti key={celebration.id} size={celebration.size} onDone={() => setCelebration(null)}/>}
|
||||||
{/* Outside the content: the reload after a save must not close it. */}
|
{/* Outside the content: the reload after a save must not close it. */}
|
||||||
{data && auditor && (
|
{data && auditor && (
|
||||||
<GameCorrectionDialog game={data} open={correcting} onOpenChange={setCorrecting}
|
<GameCorrectionDialog game={data} open={correcting} onOpenChange={setCorrecting}
|
||||||
onChanged={() => setReloads((n) => n + 1)}/>
|
onChanged={reload}/>
|
||||||
)}
|
)}
|
||||||
{auditor && data && (
|
{auditor && data && (
|
||||||
<GameMergeDialog game={data} open={merging} onOpenChange={setMerging} onMerged={(merged) => {
|
<GameMergeDialog game={data} open={merging} onOpenChange={setMerging} onMerged={(merged) => {
|
||||||
// A new key loads through the address; the same key needs a reload.
|
// A new key loads through the address; the same key needs a reload.
|
||||||
if (merged === data.appid) setReloads((n) => n + 1);
|
if (merged === data.appid) reload();
|
||||||
else navigate(`/library/${merged}`, {replace: true});
|
else navigate(`/library/${merged}`, {replace: true});
|
||||||
}}/>
|
}}/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
import {afterEach, beforeEach, expect, it, vi} from 'vitest';
|
import {afterEach, beforeEach, expect, it, vi} from 'vitest';
|
||||||
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
|
import {cleanup, fireEvent, screen, waitFor} from '@testing-library/react';
|
||||||
import {MemoryRouter, Route, Routes} from 'react-router-dom';
|
|
||||||
import GameDetail from './GameDetail.jsx';
|
import GameDetail from './GameDetail.jsx';
|
||||||
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
|
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
||||||
|
import {renderWithProviders} from '../test/renderWithProviders.jsx';
|
||||||
|
|
||||||
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||||
// Stands in for a saved correction, which loads the game again.
|
// Stands in for a saved correction, which loads the game again.
|
||||||
@@ -20,8 +20,10 @@ const gameLoads = () => fetch.mock.calls.filter(([url]) => url.endsWith('/api/li
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
localStorage.setItem('tbp_session', '1');
|
localStorage.setItem('tbp_session', '1');
|
||||||
globalThis.fetch = vi.fn(async (url) => ({ok: true, status: 200,
|
fetchRoutes({
|
||||||
json: async () => (url.includes('/api/users/me') ? {role: 'AUDITOR', activeRole: 'AUDITOR'} : game)}));
|
'GET /api/users/me': json(200, {role: 'AUDITOR', activeRole: 'AUDITOR'}),
|
||||||
|
'GET /api/library/games/220': json(200, game),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -30,11 +32,7 @@ afterEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('keeps unsaved notes while the game loads again after a correction', async () => {
|
it('keeps unsaved notes while the game loads again after a correction', async () => {
|
||||||
render(
|
renderWithProviders(<GameDetail/>, {path: '/library/:appid', entries: ['/library/220']});
|
||||||
<MemoryRouter initialEntries={['/library/220']}><PreferencesProvider>
|
|
||||||
<Routes><Route path="/library/:appid" element={<GameDetail/>}/></Routes>
|
|
||||||
</PreferencesProvider></MemoryRouter>,
|
|
||||||
);
|
|
||||||
fireEvent.change(await screen.findByLabelText('BACKLOG.NOTES_LABEL'), {target: {value: 'my draft'}});
|
fireEvent.change(await screen.findByLabelText('BACKLOG.NOTES_LABEL'), {target: {value: 'my draft'}});
|
||||||
|
|
||||||
fireEvent.click(await screen.findByRole('button', {name: 'correction saved'}));
|
fireEvent.click(await screen.findByRole('button', {name: 'correction saved'}));
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||||
import {act, cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
|
import {act, cleanup, fireEvent, screen, waitFor} from '@testing-library/react';
|
||||||
import {MemoryRouter, Route, Routes} from 'react-router-dom';
|
|
||||||
import GameDetail from './GameDetail.jsx';
|
import GameDetail from './GameDetail.jsx';
|
||||||
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
|
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
||||||
|
import {renderWithProviders} from '../test/renderWithProviders.jsx';
|
||||||
import {resetBrokenImageReports} from '../utils/brokenImages.js';
|
import {resetBrokenImageReports} from '../utils/brokenImages.js';
|
||||||
import {toast} from 'sonner';
|
import {toast} from 'sonner';
|
||||||
|
|
||||||
@@ -15,20 +15,20 @@ const game = {
|
|||||||
rating: null, notes: null, priority: null, hasAchievements: false, statsVisible: false, achievements: [],
|
rating: null, notes: null, priority: null, hasAchievements: false, statsVisible: false, achievements: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderDetail = (appid = 220) => render(
|
const renderDetail = (appid = 220) => renderWithProviders(<GameDetail/>,
|
||||||
<MemoryRouter initialEntries={[`/library/${appid}`]}><PreferencesProvider>
|
{path: '/library/:appid', entries: [`/library/${appid}`], routes: {'/library': <p>library page</p>}});
|
||||||
<Routes>
|
|
||||||
<Route path="/library/:appid" element={<GameDetail/>}/>
|
const echo = (url, init) => json(200, JSON.parse(init.body));
|
||||||
<Route path="/library" element={<p>library page</p>}/>
|
|
||||||
</Routes>
|
// The user and the game at /library/<shown.appid>; `routes` adds or replaces answers.
|
||||||
</PreferencesProvider></MemoryRouter>,
|
const answer = (shown, routes = {}, me = {settings: {}}) => fetchRoutes({
|
||||||
);
|
'GET /api/users/me': json(200, me),
|
||||||
|
[`GET /api/library/games/${shown.appid}`]: json(200, shown),
|
||||||
|
...routes,
|
||||||
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
localStorage.setItem('tbp_session', '1');
|
localStorage.setItem('tbp_session', '1');
|
||||||
globalThis.fetch = vi.fn(async (url, init) => init?.method === 'PUT'
|
|
||||||
? {ok: true, status: 200, json: async () => JSON.parse(init.body)}
|
|
||||||
: {ok: true, status: 200, json: async () => game});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -39,8 +39,7 @@ afterEach(() => {
|
|||||||
|
|
||||||
describe('GameDetail', () => {
|
describe('GameDetail', () => {
|
||||||
it('asks "finished?" instead of "playing?" when Core has a finished hint', async () => {
|
it('asks "finished?" instead of "playing?" when Core has a finished hint', async () => {
|
||||||
globalThis.fetch = vi.fn(async () => ({ok: true, status: 200, json: async () => ({...game, playtime2Weeks: 90,
|
answer({...game, playtime2Weeks: 90, source: 'STEAM', finishedHint: {type: 'ENDING', achievement: 'Survivor'}});
|
||||||
source: 'STEAM', finishedHint: {type: 'ENDING', achievement: 'Survivor'}})}));
|
|
||||||
renderDetail();
|
renderDetail();
|
||||||
|
|
||||||
expect(await screen.findByRole('button', {name: 'GAME_DETAIL.SET_FINISHED'})).toBeTruthy();
|
expect(await screen.findByRole('button', {name: 'GAME_DETAIL.SET_FINISHED'})).toBeTruthy();
|
||||||
@@ -48,9 +47,7 @@ describe('GameDetail', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('hides a Steam game and shows it again', async () => {
|
it('hides a Steam game and shows it again', async () => {
|
||||||
globalThis.fetch = vi.fn(async (url, init) => init?.method === 'PUT'
|
answer({...game, source: 'STEAM', hidden: false}, {'PUT /api/library/games/220/hidden': echo});
|
||||||
? {ok: true, status: 200, json: async () => JSON.parse(init.body)}
|
|
||||||
: {ok: true, status: 200, json: async () => ({...game, source: 'STEAM', hidden: false})});
|
|
||||||
renderDetail();
|
renderDetail();
|
||||||
|
|
||||||
fireEvent.click(await screen.findByRole('button', {name: 'GAME_DETAIL.HIDE'}));
|
fireEvent.click(await screen.findByRole('button', {name: 'GAME_DETAIL.HIDE'}));
|
||||||
@@ -67,9 +64,7 @@ describe('GameDetail', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('keeps the game shown when Core refuses', async () => {
|
it('keeps the game shown when Core refuses', async () => {
|
||||||
globalThis.fetch = vi.fn(async (url, init) => init?.method === 'PUT'
|
answer({...game, source: 'STEAM', hidden: false}, {'PUT /api/library/games/220/hidden': json(409, {code: 'MANUAL_GAME'})});
|
||||||
? {ok: false, status: 409, json: async () => ({code: 'MANUAL_GAME'})}
|
|
||||||
: {ok: true, status: 200, json: async () => ({...game, source: 'STEAM', hidden: false})});
|
|
||||||
renderDetail();
|
renderDetail();
|
||||||
|
|
||||||
fireEvent.click(await screen.findByRole('button', {name: 'GAME_DETAIL.HIDE'}));
|
fireEvent.click(await screen.findByRole('button', {name: 'GAME_DETAIL.HIDE'}));
|
||||||
@@ -79,8 +74,7 @@ describe('GameDetail', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('offers no hide for a game added by hand', async () => {
|
it('offers no hide for a game added by hand', async () => {
|
||||||
globalThis.fetch = vi.fn(async () => ({ok: true, status: 200,
|
answer({...game, source: 'MANUAL', platform: 'PS5', hidden: false});
|
||||||
json: async () => ({...game, source: 'MANUAL', platform: 'PS5', hidden: false})}));
|
|
||||||
renderDetail();
|
renderDetail();
|
||||||
|
|
||||||
await screen.findByText('GAME_DETAIL.OPEN_IN_STEAM');
|
await screen.findByText('GAME_DETAIL.OPEN_IN_STEAM');
|
||||||
@@ -90,11 +84,8 @@ describe('GameDetail', () => {
|
|||||||
it('reports a broken achievement icon once and shows the empty tile', async () => {
|
it('reports a broken achievement icon once and shows the empty tile', async () => {
|
||||||
const achievement = (apiName) => ({apiName, displayName: apiName, unlocked: false, iconUrl: `https://x/${apiName}.jpg`,
|
const achievement = (apiName) => ({apiName, displayName: apiName, unlocked: false, iconUrl: `https://x/${apiName}.jpg`,
|
||||||
iconGrayUrl: `https://x/${apiName}_gray.jpg`});
|
iconGrayUrl: `https://x/${apiName}_gray.jpg`});
|
||||||
globalThis.fetch = vi.fn(async (url, init) => init?.method === 'POST'
|
answer({...game, hasAchievements: true, statsVisible: true, achievementsTotal: 2, achievementsUnlocked: 0,
|
||||||
? {ok: true, status: 204}
|
completionPercent: 0, achievements: [achievement('A'), achievement('B')]}, {'POST /api/library/broken-images': json(204)});
|
||||||
: {ok: true, status: 200, json: async () => ({...game, hasAchievements: true, statsVisible: true,
|
|
||||||
achievementsTotal: 2, achievementsUnlocked: 0, completionPercent: 0,
|
|
||||||
achievements: [achievement('A'), achievement('B')]})});
|
|
||||||
const {container} = renderDetail();
|
const {container} = renderDetail();
|
||||||
|
|
||||||
await screen.findByText('A');
|
await screen.findByText('A');
|
||||||
@@ -108,8 +99,7 @@ describe('GameDetail', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('links the speedrun.com page when Core found one', async () => {
|
it('links the speedrun.com page when Core found one', async () => {
|
||||||
globalThis.fetch = vi.fn(async () => ({ok: true, status: 200,
|
answer({...game, speedrunUrl: 'https://www.speedrun.com/hl2'});
|
||||||
json: async () => ({...game, speedrunUrl: 'https://www.speedrun.com/hl2'})}));
|
|
||||||
renderDetail();
|
renderDetail();
|
||||||
|
|
||||||
const link = await screen.findByRole('link', {name: /GAME_DETAIL.OPEN_IN_SPEEDRUN/});
|
const link = await screen.findByRole('link', {name: /GAME_DETAIL.OPEN_IN_SPEEDRUN/});
|
||||||
@@ -118,7 +108,7 @@ describe('GameDetail', () => {
|
|||||||
|
|
||||||
it.each([null, 'https://www.speedrun.com.evil.example/hl2', 'https://evil.example/hl2', 42])(
|
it.each([null, 'https://www.speedrun.com.evil.example/hl2', 'https://evil.example/hl2', 42])(
|
||||||
'shows no speedrun link for %s', async (speedrunUrl) => {
|
'shows no speedrun link for %s', async (speedrunUrl) => {
|
||||||
globalThis.fetch = vi.fn(async () => ({ok: true, status: 200, json: async () => ({...game, speedrunUrl})}));
|
answer({...game, speedrunUrl});
|
||||||
renderDetail();
|
renderDetail();
|
||||||
|
|
||||||
await screen.findByText('GAME_DETAIL.OPEN_IN_STEAM');
|
await screen.findByText('GAME_DETAIL.OPEN_IN_STEAM');
|
||||||
@@ -126,6 +116,8 @@ describe('GameDetail', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('keeps unsaved notes in the backlog card when the hint sets the game to playing', async () => {
|
it('keeps unsaved notes in the backlog card when the hint sets the game to playing', async () => {
|
||||||
|
// Core changes only the status and answers the whole entry.
|
||||||
|
answer(game, {'PUT /api/library/games/220/status': (url, init) => json(200, {...game, ...JSON.parse(init.body)})});
|
||||||
renderDetail();
|
renderDetail();
|
||||||
const notes = await screen.findByLabelText('BACKLOG.NOTES_LABEL');
|
const notes = await screen.findByLabelText('BACKLOG.NOTES_LABEL');
|
||||||
fireEvent.change(notes, {target: {value: 'my draft'}});
|
fireEvent.change(notes, {target: {value: 'my draft'}});
|
||||||
@@ -137,6 +129,7 @@ describe('GameDetail', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('uses the game as background and can remove it again', async () => {
|
it('uses the game as background and can remove it again', async () => {
|
||||||
|
answer(game, {'PUT /api/users/settings/appearance': echo});
|
||||||
renderDetail();
|
renderDetail();
|
||||||
|
|
||||||
fireEvent.click(await screen.findByRole('button', {name: 'GAME_DETAIL.SET_BACKGROUND'}));
|
fireEvent.click(await screen.findByRole('button', {name: 'GAME_DETAIL.SET_BACKGROUND'}));
|
||||||
@@ -151,6 +144,7 @@ describe('GameDetail', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('uses the game as banner and goes back to the automatic banner', async () => {
|
it('uses the game as banner and goes back to the automatic banner', async () => {
|
||||||
|
answer(game, {'PUT /api/users/settings/appearance': echo});
|
||||||
renderDetail();
|
renderDetail();
|
||||||
|
|
||||||
fireEvent.click(await screen.findByRole('button', {name: 'GAME_DETAIL.SET_BANNER'}));
|
fireEvent.click(await screen.findByRole('button', {name: 'GAME_DETAIL.SET_BANNER'}));
|
||||||
@@ -165,6 +159,7 @@ describe('GameDetail', () => {
|
|||||||
|
|
||||||
it('offers "use as banner" for the picked game while the banner is off, and turns it on', async () => {
|
it('offers "use as banner" for the picked game while the banner is off, and turns it on', async () => {
|
||||||
localStorage.setItem('tbp_appearance', JSON.stringify({bannerAppid: 220, showBanner: false}));
|
localStorage.setItem('tbp_appearance', JSON.stringify({bannerAppid: 220, showBanner: false}));
|
||||||
|
answer(game, {'PUT /api/users/settings/appearance': echo});
|
||||||
renderDetail();
|
renderDetail();
|
||||||
|
|
||||||
fireEvent.click(await screen.findByRole('button', {name: 'GAME_DETAIL.SET_BANNER'}));
|
fireEvent.click(await screen.findByRole('button', {name: 'GAME_DETAIL.SET_BANNER'}));
|
||||||
@@ -181,10 +176,10 @@ describe('GameDetail of a game added by hand', () => {
|
|||||||
playtime2Weeks: 0, imageUrl: 'https://images.igdb.com/z.jpg',
|
playtime2Weeks: 0, imageUrl: 'https://images.igdb.com/z.jpg',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const noBody = {ok: true, status: 204, json: async () => { throw new Error('204 has no body'); }};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
globalThis.fetch = vi.fn(async (url, init) => (init?.method === 'PUT' || init?.method === 'DELETE')
|
answer(manualGame, {'PUT /api/library/games/-1025/manual': noBody, 'DELETE /api/library/games/-1025': noBody});
|
||||||
? {ok: true, status: 204, json: async () => { throw new Error('204 has no body'); }}
|
|
||||||
: {ok: true, status: 200, json: async () => manualGame});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows platform and IGDB picture, and offers no Steam actions', async () => {
|
it('shows platform and IGDB picture, and offers no Steam actions', async () => {
|
||||||
@@ -226,7 +221,7 @@ describe('GameDetail of a game added by hand', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('keeps Save off for an unchanged game with 75 minutes', async () => {
|
it('keeps Save off for an unchanged game with 75 minutes', async () => {
|
||||||
globalThis.fetch = vi.fn(async () => ({ok: true, status: 200, json: async () => ({...manualGame, playtimeMinutes: 75})}));
|
answer({...manualGame, playtimeMinutes: 75});
|
||||||
renderDetail(-1025);
|
renderDetail(-1025);
|
||||||
|
|
||||||
await screen.findByLabelText('MANUAL_GAME.HOURS');
|
await screen.findByLabelText('MANUAL_GAME.HOURS');
|
||||||
@@ -235,7 +230,7 @@ describe('GameDetail of a game added by hand', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('shows no manual card for a Steam game', async () => {
|
it('shows no manual card for a Steam game', async () => {
|
||||||
globalThis.fetch = vi.fn(async () => ({ok: true, status: 200, json: async () => ({...game, source: 'STEAM'})}));
|
answer({...game, source: 'STEAM'});
|
||||||
renderDetail();
|
renderDetail();
|
||||||
|
|
||||||
expect(await screen.findByRole('button', {name: 'GAME_DETAIL.SET_BACKGROUND'})).toBeTruthy();
|
expect(await screen.findByRole('button', {name: 'GAME_DETAIL.SET_BACKGROUND'})).toBeTruthy();
|
||||||
@@ -244,10 +239,10 @@ describe('GameDetail of a game added by hand', () => {
|
|||||||
|
|
||||||
it('keeps a background set in another tab when it sets the profile banner', async () => {
|
it('keeps a background set in another tab when it sets the profile banner', async () => {
|
||||||
let backgroundAppid = null;
|
let backgroundAppid = null;
|
||||||
globalThis.fetch = vi.fn(async (url, init) => ({ok: true, status: init?.method === 'PUT' ? 204 : 200,
|
answer({...game, source: 'STEAM'}, {
|
||||||
json: async () => (url.includes('/api/users/me')
|
'GET /api/users/me': () => json(200, {role: 'USER', settings: {profileStyle: {accent: 'red', corners: 'round', backgroundAppid}}}),
|
||||||
? {role: 'USER', settings: {profileStyle: {accent: 'red', corners: 'round', backgroundAppid}}}
|
'PUT /api/users/settings/profile-style': json(204),
|
||||||
: {...game, source: 'STEAM'})}));
|
});
|
||||||
renderDetail();
|
renderDetail();
|
||||||
const menu = await screen.findByRole('button', {name: 'GAME_DETAIL.PROFILE_ART'});
|
const menu = await screen.findByRole('button', {name: 'GAME_DETAIL.PROFILE_ART'});
|
||||||
|
|
||||||
@@ -263,10 +258,10 @@ describe('GameDetail of a game added by hand', () => {
|
|||||||
|
|
||||||
it('keeps a banner picked in another tab when it sets this game as banner', async () => {
|
it('keeps a banner picked in another tab when it sets this game as banner', async () => {
|
||||||
let bannerAppid = 220;
|
let bannerAppid = 220;
|
||||||
globalThis.fetch = vi.fn(async (url, init) => ({ok: true, status: init?.method === 'PUT' ? 204 : 200,
|
answer({...game, source: 'STEAM'}, {
|
||||||
json: async () => (url.includes('/api/users/me')
|
'GET /api/users/me': () => json(200, {role: 'USER', settings: {profileStyle: {bannerAppid}}}),
|
||||||
? {role: 'USER', settings: {profileStyle: {bannerAppid}}}
|
'PUT /api/users/settings/profile-style': json(204),
|
||||||
: {...game, source: 'STEAM'})}));
|
});
|
||||||
renderDetail();
|
renderDetail();
|
||||||
const menu = await screen.findByRole('button', {name: 'GAME_DETAIL.PROFILE_ART'});
|
const menu = await screen.findByRole('button', {name: 'GAME_DETAIL.PROFILE_ART'});
|
||||||
|
|
||||||
@@ -281,12 +276,12 @@ describe('GameDetail of a game added by hand', () => {
|
|||||||
|
|
||||||
it('uses the art as profile banner and removes it on the second click', async () => {
|
it('uses the art as profile banner and removes it on the second click', async () => {
|
||||||
let stored = {accent: 'red', corners: 'round'};
|
let stored = {accent: 'red', corners: 'round'};
|
||||||
globalThis.fetch = vi.fn(async (url, init) => {
|
answer({...game, source: 'STEAM'}, {
|
||||||
if (init?.method === 'PUT') stored = JSON.parse(init.body);
|
'GET /api/users/me': () => json(200, {role: 'USER', settings: {profileStyle: stored}}),
|
||||||
return {ok: true, status: init?.method === 'PUT' ? 204 : 200,
|
'PUT /api/users/settings/profile-style': (url, init) => {
|
||||||
json: async () => (url.includes('/api/users/me')
|
stored = JSON.parse(init.body);
|
||||||
? {role: 'USER', settings: {profileStyle: stored}}
|
return json(204);
|
||||||
: {...game, source: 'STEAM'})};
|
},
|
||||||
});
|
});
|
||||||
const styleBodies = () => fetch.mock.calls.filter(([url, init]) => init?.method === 'PUT' && url.endsWith('/profile-style'))
|
const styleBodies = () => fetch.mock.calls.filter(([url, init]) => init?.method === 'PUT' && url.endsWith('/profile-style'))
|
||||||
.map(([, init]) => JSON.parse(init.body));
|
.map(([, init]) => JSON.parse(init.body));
|
||||||
@@ -307,10 +302,7 @@ describe('GameDetail of a game added by hand', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('corrections', () => {
|
describe('corrections', () => {
|
||||||
const loggedInAs = (me) => {
|
const loggedInAs = (me) => answer(game, {}, me);
|
||||||
globalThis.fetch = vi.fn(async (url) => ({ok: true, status: 200,
|
|
||||||
json: async () => (url.includes('/api/users/me') ? me : game)}));
|
|
||||||
};
|
|
||||||
|
|
||||||
it('offers "correct data" to an active auditor', async () => {
|
it('offers "correct data" to an active auditor', async () => {
|
||||||
loggedInAs({role: 'AUDITOR', activeRole: 'AUDITOR'});
|
loggedInAs({role: 'AUDITOR', activeRole: 'AUDITOR'});
|
||||||
@@ -358,7 +350,7 @@ describe('GameDetail confetti', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const opened = async (entry) => {
|
const opened = async (entry) => {
|
||||||
globalThis.fetch = vi.fn(async () => ({ok: true, status: 200, json: async () => ({...game, ...entry})}));
|
answer({...game, ...entry});
|
||||||
const {container} = renderDetail();
|
const {container} = renderDetail();
|
||||||
await screen.findByText('Half-Life 2');
|
await screen.findByText('Half-Life 2');
|
||||||
return container.querySelector('canvas[data-size]');
|
return container.querySelector('canvas[data-size]');
|
||||||
@@ -378,3 +370,21 @@ describe('GameDetail confetti', () => {
|
|||||||
expect(await opened({status: 'PLAYING'})).toBeNull();
|
expect(await opened({status: 'PLAYING'})).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('GameDetail session', () => {
|
||||||
|
it('opens the login page on a 401 that the refresh cannot fix, without an error', async () => {
|
||||||
|
toast.error.mockClear();
|
||||||
|
fetchRoutes({
|
||||||
|
'GET /api/users/me': json(200, {settings: {}}),
|
||||||
|
'GET /api/library/games/220': json(401, {}),
|
||||||
|
'POST /api/auth/refresh': json(401, {}),
|
||||||
|
});
|
||||||
|
const {router} = renderWithProviders(<GameDetail/>, {path: '/library/:appid', entries: ['/library/220'],
|
||||||
|
guarded: true, routes: {'/login': <p>login</p>}});
|
||||||
|
|
||||||
|
await waitFor(() => expect(router.state.location.pathname).toBe('/login'));
|
||||||
|
expect(router.state.location.state).toEqual({sessionExpired: true, from: '/library/220'});
|
||||||
|
expect(toast.error).not.toHaveBeenCalled();
|
||||||
|
expect(screen.queryByText('GAME_DETAIL.ERROR_LOAD')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
+21
-43
@@ -1,10 +1,9 @@
|
|||||||
import {useCallback, useEffect, useState} from 'react';
|
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 {useTranslation} from 'react-i18next';
|
||||||
import useLibrarySync from '../hooks/useLibrarySync.js';
|
import useLibrarySync from '../hooks/useLibrarySync.js';
|
||||||
import SyncStatusBadge from '../components/SyncStatusBadge.jsx';
|
import SyncStatusBadge from '../components/SyncStatusBadge.jsx';
|
||||||
import useForceSync from '../hooks/useForceSync.js';
|
import useForceSync from '../hooks/useForceSync.js';
|
||||||
import useSessionExpired from '../hooks/useSessionExpired.js';
|
|
||||||
import {formatPlaytime} from '../utils/games.js';
|
import {formatPlaytime} from '../utils/games.js';
|
||||||
import {libraryQuery, parseStatus} from '../utils/backlog.js';
|
import {libraryQuery, parseStatus} from '../utils/backlog.js';
|
||||||
import {GAME_STATUSES, MAX_LIBRARY_SEARCH_LENGTH} from '../utils/limits.js';
|
import {GAME_STATUSES, MAX_LIBRARY_SEARCH_LENGTH} from '../utils/limits.js';
|
||||||
@@ -21,8 +20,8 @@ import GameCover from '../components/GameCover.jsx';
|
|||||||
import LibraryTabs from '../components/LibraryTabs.jsx';
|
import LibraryTabs from '../components/LibraryTabs.jsx';
|
||||||
import GameStatusBadge from '../components/GameStatusBadge.jsx';
|
import GameStatusBadge from '../components/GameStatusBadge.jsx';
|
||||||
import PageContainer from '../components/PageContainer.jsx';
|
import PageContainer from '../components/PageContainer.jsx';
|
||||||
import {apiFetch} from '../utils/api.js';
|
import {apiFetch, request} from '../utils/api.js';
|
||||||
import {hasSession} from '../utils/auth.js';
|
import useAbortPrevious from '../hooks/useAbortPrevious.js';
|
||||||
import {platformLabel} from '../utils/platforms.js';
|
import {platformLabel} from '../utils/platforms.js';
|
||||||
|
|
||||||
const PAGE_SIZES = [20, 40, 80];
|
const PAGE_SIZES = [20, 40, 80];
|
||||||
@@ -42,8 +41,6 @@ const SORT_OPTIONS = [
|
|||||||
|
|
||||||
export default function GameLibrary() {
|
export default function GameLibrary() {
|
||||||
const {t} = useTranslation();
|
const {t} = useTranslation();
|
||||||
const navigate = useNavigate();
|
|
||||||
const expireSession = useSessionExpired();
|
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
|
||||||
const page = Math.max(0, parseInt(searchParams.get('page') ?? '0', 10) || 0);
|
const page = Math.max(0, parseInt(searchParams.get('page') ?? '0', 10) || 0);
|
||||||
@@ -69,47 +66,28 @@ export default function GameLibrary() {
|
|||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
const nextSignal = useAbortPrevious();
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
if (!hasSession()) {
|
const signal = nextSignal();
|
||||||
navigate('/login');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
const url = `${import.meta.env.VITE_CORE_API_URL}/api/library/games?${libraryQuery({page, size, sort, status: listStatus, q, maxMinutes: listShortOnly ? SHORT_GAME_MINUTES : null, hidden: hiddenOnly})}`;
|
||||||
const url = `${import.meta.env.VITE_CORE_API_URL}/api/library/games?${libraryQuery({page, size, sort, status: listStatus, q, maxMinutes: listShortOnly ? SHORT_GAME_MINUTES : null, hidden: hiddenOnly})}`;
|
const {ok, status, data: json} = await request(url, {signal});
|
||||||
const response = await apiFetch(url, {
|
if (signal.aborted) return;
|
||||||
headers: {
|
setIsLoading(false);
|
||||||
'Content-Type': 'application/json',
|
if (status === 401) return;
|
||||||
},
|
if (!ok || !json) {
|
||||||
});
|
setError(status === 0 ? 'INFO.ERROR.NETWORK_FAILURE' : 'LIBRARY.ERROR_LOAD');
|
||||||
|
return;
|
||||||
if (response.status === 401) {
|
|
||||||
expireSession();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
setError('LIBRARY.ERROR_LOAD');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const json = await response.json();
|
|
||||||
setData(json);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Library load error:', err);
|
|
||||||
setError('INFO.ERROR.NETWORK_FAILURE');
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
}
|
||||||
}, [navigate, expireSession, page, size, sort, listStatus, q, listShortOnly, hiddenOnly]);
|
setData(json);
|
||||||
|
}, [nextSignal, page, size, sort, listStatus, q, listShortOnly, hiddenOnly]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
load();
|
load();
|
||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!hasSession()) return undefined;
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
apiFetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/games-count`)
|
apiFetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/games-count`)
|
||||||
.then((response) => (response.ok ? response.json() : null))
|
.then((response) => (response.ok ? response.json() : null))
|
||||||
@@ -127,26 +105,25 @@ export default function GameLibrary() {
|
|||||||
enabled: hasSteamId,
|
enabled: hasSteamId,
|
||||||
expectInitialSync,
|
expectInitialSync,
|
||||||
onComplete: load,
|
onComplete: load,
|
||||||
onUnauthorized: expireSession,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const updateParams = (next) => {
|
const updateParams = useCallback((next) => {
|
||||||
const params = new URLSearchParams(searchParams);
|
const params = new URLSearchParams(searchParams);
|
||||||
Object.entries(next).forEach(([k, v]) => (v === null || v === '' ? params.delete(k) : params.set(k, String(v))));
|
Object.entries(next).forEach(([k, v]) => (v === null || v === '' ? params.delete(k) : params.set(k, String(v))));
|
||||||
setSearchParams(params);
|
setSearchParams(params);
|
||||||
};
|
}, [searchParams, setSearchParams]);
|
||||||
|
|
||||||
// Back/forward navigation changes q without typing. Keeps a trailing space the user is still typing.
|
// Back/forward navigation changes q without typing. Keeps a trailing space the user is still typing.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSearch((current) => (current.trim() === q ? current : q));
|
setSearch((current) => (current.trim() === q ? current : q));
|
||||||
}, [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(() => {
|
useEffect(() => {
|
||||||
if (search.trim() === q) return undefined;
|
if (search.trim() === q) return undefined;
|
||||||
const timer = setTimeout(() => updateParams({q: search.trim(), page: 0}), SEARCH_DEBOUNCE_MS);
|
const timer = setTimeout(() => updateParams({q: search.trim(), page: 0}), SEARCH_DEBOUNCE_MS);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [search, q, searchParams]);
|
}, [search, q, updateParams]);
|
||||||
|
|
||||||
const onStatusChange = (value) => {
|
const onStatusChange = (value) => {
|
||||||
updateParams({status: value, hidden: null, page: 0});
|
updateParams({status: value, hidden: null, page: 0});
|
||||||
@@ -169,7 +146,7 @@ export default function GameLibrary() {
|
|||||||
updateParams({page: target});
|
updateParams({page: target});
|
||||||
};
|
};
|
||||||
|
|
||||||
const {trigger: triggerSync, busy: syncBusy} = useForceSync(load);
|
const {trigger: triggerSync, busy: syncBusy} = useForceSync(sync.watch);
|
||||||
|
|
||||||
const syncing = syncBusy || sync.isRunning;
|
const syncing = syncBusy || sync.isRunning;
|
||||||
const ready = !isLoading && !error;
|
const ready = !isLoading && !error;
|
||||||
@@ -187,6 +164,7 @@ export default function GameLibrary() {
|
|||||||
status={sync.status}
|
status={sync.status}
|
||||||
lastSyncedAt={sync.lastSyncedAt}
|
lastSyncedAt={sync.lastSyncedAt}
|
||||||
isRunning={sync.isRunning}
|
isRunning={sync.isRunning}
|
||||||
|
stale={sync.stale}
|
||||||
/>
|
/>
|
||||||
<Button variant="outline" size="sm" onClick={triggerSync} disabled={syncing}>
|
<Button variant="outline" size="sm" onClick={triggerSync} disabled={syncing}>
|
||||||
<RefreshCw className={syncing ? 'animate-spin' : ''}/>
|
<RefreshCw className={syncing ? 'animate-spin' : ''}/>
|
||||||
|
|||||||
@@ -1,20 +1,31 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||||
import {act, cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
|
import {act, cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
|
||||||
import {MemoryRouter, Route, Routes, useLocation, useNavigate} from 'react-router-dom';
|
import {createMemoryRouter, RouterProvider, useLocation, useNavigate} from 'react-router-dom';
|
||||||
import GameLibrary from './GameLibrary.jsx';
|
import GameLibrary from './GameLibrary.jsx';
|
||||||
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
|
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
|
||||||
|
import {CurrentUserProvider} from '../context/CurrentUserContext.jsx';
|
||||||
|
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
||||||
|
import {renderWithProviders} from '../test/renderWithProviders.jsx';
|
||||||
|
import {toast} from 'sonner';
|
||||||
|
|
||||||
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||||
|
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
|
||||||
vi.mock('../hooks/useLibrarySync.js', () => ({default: () => ({status: 'OK', lastSyncedAt: null, isRunning: false})}));
|
vi.mock('../hooks/useLibrarySync.js', () => ({default: () => ({status: 'OK', lastSyncedAt: null, isRunning: false})}));
|
||||||
vi.mock('../hooks/useForceSync.js', () => ({default: () => ({trigger: vi.fn(), busy: false})}));
|
vi.mock('../hooks/useForceSync.js', () => ({default: () => ({trigger: vi.fn(), busy: false})}));
|
||||||
|
|
||||||
const DEBOUNCE_WAIT_MS = 700;
|
const DEBOUNCE_WAIT_MS = 700;
|
||||||
const page = {content: [{appid: 220, name: 'Half-Life 2', playtimeMinutes: 0, status: 'BACKLOG'}], page: 0, size: 20, totalElements: 1, totalPages: 1};
|
const page = {content: [{appid: 220, name: 'Half-Life 2', playtimeMinutes: 0, status: 'BACKLOG'}], page: 0, size: 20, totalElements: 1, totalPages: 1};
|
||||||
|
|
||||||
|
const answer = (games = page.content, hasSteamId = true) => fetchRoutes({
|
||||||
|
'GET /api/users/me': json(200, {settings: {}}),
|
||||||
|
'GET /api/library/games': json(200, {...page, content: games, totalElements: games.length, totalPages: games.length ? 1 : 0}),
|
||||||
|
'GET /api/library/games-count': json(200, {count: games.length, hasSteamId}),
|
||||||
|
});
|
||||||
|
|
||||||
const wait = (ms) => act(() => new Promise((resolve) => setTimeout(resolve, ms)));
|
const wait = (ms) => act(() => new Promise((resolve) => setTimeout(resolve, ms)));
|
||||||
|
|
||||||
// MemoryRouter, not createMemoryRouter: it pushes like the BrowserRouter in main.jsx, also for an unchanged URL.
|
// A data router like main.jsx: a navigation to the same URL replaces the entry.
|
||||||
function renderLibrary(initialEntry = '/library') {
|
function renderLibrary(initialEntry = '/library') {
|
||||||
const lib = {navigations: 0, location: null, navigate: null};
|
const lib = {navigations: 0, location: null, navigate: null};
|
||||||
function Spy() {
|
function Spy() {
|
||||||
@@ -26,11 +37,11 @@ function renderLibrary(initialEntry = '/library') {
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
render(
|
const router = createMemoryRouter([{
|
||||||
<MemoryRouter initialEntries={[initialEntry]}><PreferencesProvider>
|
path: '/library',
|
||||||
<Routes><Route path="/library" element={<><GameLibrary/><Spy/></>}/></Routes>
|
element: <PreferencesProvider><CurrentUserProvider><GameLibrary/><Spy/></CurrentUserProvider></PreferencesProvider>,
|
||||||
</PreferencesProvider></MemoryRouter>,
|
}], {initialEntries: [initialEntry]});
|
||||||
);
|
render(<RouterProvider router={router}/>);
|
||||||
return {
|
return {
|
||||||
navigations: () => lib.navigations,
|
navigations: () => lib.navigations,
|
||||||
params: () => new URLSearchParams(lib.location.search),
|
params: () => new URLSearchParams(lib.location.search),
|
||||||
@@ -42,7 +53,7 @@ const typeInto = (value) => fireEvent.change(screen.getByRole('searchbox'), {tar
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
localStorage.setItem('tbp_session', '1');
|
localStorage.setItem('tbp_session', '1');
|
||||||
globalThis.fetch = vi.fn(async () => ({ok: true, status: 200, json: async () => page}));
|
answer();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -52,16 +63,46 @@ afterEach(() => {
|
|||||||
|
|
||||||
describe('GameLibrary search', () => {
|
describe('GameLibrary search', () => {
|
||||||
it('shows the achievement progress of a game when it is known', async () => {
|
it('shows the achievement progress of a game when it is known', async () => {
|
||||||
globalThis.fetch = vi.fn(async () => ({ok: true, status: 200, json: async () => ({...page, content: [
|
answer([
|
||||||
{...page.content[0], achievementsUnlocked: 3, achievementsTotal: 4},
|
{...page.content[0], achievementsUnlocked: 3, achievementsTotal: 4},
|
||||||
{appid: 620, name: 'Portal 2', playtimeMinutes: 0, status: 'BACKLOG', achievementsUnlocked: null, achievementsTotal: 5},
|
{appid: 620, name: 'Portal 2', playtimeMinutes: 0, status: 'BACKLOG', achievementsUnlocked: null, achievementsTotal: 5},
|
||||||
]})}));
|
]);
|
||||||
renderLibrary();
|
renderLibrary();
|
||||||
|
|
||||||
expect(await screen.findByText('3/4')).toBeTruthy();
|
expect(await screen.findByText('3/4')).toBeTruthy();
|
||||||
expect(screen.queryByText(/\/5$/)).toBeNull();
|
expect(screen.queryByText(/\/5$/)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows the newest search when an older answer comes last', async () => {
|
||||||
|
const later = [];
|
||||||
|
const games = (name) => json(200, {...page, content: [{...page.content[0], name}], totalElements: 1, totalPages: 1});
|
||||||
|
fetchRoutes({
|
||||||
|
'GET /api/users/me': json(200, {settings: {}}),
|
||||||
|
'GET /api/library/games-count': json(200, {count: 1, hasSteamId: true}),
|
||||||
|
'GET /api/library/games': (url, init) => {
|
||||||
|
const q = new URL(url, 'http://x').searchParams.get('q');
|
||||||
|
if (!q) return games('Half-Life 2');
|
||||||
|
// The first search answers only after the second one.
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
later.push({q, answer: () => resolve(games(q === 'ha' ? 'Old answer' : 'New answer'))});
|
||||||
|
init?.signal?.addEventListener('abort', () => reject(new DOMException('aborted', 'AbortError')));
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
renderLibrary();
|
||||||
|
await screen.findByText('Half-Life 2');
|
||||||
|
|
||||||
|
typeInto('ha');
|
||||||
|
await waitFor(() => expect(later).toHaveLength(1));
|
||||||
|
typeInto('half');
|
||||||
|
await waitFor(() => expect(later).toHaveLength(2));
|
||||||
|
await act(async () => later[1].answer());
|
||||||
|
await act(async () => later[0].answer());
|
||||||
|
|
||||||
|
expect(screen.getByText('New answer')).toBeTruthy();
|
||||||
|
expect(screen.queryByText('Old answer')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it('writes the trimmed search to the URL after the delay and resets the page', async () => {
|
it('writes the trimmed search to the URL after the delay and resets the page', async () => {
|
||||||
const lib = renderLibrary('/library?page=3');
|
const lib = renderLibrary('/library?page=3');
|
||||||
|
|
||||||
@@ -100,7 +141,7 @@ describe('GameLibrary search', () => {
|
|||||||
typeInto('half');
|
typeInto('half');
|
||||||
await waitFor(() => expect(lib.params().get('q')).toBe('half'));
|
await waitFor(() => expect(lib.params().get('q')).toBe('half'));
|
||||||
|
|
||||||
act(() => lib.back());
|
await act(async () => lib.back());
|
||||||
|
|
||||||
await waitFor(() => expect(screen.getByRole('searchbox').value).toBe(''));
|
await waitFor(() => expect(screen.getByRole('searchbox').value).toBe(''));
|
||||||
});
|
});
|
||||||
@@ -161,7 +202,7 @@ describe('GameLibrary short games', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('says so when no game is hidden', async () => {
|
it('says so when no game is hidden', async () => {
|
||||||
globalThis.fetch = vi.fn(async () => ({ok: true, status: 200, json: async () => ({...page, content: [], totalElements: 0, totalPages: 0})}));
|
answer([]);
|
||||||
renderLibrary('/library?hidden=true');
|
renderLibrary('/library?hidden=true');
|
||||||
|
|
||||||
expect(await screen.findByText('LIBRARY.NO_HIDDEN')).toBeTruthy();
|
expect(await screen.findByText('LIBRARY.NO_HIDDEN')).toBeTruthy();
|
||||||
@@ -169,15 +210,11 @@ describe('GameLibrary short games', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('GameLibrary with games added by hand', () => {
|
describe('GameLibrary with games added by hand', () => {
|
||||||
const answer = (games, hasSteamId) => vi.fn(async (url) => ({
|
|
||||||
ok: true, status: 200,
|
|
||||||
json: async () => (String(url).includes('/games-count') ? {count: games.length, hasSteamId} : {...page, content: games}),
|
|
||||||
}));
|
|
||||||
const zelda = {appid: -1025, name: 'Zelda', playtimeMinutes: 90, status: 'BACKLOG', source: 'MANUAL',
|
const zelda = {appid: -1025, name: 'Zelda', playtimeMinutes: 90, status: 'BACKLOG', source: 'MANUAL',
|
||||||
platform: 'Nintendo Switch', imageUrl: 'https://images.igdb.com/z.jpg'};
|
platform: 'Nintendo Switch', imageUrl: 'https://images.igdb.com/z.jpg'};
|
||||||
|
|
||||||
it('shows the platform and the IGDB picture of a manual game', async () => {
|
it('shows the platform and the IGDB picture of a manual game', async () => {
|
||||||
globalThis.fetch = answer([zelda], true);
|
answer([zelda], true);
|
||||||
renderLibrary();
|
renderLibrary();
|
||||||
|
|
||||||
expect(await screen.findByText('Nintendo Switch')).toBeTruthy();
|
expect(await screen.findByText('Nintendo Switch')).toBeTruthy();
|
||||||
@@ -185,7 +222,7 @@ describe('GameLibrary with games added by hand', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('shows Steam as the platform of a Steam game, and links the import', async () => {
|
it('shows Steam as the platform of a Steam game, and links the import', async () => {
|
||||||
globalThis.fetch = answer([{appid: 220, name: 'Half-Life 2', playtimeMinutes: 0, status: 'BACKLOG', source: 'STEAM',
|
answer([{appid: 220, name: 'Half-Life 2', playtimeMinutes: 0, status: 'BACKLOG', source: 'STEAM',
|
||||||
platform: null}], true);
|
platform: null}], true);
|
||||||
renderLibrary();
|
renderLibrary();
|
||||||
|
|
||||||
@@ -194,7 +231,7 @@ describe('GameLibrary with games added by hand', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('hides the Steam sync for a user without a Steam ID but still offers adding a game', async () => {
|
it('hides the Steam sync for a user without a Steam ID but still offers adding a game', async () => {
|
||||||
globalThis.fetch = answer([zelda], false);
|
answer([zelda], false);
|
||||||
renderLibrary();
|
renderLibrary();
|
||||||
|
|
||||||
await screen.findByText('Zelda');
|
await screen.findByText('Zelda');
|
||||||
@@ -203,7 +240,7 @@ describe('GameLibrary with games added by hand', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('opens the add panel', async () => {
|
it('opens the add panel', async () => {
|
||||||
globalThis.fetch = answer([zelda], true);
|
answer([zelda], true);
|
||||||
renderLibrary();
|
renderLibrary();
|
||||||
|
|
||||||
fireEvent.click(await screen.findByRole('button', {name: 'MANUAL_GAME.ADD_BUTTON'}));
|
fireEvent.click(await screen.findByRole('button', {name: 'MANUAL_GAME.ADD_BUTTON'}));
|
||||||
@@ -212,3 +249,21 @@ describe('GameLibrary with games added by hand', () => {
|
|||||||
expect(screen.getByText('LIBRARY.RESYNC')).toBeTruthy();
|
expect(screen.getByText('LIBRARY.RESYNC')).toBeTruthy();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('GameLibrary session', () => {
|
||||||
|
it('opens the login page on a 401 that the refresh cannot fix, without an error', async () => {
|
||||||
|
fetchRoutes({
|
||||||
|
'GET /api/users/me': json(200, {settings: {}}),
|
||||||
|
'GET /api/library/games': json(401, {}),
|
||||||
|
'GET /api/library/games-count': json(401, {}),
|
||||||
|
'POST /api/auth/refresh': json(401, {}),
|
||||||
|
});
|
||||||
|
const {router} = renderWithProviders(<GameLibrary/>, {path: '/library', entries: ['/library?q=zelda'],
|
||||||
|
guarded: true, routes: {'/login': <p>login</p>}});
|
||||||
|
|
||||||
|
await waitFor(() => expect(router.state.location.pathname).toBe('/login'));
|
||||||
|
expect(router.state.location.state).toEqual({sessionExpired: true, from: '/library?q=zelda'});
|
||||||
|
expect(toast.error).not.toHaveBeenCalled();
|
||||||
|
expect(screen.queryByText('LIBRARY.ERROR_LOAD')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
+12
-493
@@ -1,48 +1,14 @@
|
|||||||
import {memo, useCallback, useEffect, useRef, useState} from 'react';
|
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 {useTranslation} from 'react-i18next';
|
||||||
import {toast} from 'sonner';
|
import {ArrowLeft, Loader2, Upload} from 'lucide-react';
|
||||||
import {ArrowLeft, CheckCircle2, Circle, Loader2, RotateCcw, Search, Upload, XCircle} from 'lucide-react';
|
|
||||||
|
|
||||||
import {Badge} from '@/components/ui/badge';
|
|
||||||
import {Button} from '@/components/ui/button';
|
import {Button} from '@/components/ui/button';
|
||||||
import {Card} from '@/components/ui/card';
|
import ImportHistory from '../components/import/ImportHistory.jsx';
|
||||||
import {Input} from '@/components/ui/input';
|
import ImportInput from '../components/import/ImportInput.jsx';
|
||||||
import {Textarea} from '@/components/ui/textarea';
|
import ImportView from '../components/import/ImportView.jsx';
|
||||||
import {cn} from '@/lib/utils';
|
|
||||||
import GameSearch from '../components/GameSearch.jsx';
|
|
||||||
import PageContainer from '../components/PageContainer.jsx';
|
import PageContainer from '../components/PageContainer.jsx';
|
||||||
import useSessionExpired from '../hooks/useSessionExpired.js';
|
import {listImports, RUNNING} from '../utils/imports.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';
|
|
||||||
|
|
||||||
const POLL_MS = 2000;
|
|
||||||
// Statuses a worker still has: the page polls while it shows one.
|
|
||||||
const RUNNING = ['QUEUED', 'MATCHING', 'SAVING'];
|
|
||||||
// The steps an import goes through, as shown to the user.
|
|
||||||
const STEPS = ['QUEUED', 'MATCHING', 'REVIEW', 'SAVING', 'DETAILS', 'DONE'];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The step an import is in; a FAILED one is in the step it stopped in.
|
|
||||||
*/
|
|
||||||
export const stepOf = (data) => {
|
|
||||||
switch (data.status) {
|
|
||||||
case 'QUEUED': return 'QUEUED';
|
|
||||||
case 'MATCHING': return 'MATCHING';
|
|
||||||
case 'READY': return 'REVIEW';
|
|
||||||
case 'SAVING': return data.step === 'DETAILS' ? 'DETAILS' : 'SAVING';
|
|
||||||
case 'FAILED': return data.chosen > 0 ? 'SAVING' : 'MATCHING';
|
|
||||||
default: return 'DONE';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
// The native select: a review of 1000 lines would mount 3000 Radix selects.
|
|
||||||
const SELECT_CLASS = 'h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-2 text-sm dark:bg-input/30';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Many games at once: a list or a CSV goes to Core, which matches it with IGDB in the background; the user checks
|
* Many games at once: a list or a CSV goes to Core, which matches it with IGDB in the background; the user checks
|
||||||
@@ -50,8 +16,6 @@ const SELECT_CLASS = 'h-9 w-full min-w-0 rounded-md border border-input bg-trans
|
|||||||
*/
|
*/
|
||||||
export default function ImportPage() {
|
export default function ImportPage() {
|
||||||
const {t} = useTranslation();
|
const {t} = useTranslation();
|
||||||
const navigate = useNavigate();
|
|
||||||
const expireSession = useSessionExpired();
|
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const [imports, setImports] = useState(null);
|
const [imports, setImports] = useState(null);
|
||||||
const id = searchParams.get('id');
|
const id = searchParams.get('id');
|
||||||
@@ -59,24 +23,17 @@ export default function ImportPage() {
|
|||||||
const loadImports = useCallback(async () => {
|
const loadImports = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const response = await listImports();
|
const response = await listImports();
|
||||||
if (response.status === 401) {
|
if (response.status === 401) return;
|
||||||
expireSession();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setImports(response.ok ? (await response.json()).imports : []);
|
setImports(response.ok ? (await response.json()).imports : []);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Import list error:', err);
|
console.error('Import list error:', err);
|
||||||
setImports([]);
|
setImports([]);
|
||||||
}
|
}
|
||||||
}, [expireSession]);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!hasSession()) {
|
|
||||||
navigate('/login');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
loadImports();
|
loadImports();
|
||||||
}, [navigate, loadImports]);
|
}, [loadImports]);
|
||||||
|
|
||||||
// Without ?id=, an import a worker still has is shown first; one to review or failed is in the history.
|
// 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));
|
const open = imports?.find((entry) => RUNNING.includes(entry.status));
|
||||||
@@ -103,452 +60,14 @@ export default function ImportPage() {
|
|||||||
{imports === null ? (
|
{imports === null ? (
|
||||||
<Loader2 className="animate-spin text-muted-foreground"/>
|
<Loader2 className="animate-spin text-muted-foreground"/>
|
||||||
) : shownId ? (
|
) : shownId ? (
|
||||||
<ImportView key={shownId} id={shownId} onDone={done} onChanged={loadImports} expireSession={expireSession}/>
|
<ImportView key={shownId} id={shownId} onDone={done} onChanged={loadImports}/>
|
||||||
) : (
|
) : (
|
||||||
<ImportInput onCreated={(created) => {
|
<ImportInput onCreated={(created) => {
|
||||||
show(created);
|
show(created);
|
||||||
loadImports();
|
loadImports();
|
||||||
}}/>
|
}}/>
|
||||||
)}
|
)}
|
||||||
{imports?.length > 0 && <History imports={imports} onOpen={show} onChanged={loadImports} expireSession={expireSession}/>}
|
{imports?.length > 0 && <ImportHistory imports={imports} onOpen={show} onChanged={loadImports}/>}
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ImportInput({onCreated}) {
|
|
||||||
const {t} = useTranslation();
|
|
||||||
const [source, setSource] = useState('TEXT');
|
|
||||||
const [text, setText] = useState('');
|
|
||||||
const [csv, setCsv] = useState(null);
|
|
||||||
const [busy, setBusy] = useState(false);
|
|
||||||
const rows = source === 'TEXT' ? parseTextList(text) : csv?.rows ?? [];
|
|
||||||
const tooMany = rows.length > MAX_IMPORT_ROWS;
|
|
||||||
|
|
||||||
const readFile = async (file) => setCsv(file ? parseCsv(await file.text()) : null);
|
|
||||||
|
|
||||||
const startImport = useStartImport();
|
|
||||||
const start = async () => {
|
|
||||||
setBusy(true);
|
|
||||||
const id = await startImport(source, rows);
|
|
||||||
setBusy(false);
|
|
||||||
if (id) onCreated(id);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card className="gap-4 p-5">
|
|
||||||
<p className="text-sm text-muted-foreground">{t('IMPORT.HINT')}</p>
|
|
||||||
<div role="group" aria-label={t('IMPORT.SOURCE')} className="flex gap-2">
|
|
||||||
{['TEXT', 'CSV'].map((value) => (
|
|
||||||
<Button key={value} size="sm" variant={source === value ? 'default' : 'outline'} aria-pressed={source === value}
|
|
||||||
onClick={() => setSource(value)}>{t(`IMPORT.SOURCE_${value}`)}</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{source === 'TEXT' ? (
|
|
||||||
<Textarea rows={10} value={text} aria-label={t('IMPORT.TEXT_LABEL')} placeholder={t('IMPORT.TEXT_PLACEHOLDER')}
|
|
||||||
onChange={(e) => setText(e.target.value)}/>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Input type="file" accept=".csv,text/csv" aria-label={t('IMPORT.CSV_LABEL')}
|
|
||||||
onChange={(e) => readFile(e.target.files?.[0])}/>
|
|
||||||
<p className="text-xs text-muted-foreground">{t('IMPORT.CSV_HINT')}</p>
|
|
||||||
{csv?.error && <p className="text-sm text-destructive">{t(csv.error)}</p>}
|
|
||||||
{csv?.rows?.length > 0 && (
|
|
||||||
<ul className="text-sm text-muted-foreground">
|
|
||||||
{csv.rows.slice(0, 5).map((row, index) => (
|
|
||||||
<li key={index} className="truncate">
|
|
||||||
{[row.title, row.platform, row.status].filter(Boolean).join(' · ')}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
||||||
<span className={tooMany ? 'text-sm text-destructive' : 'text-sm text-muted-foreground'}>
|
|
||||||
{t(tooMany ? 'IMPORT.TOO_MANY' : 'IMPORT.LINES', {count: rows.length, max: MAX_IMPORT_ROWS})}
|
|
||||||
</span>
|
|
||||||
<Button onClick={start} disabled={busy || rows.length === 0 || tooMany}>
|
|
||||||
{busy ? <Loader2 className="animate-spin"/> : <Search/>}{t('IMPORT.START')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Polls while a worker has the import.
|
|
||||||
function ImportView({id, onDone, onChanged, expireSession}) {
|
|
||||||
const {t} = useTranslation();
|
|
||||||
const [data, setData] = useState(null);
|
|
||||||
const [failed, setFailed] = useState(false);
|
|
||||||
// Grows after a save or a retry: the import is back with a worker, so polling starts again.
|
|
||||||
const [version, setVersion] = useState(0);
|
|
||||||
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);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let timer;
|
|
||||||
let cancelled = false;
|
|
||||||
const load = async () => {
|
|
||||||
try {
|
|
||||||
const response = await getImport(id);
|
|
||||||
if (cancelled) return;
|
|
||||||
if (response.status === 401) {
|
|
||||||
expireSession();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// A 4xx ends the polling; a 5xx while Core restarts is asked again.
|
|
||||||
if (response.status >= 400 && response.status < 500) {
|
|
||||||
setFailed(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!response.ok) {
|
|
||||||
timer = setTimeout(load, POLL_MS);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const next = await response.json();
|
|
||||||
if (cancelled) return;
|
|
||||||
// A new status also shows in the history.
|
|
||||||
if (lastStatus.current && lastStatus.current !== next.status) onChanged();
|
|
||||||
lastStatus.current = next.status;
|
|
||||||
setData(next);
|
|
||||||
if (RUNNING.includes(next.status)) timer = setTimeout(load, POLL_MS);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Import load error:', err);
|
|
||||||
if (!cancelled) timer = setTimeout(load, POLL_MS);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
load();
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
clearTimeout(timer);
|
|
||||||
};
|
|
||||||
}, [id, expireSession, version, onChanged]);
|
|
||||||
|
|
||||||
if (failed) return <p className="text-sm text-destructive">{t('IMPORT.LOAD_FAILED')}</p>;
|
|
||||||
if (!data || favorite === undefined) return <Loader2 className="animate-spin text-muted-foreground"/>;
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<ImportSteps data={data}/>
|
|
||||||
{data.status === 'READY' ? (
|
|
||||||
<Review data={data} favorite={favorite} onSaved={reload} onDone={onDone} expireSession={expireSession}/>
|
|
||||||
) : (
|
|
||||||
<ImportProgress data={data} onRetried={reload} onDone={onDone} expireSession={expireSession}/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// The steps in a row: done ones checked, the current one marked (red when the import failed there).
|
|
||||||
function ImportSteps({data}) {
|
|
||||||
const {t} = useTranslation();
|
|
||||||
const current = STEPS.indexOf(stepOf(data));
|
|
||||||
const failedHere = data.status === 'FAILED';
|
|
||||||
return (
|
|
||||||
<ol className="flex flex-wrap gap-x-4 gap-y-2 text-sm" aria-label={t('IMPORT.STEPS')}>
|
|
||||||
{STEPS.map((step, index) => {
|
|
||||||
const done = index < current || (index === current && step === 'DONE');
|
|
||||||
const now = index === current && !done;
|
|
||||||
return (
|
|
||||||
<li key={step} aria-current={now ? 'step' : undefined}
|
|
||||||
className={cn('flex items-center gap-1.5', done && 'text-muted-foreground',
|
|
||||||
now && (failedHere ? 'font-medium text-destructive' : 'font-medium text-primary'),
|
|
||||||
!done && !now && 'text-muted-foreground/60')}>
|
|
||||||
{done ? <CheckCircle2 className="size-4"/> : now && failedHere ? <XCircle className="size-4"/>
|
|
||||||
: now && RUNNING.includes(data.status) ? <Loader2 className="size-4 animate-spin"/>
|
|
||||||
: <Circle className="size-4"/>}
|
|
||||||
{t(`IMPORT.STEP.${step}`)}
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ol>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// What the current step does, with its numbers; Retry for a FAILED import.
|
|
||||||
function ImportProgress({data, onRetried, onDone, expireSession}) {
|
|
||||||
const {t} = useTranslation();
|
|
||||||
const [busy, setBusy] = useState(false);
|
|
||||||
const step = stepOf(data);
|
|
||||||
const waiting = data.ahead != null;
|
|
||||||
|
|
||||||
const retry = async () => {
|
|
||||||
setBusy(true);
|
|
||||||
try {
|
|
||||||
const response = await retryImport(data.id);
|
|
||||||
if (response.status === 401) {
|
|
||||||
expireSession();
|
|
||||||
} else if (response.status === 409) {
|
|
||||||
toast.error(t('IMPORT.RUNNING'));
|
|
||||||
} else if (!response.ok) {
|
|
||||||
toast.error(t('IMPORT.RETRY_FAILED'));
|
|
||||||
} else {
|
|
||||||
onRetried();
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Import retry error:', err);
|
|
||||||
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
|
|
||||||
} finally {
|
|
||||||
setBusy(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let text;
|
|
||||||
let progress = null;
|
|
||||||
if (data.status === 'FAILED') {
|
|
||||||
text = t('IMPORT.FAILED', {count: data.attempts});
|
|
||||||
} else if (waiting) {
|
|
||||||
text = data.ahead > 0 ? t('IMPORT.WAITING', {count: data.ahead}) : t('IMPORT.WAITING_NEXT');
|
|
||||||
} else if (step === 'MATCHING') {
|
|
||||||
text = t('IMPORT.MATCHING', {matched: data.matched, total: data.total});
|
|
||||||
progress = [data.matched, data.total];
|
|
||||||
} else if (step === 'SAVING') {
|
|
||||||
text = t('IMPORT.SAVING', {saved: data.saved, chosen: data.chosen});
|
|
||||||
progress = [data.saved, data.chosen];
|
|
||||||
} else if (step === 'DETAILS') {
|
|
||||||
text = t('IMPORT.DETAILS');
|
|
||||||
} else {
|
|
||||||
text = t(data.status === 'UNDONE' ? 'IMPORT.STATUS.UNDONE' : 'IMPORT.DONE',
|
|
||||||
{added: data.added, skipped: data.saved - data.added});
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card className="gap-3 p-5">
|
|
||||||
<p className={cn('font-medium', data.status === 'FAILED' && 'text-destructive')}>{text}</p>
|
|
||||||
{progress && <progress className="w-full accent-primary" value={progress[0]} max={progress[1]}/>}
|
|
||||||
{data.attempts > 0 && RUNNING.includes(data.status) && (
|
|
||||||
<p className="text-sm text-muted-foreground">{t('IMPORT.ATTEMPT', {attempt: data.attempts + 1})}</p>
|
|
||||||
)}
|
|
||||||
{RUNNING.includes(data.status) && <p className="text-sm text-muted-foreground">{t('IMPORT.MATCHING_HINT')}</p>}
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{data.status === 'FAILED' && (
|
|
||||||
<Button onClick={retry} disabled={busy}>
|
|
||||||
{busy ? <Loader2 className="animate-spin"/> : <RotateCcw/>}{t('IMPORT.RETRY')}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{!RUNNING.includes(data.status) && (
|
|
||||||
<Button variant="outline" onClick={onDone}>{t('IMPORT.NEW')}</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The first choice of a line: its best candidate with the preselected platform; checked only for a sure match (the
|
|
||||||
* same name, not in the library yet, a platform found).
|
|
||||||
*/
|
|
||||||
export const initialChoice = (row, favorite) => {
|
|
||||||
const candidate = row.candidates[0];
|
|
||||||
const platform = candidate ? preselectPlatform(row.platform, candidate.platforms, favorite) : '';
|
|
||||||
return {
|
|
||||||
candidates: row.candidates,
|
|
||||||
igdbId: candidate?.igdbId ?? null,
|
|
||||||
platform,
|
|
||||||
status: row.status ?? 'BACKLOG',
|
|
||||||
hours: toHours(row.playtimeMinutes),
|
|
||||||
checked: row.exact && !!candidate && !candidate.inLibrary && platform !== '',
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
function Review({data, favorite, onSaved, onDone, expireSession}) {
|
|
||||||
const {t} = useTranslation();
|
|
||||||
const [choices, setChoices] = useState(() => data.rows.map((row) => initialChoice(row, favorite)));
|
|
||||||
const [busy, setBusy] = useState(false);
|
|
||||||
|
|
||||||
// Stable, so a change in one line re-renders only that line (up to 1000 lines).
|
|
||||||
const change = useCallback((index, changes) => setChoices((current) =>
|
|
||||||
current.map((choice, i) => (i === index ? {...choice, ...changes} : choice))), []);
|
|
||||||
const pick = useCallback((index, candidate) => setChoices((current) => current.map((choice, i) => {
|
|
||||||
if (i !== index) return choice;
|
|
||||||
const candidates = choice.candidates.some((known) => known.igdbId === candidate.igdbId)
|
|
||||||
? choice.candidates : [...choice.candidates, candidate];
|
|
||||||
return {...choice, candidates, igdbId: candidate.igdbId,
|
|
||||||
platform: preselectPlatform(data.rows[index].platform, candidate.platforms, favorite)};
|
|
||||||
})), [data.rows, favorite]);
|
|
||||||
const chosen = choices.map((choice, index) => ({choice, row: data.rows[index]}))
|
|
||||||
.filter(({choice}) => choice.checked && choice.igdbId && choice.platform.trim());
|
|
||||||
|
|
||||||
const save = async () => {
|
|
||||||
setBusy(true);
|
|
||||||
try {
|
|
||||||
const response = await saveImport(data.id, chosen.map(({choice, row}) => ({
|
|
||||||
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.ok) {
|
|
||||||
toast.error(t('IMPORT.SAVE_FAILED'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
onSaved();
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Import save error:', err);
|
|
||||||
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
|
|
||||||
} finally {
|
|
||||||
setBusy(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card className="gap-0 overflow-hidden p-0">
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3 border-b px-5 py-4">
|
|
||||||
<p className="text-sm text-muted-foreground">{t('IMPORT.REVIEW_HINT')}</p>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button variant="ghost" onClick={onDone} disabled={busy}>{t('IMPORT.NEW')}</Button>
|
|
||||||
<Button onClick={save} disabled={busy || chosen.length === 0}>
|
|
||||||
{busy && <Loader2 className="animate-spin"/>}{t('IMPORT.SAVE', {count: chosen.length})}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<ul className="divide-y">
|
|
||||||
{choices.map((choice, index) => (
|
|
||||||
<ReviewRow key={data.rows[index].lineNo} index={index} row={data.rows[index]} choice={choice}
|
|
||||||
onChange={change} onPick={pick}/>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const ReviewRow = memo(function ReviewRow({index, row, choice, onChange: changeLine, onPick: pickLine}) {
|
|
||||||
const {t} = useTranslation();
|
|
||||||
const onChange = (changes) => changeLine(index, changes);
|
|
||||||
const onPick = (candidate) => pickLine(index, candidate);
|
|
||||||
const [searching, setSearching] = useState(false);
|
|
||||||
const [query, setQuery] = useState(row.title ?? '');
|
|
||||||
const candidate = choice.candidates.find((known) => known.igdbId === choice.igdbId);
|
|
||||||
const label = `${t('IMPORT.LINE', {line: row.lineNo})}: ${row.title ?? ''}`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<li className="space-y-2 px-5 py-3" data-line={row.lineNo}>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<input type="checkbox" className="size-4 accent-primary" aria-label={label} checked={choice.checked}
|
|
||||||
disabled={!candidate} onChange={(e) => onChange({checked: e.target.checked})}/>
|
|
||||||
<span className="min-w-0 flex-1 truncate text-sm">{label}</span>
|
|
||||||
{candidate?.inLibrary && <Badge variant="secondary">{t('MANUAL_GAME.IN_LIBRARY')}</Badge>}
|
|
||||||
{row.searchFailed && <Badge variant="destructive">{t('IMPORT.SEARCH_FAILED')}</Badge>}
|
|
||||||
<Button variant="ghost" size="sm" onClick={() => setSearching(!searching)}>
|
|
||||||
<Search/>{t('IMPORT.SEARCH_AGAIN')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{searching && (
|
|
||||||
<GameSearch query={query} onQueryChange={setQuery} hide={(hit) => !hit.igdbId} onPick={(hit) => {
|
|
||||||
onPick(hit);
|
|
||||||
setSearching(false);
|
|
||||||
}}/>
|
|
||||||
)}
|
|
||||||
{choice.candidates.length === 0 ? (
|
|
||||||
<p className="text-sm text-muted-foreground">{t('IMPORT.NO_MATCH')}</p>
|
|
||||||
) : (
|
|
||||||
<div className="grid gap-2 sm:grid-cols-[2fr_1fr_1fr_6rem]">
|
|
||||||
<select className={SELECT_CLASS} aria-label={t('IMPORT.MATCH', {line: row.lineNo})} value={choice.igdbId ?? ''}
|
|
||||||
onChange={(e) => onPick(choice.candidates.find((known) => String(known.igdbId) === e.target.value))}>
|
|
||||||
{choice.candidates.map((known) => (
|
|
||||||
<option key={known.igdbId} value={known.igdbId}>
|
|
||||||
{known.name}{known.year ? ` (${known.year})` : ''}{known.inLibrary ? ` · ${t('MANUAL_GAME.IN_LIBRARY')}` : ''}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
{candidate?.platforms.length > 0 ? (
|
|
||||||
<select className={SELECT_CLASS} aria-label={t('IMPORT.PLATFORM', {line: row.lineNo})} value={choice.platform}
|
|
||||||
onChange={(e) => onChange({platform: e.target.value})}>
|
|
||||||
<option value="">{t('IMPORT.PICK_PLATFORM')}</option>
|
|
||||||
{!candidate.platforms.includes(choice.platform) && choice.platform && (
|
|
||||||
<option value={choice.platform}>{choice.platform}</option>
|
|
||||||
)}
|
|
||||||
{candidate.platforms.map((platform) => <option key={platform} value={platform}>{platform}</option>)}
|
|
||||||
</select>
|
|
||||||
) : (
|
|
||||||
<Input value={choice.platform} maxLength={MAX_PLATFORM_LENGTH} aria-label={t('IMPORT.PLATFORM', {line: row.lineNo})}
|
|
||||||
onChange={(e) => onChange({platform: e.target.value})}/>
|
|
||||||
)}
|
|
||||||
<select className={SELECT_CLASS} aria-label={t('IMPORT.STATUS_LABEL', {line: row.lineNo})} value={choice.status}
|
|
||||||
onChange={(e) => onChange({status: e.target.value})}>
|
|
||||||
{GAME_STATUSES.map((status) => <option key={status} value={status}>{t(`BACKLOG.STATUS.${status}`)}</option>)}
|
|
||||||
</select>
|
|
||||||
<Input type="number" min="0" step="0.5" inputMode="decimal" value={choice.hours}
|
|
||||||
aria-label={t('IMPORT.HOURS', {line: row.lineNo})} onChange={(e) => onChange({hours: e.target.value})}/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
function History({imports, onOpen, onChanged, expireSession}) {
|
|
||||||
const {t, i18n} = useTranslation();
|
|
||||||
const [confirm, setConfirm] = useState(null);
|
|
||||||
const [busy, setBusy] = useState(false);
|
|
||||||
|
|
||||||
const run = async (call, onOk) => {
|
|
||||||
setBusy(true);
|
|
||||||
try {
|
|
||||||
const response = await call();
|
|
||||||
if (response.status === 401) {
|
|
||||||
expireSession();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!response.ok) {
|
|
||||||
toast.error(t('IMPORT.UNDO_FAILED'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await onOk(response);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Import undo error:', err);
|
|
||||||
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
|
|
||||||
} finally {
|
|
||||||
setBusy(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const askUndo = (id) => run(() => checkUndo(id), async (response) => setConfirm({id, ...await response.json()}));
|
|
||||||
const undo = () => run(() => undoImport(confirm.id), () => {
|
|
||||||
toast.success(t('IMPORT.UNDONE', {count: confirm.games}));
|
|
||||||
setConfirm(null);
|
|
||||||
onChanged();
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card className="gap-0 p-0">
|
|
||||||
<h2 className="border-b px-5 py-3 font-semibold">{t('IMPORT.HISTORY')}</h2>
|
|
||||||
<ul className="divide-y">
|
|
||||||
{imports.map((entry) => (
|
|
||||||
<li key={entry.id} className="flex flex-wrap items-center gap-3 px-5 py-3 text-sm">
|
|
||||||
<span className="tabular-nums text-muted-foreground">
|
|
||||||
{new Date(entry.createdAt).toLocaleDateString(i18n.language)}
|
|
||||||
</span>
|
|
||||||
<span className="flex-1">
|
|
||||||
{t(`IMPORT.SOURCE_${entry.source}`)} · {t('IMPORT.LINES', {count: entry.total})}
|
|
||||||
</span>
|
|
||||||
<Badge variant={entry.status === 'FAILED' ? 'destructive' : 'outline'}>{t(`IMPORT.STATUS.${entry.status}`)}</Badge>
|
|
||||||
{entry.status !== 'SAVED' && entry.status !== 'UNDONE' && (
|
|
||||||
<Button variant="outline" size="sm" onClick={() => onOpen(entry.id)}>{t('IMPORT.OPEN_IMPORT')}</Button>
|
|
||||||
)}
|
|
||||||
{(entry.status === 'SAVED' || (entry.status === 'FAILED' && entry.added > 0))
|
|
||||||
&& confirm?.id !== entry.id && (
|
|
||||||
<Button variant="outline" size="sm" disabled={busy} onClick={() => askUndo(entry.id)}>
|
|
||||||
<RotateCcw/>{t('IMPORT.UNDO')}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{confirm?.id === entry.id && (
|
|
||||||
<div className="flex w-full flex-wrap items-center gap-2">
|
|
||||||
<span>{t('IMPORT.UNDO_CONFIRM', {count: confirm.games, changed: confirm.changed})}</span>
|
|
||||||
<Button variant="destructive" size="sm" disabled={busy} onClick={undo}>{t('IMPORT.UNDO_YES')}</Button>
|
|
||||||
<Button variant="ghost" size="sm" disabled={busy} onClick={() => setConfirm(null)}>
|
|
||||||
{t('MANUAL_GAME.REMOVE_NO')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Loaded 100 of 133 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user