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
|
||||
- Production image: nginx (Alpine), SPA fallback, long-term caching for hashed assets, Content Security Policy
|
||||
and security headers
|
||||
- CI: Forgejo Actions (`npm ci`, `npm test`, `npm run build`, Docker image)
|
||||
- ESLint with the React hooks rules (`npm run lint`)
|
||||
- CI: Forgejo Actions (`npm ci`, `npm run lint`, `npm test`, `npm run build`, Docker image)
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -127,8 +128,19 @@ flowchart LR
|
||||
- Calls for the logged-in user go through `src/utils/api.js`. `clientFetch` sends the HttpOnly cookies and the
|
||||
`X-TBP-Client` header. `apiFetch` answers a `401` with one `POST /api/auth/refresh` and sends the request again.
|
||||
Parallel requests and other tabs share this refresh (`navigator.locks`). When the second answer is still a
|
||||
`401`, the page calls `useSessionExpired`, which opens the login page. Public pages (landing page, public
|
||||
profile) and the public `GET /api/version` in the settings use plain `fetch`.
|
||||
`401`, `apiFetch` ends the session (`expireSession`); the pages only stop. `request()` wraps `apiFetch` as
|
||||
`{ok, status, data}` and never throws. Public pages (landing page, public profile) and the public
|
||||
`GET /api/version` in the settings use plain `fetch`.
|
||||
- `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
|
||||
detail page) to Core.
|
||||
- `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
|
||||
src/
|
||||
├── pages/ # One component per route (Dashboard, GameLibrary, GameDetail, ...)
|
||||
├── components/ # Shared components; ui/ holds the base components
|
||||
├── hooks/ # Data hooks (library sync, Steam profile, appearance, session expiry)
|
||||
├── pages/ # One component per route (Dashboard, GameLibrary, GameDetail, ...): loads data, places cards
|
||||
├── components/ # Cards and shared components; ui/ holds the base components, import/ and stats/ the parts
|
||||
│ # of one page that share page-only code
|
||||
├── hooks/ # Data hooks (game, dashboard, library sync, Steam profile, appearance, session)
|
||||
├── context/ # PreferencesContext: theme, language, appearance
|
||||
├── utils/ # Validation schemas and helpers
|
||||
└── test/ # Test setup (i18n mock)
|
||||
@@ -191,6 +204,7 @@ browser bundle, so they must never hold a secret.
|
||||
## Tests and build
|
||||
|
||||
```bash
|
||||
npm run lint
|
||||
npm test
|
||||
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",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run"
|
||||
"test": "vitest run",
|
||||
"lint": "eslint ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/inter": "^5.3.0",
|
||||
@@ -29,10 +30,14 @@
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@testing-library/dom": "^10.4.2",
|
||||
"@testing-library/react": "^16.3.3",
|
||||
"@vitejs/plugin-react": "^6.1.1",
|
||||
"autoprefixer": "~10.6.1",
|
||||
"eslint": "^10.11.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"globals": "^17.12.0",
|
||||
"jsdom": "^30.1.1",
|
||||
"postcss": "~8.5.28",
|
||||
"tailwindcss": "~4.3.3",
|
||||
|
||||
@@ -205,6 +205,7 @@
|
||||
"SYNCING": "Synchronisiere mit Steam …",
|
||||
"SYNCING_FIRST_TIME": "Erste Synchronisation mit Steam läuft. Deine Spiele erscheinen gleich.",
|
||||
"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}}",
|
||||
"FILTER_ALL": "Alle",
|
||||
"SEARCH_PLACEHOLDER": "Spiel suchen …",
|
||||
@@ -654,6 +655,8 @@
|
||||
"SAVE": "{{count}} Spiele hinzufügen",
|
||||
"SAVE_FAILED": "Der Import konnte nicht gespeichert werden.",
|
||||
"NEW": "Neuer Import",
|
||||
"SELECT_ALL": "Alle auswählen",
|
||||
"SELECT_NONE": "Alle abwählen",
|
||||
"LINE": "Zeile {{line}}",
|
||||
"SEARCH_FAILED": "Suche fehlgeschlagen",
|
||||
"SEARCH_AGAIN": "Neu suchen",
|
||||
@@ -703,5 +706,8 @@
|
||||
"RETRY": "Nochmal versuchen",
|
||||
"RETRY_FAILED": "Der Import konnte nicht neu gestartet werden.",
|
||||
"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_FIRST_TIME": "First sync with Steam in progress. Your games will appear here shortly.",
|
||||
"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}}",
|
||||
"FILTER_ALL": "All",
|
||||
"SEARCH_PLACEHOLDER": "Search games …",
|
||||
@@ -654,6 +655,8 @@
|
||||
"SAVE": "Add {{count}} games",
|
||||
"SAVE_FAILED": "Could not save the import.",
|
||||
"NEW": "New import",
|
||||
"SELECT_ALL": "Select all",
|
||||
"SELECT_NONE": "Deselect all",
|
||||
"LINE": "Line {{line}}",
|
||||
"SEARCH_FAILED": "Search failed",
|
||||
"SEARCH_AGAIN": "Search again",
|
||||
@@ -703,5 +706,8 @@
|
||||
"RETRY": "Try again",
|
||||
"RETRY_FAILED": "Could not start the import again.",
|
||||
"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?"
|
||||
}
|
||||
}
|
||||
+11
-13
@@ -1,6 +1,6 @@
|
||||
import {Link, Route, Routes, useLocation, useNavigate} from 'react-router-dom';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {useEffect, useState} from 'react';
|
||||
import {useEffect} from 'react';
|
||||
|
||||
import LandingPage from './pages/LandingPage';
|
||||
import LoginPage from './pages/LoginPage';
|
||||
@@ -18,28 +18,25 @@ import StatsPage from './pages/StatsPage.jsx';
|
||||
import ImportPage from './pages/ImportPage.jsx';
|
||||
import PublicProfilePage from './pages/PublicProfilePage.jsx';
|
||||
import AppHeader from './components/AppHeader.jsx';
|
||||
import RequireSession from './components/RequireSession.jsx';
|
||||
import {Toaster} from '@/components/ui/sonner';
|
||||
import {TooltipProvider} from '@/components/ui/tooltip';
|
||||
import PageBackground from './components/PageBackground.jsx';
|
||||
import {usePreferences} from './context/PreferencesContext.jsx';
|
||||
import {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';
|
||||
|
||||
function App() {
|
||||
const {t} = useTranslation();
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const {appearance, syncPreferences, resetAppearance} = usePreferences();
|
||||
const {active: isAuthenticated} = useSession();
|
||||
const {appearance, syncPreferences} = usePreferences();
|
||||
|
||||
// Hooks for routing
|
||||
const navigate = useNavigate();
|
||||
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.
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) syncPreferences();
|
||||
@@ -49,8 +46,6 @@ function App() {
|
||||
const handleLogout = async () => {
|
||||
await logout();
|
||||
clearSession();
|
||||
resetAppearance();
|
||||
setIsAuthenticated(false);
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
@@ -67,6 +62,7 @@ function App() {
|
||||
<Route path="/" element={<LandingPage/>}/>
|
||||
<Route path="/login" element={<LoginPage/>}/>
|
||||
<Route path="/register" element={<RegistrationPage/>}/>
|
||||
<Route element={<RequireSession/>}>
|
||||
<Route path="/dashboard" element={<Dashboard/>}/>
|
||||
<Route path="/library" element={<GameLibrary/>}/>
|
||||
<Route path="/library/:appid" element={<GameDetail/>}/>
|
||||
@@ -74,10 +70,11 @@ function App() {
|
||||
<Route path="/play-next" element={<PlayNextPage/>}/>
|
||||
<Route path="/stats" element={<StatsPage/>}/>
|
||||
<Route path="/import" element={<ImportPage/>}/>
|
||||
<Route path="/u/:name" element={<PublicProfilePage/>}/>
|
||||
<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="/impressum" element={<ImprintPage/>}/>
|
||||
</Routes>
|
||||
</main>
|
||||
@@ -88,6 +85,7 @@ function App() {
|
||||
</Link>
|
||||
</footer>
|
||||
</div>
|
||||
<UnsavedChangesGuard/>
|
||||
<Toaster richColors position="bottom-right"/>
|
||||
</TooltipProvider>
|
||||
);
|
||||
|
||||
+16
-19
@@ -1,9 +1,9 @@
|
||||
// @vitest-environment jsdom
|
||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
import {act, cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
|
||||
import {MemoryRouter, useLocation} from 'react-router-dom';
|
||||
import {act, cleanup, fireEvent, screen, waitFor} from '@testing-library/react';
|
||||
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('./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 current = {location: null};
|
||||
|
||||
function Spy() {
|
||||
current.location = useLocation();
|
||||
return null;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.setItem('tbp_session', '1');
|
||||
globalThis.fetch = vi.fn(async () => ({ok: true, status: 200, json: async () => page}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -33,19 +25,24 @@ afterEach(() => {
|
||||
|
||||
describe('App', () => {
|
||||
it('ends the session on a 401 that the refresh cannot fix', async () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/library']}>
|
||||
<PreferencesProvider><App/><Spy/></PreferencesProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
let expired = false;
|
||||
const library = () => (expired ? json(401, {}) : json(200, page));
|
||||
fetchRoutes({
|
||||
'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());
|
||||
|
||||
fetch.mockImplementation(async () => ({ok: false, status: 401, json: async () => ({})}));
|
||||
expired = true;
|
||||
fireEvent.change(screen.getByRole('searchbox'), {target: {value: 'half'}});
|
||||
await wait(DEBOUNCE_WAIT_MS);
|
||||
|
||||
await waitFor(() => expect(current.location.pathname).toBe('/login'));
|
||||
expect(current.location.state).toEqual({sessionExpired: true, from: '/library?q=half&page=0'});
|
||||
await waitFor(() => expect(router.state.location.pathname).toBe('/login'));
|
||||
expect(router.state.location.state).toEqual({sessionExpired: true, from: '/library?q=half&page=0'});
|
||||
expect(localStorage.getItem('tbp_session')).toBeNull();
|
||||
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 GameCover from './GameCover.jsx';
|
||||
import GameSearch from './GameSearch.jsx';
|
||||
import useSessionExpired from '../hooks/useSessionExpired.js';
|
||||
import useStartImport from '../hooks/useStartImport.js';
|
||||
import {GAME_STATUSES, MAX_PLATFORM_LENGTH} from '../utils/limits.js';
|
||||
import {addGame, hoursToMinutes} from '../utils/manualGames.js';
|
||||
import {loadFavoritePlatform, preselectPlatform} from '../utils/platforms.js';
|
||||
import {preselectPlatform} from '../utils/platforms.js';
|
||||
import {useCurrentUserContext} from '../context/CurrentUserContext.jsx';
|
||||
|
||||
/**
|
||||
* Search IGDB and add a game the user owns outside Steam, e.g. on PlayStation or Switch; or check several and review
|
||||
@@ -24,16 +24,12 @@ import {loadFavoritePlatform, preselectPlatform} from '../utils/platforms.js';
|
||||
export default function AddGamePanel({onClose}) {
|
||||
const {t} = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const expireSession = useSessionExpired();
|
||||
const [query, setQuery] = useState('');
|
||||
const [picked, setPicked] = useState(null);
|
||||
// igdbId -> hit, over several searches.
|
||||
const [selected, setSelected] = useState(() => new Map());
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [favorite, setFavorite] = useState(null);
|
||||
useEffect(() => {
|
||||
loadFavoritePlatform().then(setFavorite);
|
||||
}, []);
|
||||
const favorite = useCurrentUserContext().user?.settings?.favoritePlatform ?? null;
|
||||
const startImport = useStartImport();
|
||||
|
||||
const toggle = (hit) => {
|
||||
@@ -64,8 +60,7 @@ export default function AddGamePanel({onClose}) {
|
||||
</div>
|
||||
|
||||
{picked ? (
|
||||
<AddForm hit={picked} favorite={favorite} onBack={() => setPicked(null)} navigate={navigate}
|
||||
expireSession={expireSession}/>
|
||||
<AddForm hit={picked} favorite={favorite} onBack={() => setPicked(null)} navigate={navigate}/>
|
||||
) : (
|
||||
<>
|
||||
<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 [platform, setPlatform] = useState(() => preselectPlatform(null, hit.platforms, favorite));
|
||||
// The favorite can arrive after the pick; it fills only a platform the user has not picked yet.
|
||||
@@ -103,10 +98,7 @@ function AddForm({hit, favorite, onBack, navigate, expireSession}) {
|
||||
const response = await addGame({
|
||||
igdbId: hit.igdbId, platform: platform.trim(), status, playtimeMinutes: hoursToMinutes(hours),
|
||||
});
|
||||
if (response.status === 401) {
|
||||
expireSession();
|
||||
return;
|
||||
}
|
||||
if (response.status === 401) return;
|
||||
if (response.status === 409) {
|
||||
toast.error(t('MANUAL_GAME.ALREADY_IN_LIBRARY'));
|
||||
return;
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
// @vitest-environment jsdom
|
||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
|
||||
import {MemoryRouter, Route, Routes} from 'react-router-dom';
|
||||
import {cleanup, fireEvent, screen, waitFor} from '@testing-library/react';
|
||||
import {toast} from 'sonner';
|
||||
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('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 json = (status, body) => ({ok: status < 400, status, json: async () => body});
|
||||
|
||||
function renderPanel(post = json(201, {appid: -1025}), favoritePlatform = 'Nintendo Switch') {
|
||||
globalThis.fetch = vi.fn(async (url, init) => {
|
||||
if (url.includes('/api/users/me')) return json(200, {role: 'USER', settings: {favoritePlatform}});
|
||||
return init?.method === 'POST' ? post : json(200, {available: true, results: [zelda, portal]});
|
||||
function renderPanel(post = json(201, {appid: -1025}), favoritePlatform = 'Nintendo Switch',
|
||||
found = json(200, {available: true, results: [zelda, portal]})) {
|
||||
fetchRoutes({
|
||||
'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(
|
||||
<MemoryRouter initialEntries={['/library']}>
|
||||
<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>,
|
||||
);
|
||||
renderWithProviders(<AddGamePanel onClose={() => {}}/>, {path: '/library', entries: ['/library'],
|
||||
routes: {'/library/:appid': <p>detail page</p>, '/import': <p>import page</p>}});
|
||||
}
|
||||
|
||||
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 () => {
|
||||
renderPanel();
|
||||
fetch.mockImplementation(async (url) => (url.includes('/api/users/me')
|
||||
? json(200, {settings: {}}) : json(200, {available: false, results: []})));
|
||||
renderPanel(undefined, undefined, json(200, {available: false, results: []}));
|
||||
search('zelda');
|
||||
|
||||
expect(await screen.findByText('MANUAL_GAME.UNAVAILABLE')).toBeTruthy();
|
||||
|
||||
@@ -21,10 +21,9 @@ import Logo from './Logo.jsx';
|
||||
import PageContainer from './PageContainer.jsx';
|
||||
import SteamAvatar from './SteamAvatar.jsx';
|
||||
import useCurrentUser from '../hooks/useCurrentUser.js';
|
||||
import useSessionExpired from '../hooks/useSessionExpired.js';
|
||||
import useSteamProfile from '../hooks/useSteamProfile.js';
|
||||
import {ROLES, viewAs} from '../utils/admin.js';
|
||||
import {markSession} from '../utils/auth.js';
|
||||
import {expireSession, markSession} from '../utils/auth.js';
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{to: '/dashboard', labelKey: 'NAV.DASHBOARD', Icon: LayoutDashboard},
|
||||
@@ -48,7 +47,6 @@ export default function AppHeader({isAuthenticated, onLogout}) {
|
||||
const steamProfile = useSteamProfile(isAuthenticated);
|
||||
const currentUser = useCurrentUser(isAuthenticated);
|
||||
const navigate = useNavigate();
|
||||
const expireSession = useSessionExpired();
|
||||
const isAdmin = currentUser?.role === 'PLATFORM_ADMIN';
|
||||
const viewingAs = currentUser && currentUser.activeRole !== currentUser.role ? currentUser.activeRole : null;
|
||||
|
||||
@@ -56,7 +54,7 @@ export default function AppHeader({isAuthenticated, onLogout}) {
|
||||
try {
|
||||
const response = await viewAs(role === currentUser.role ? null : role);
|
||||
if (response.ok) {
|
||||
// New cookies with the other role; the new stamp makes the header and pages load again.
|
||||
// New cookies with the other role; the new stamp loads the user and the Steam data again.
|
||||
markSession();
|
||||
navigate('/dashboard');
|
||||
return;
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
// @vitest-environment jsdom
|
||||
import {afterEach, describe, expect, it, vi} from 'vitest';
|
||||
import {cleanup, fireEvent, render, screen} from '@testing-library/react';
|
||||
import {MemoryRouter} from 'react-router-dom';
|
||||
import {cleanup, fireEvent, screen} from '@testing-library/react';
|
||||
import {toast} from 'sonner';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
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('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
|
||||
@@ -16,26 +16,18 @@ afterEach(() => {
|
||||
useTranslation().i18n.language = 'de';
|
||||
});
|
||||
|
||||
const loggedInAs = (me) => {
|
||||
const loggedInAs = (me, routes = {}) => {
|
||||
localStorage.setItem('tbp_session', '1');
|
||||
globalThis.fetch = vi.fn(async (url) => ({
|
||||
ok: true, status: 200, json: async () => (url.includes('/api/users/me') ? me : {}),
|
||||
}));
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<PreferencesProvider><AppHeader isAuthenticated onLogout={() => {}}/></PreferencesProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
fetchRoutes({'GET /api/users/me': json(200, me), 'GET /api/library/steam-profile': json(200, {}), ...routes});
|
||||
renderWithProviders(<AppHeader isAuthenticated onLogout={() => {}}/>);
|
||||
};
|
||||
|
||||
const openMenu = () => fireEvent.keyDown(screen.getByRole('button', {name: 'NAV.MENU'}), {key: 'Enter'});
|
||||
|
||||
describe('AppHeader', () => {
|
||||
it('says so when Auth refuses the language switch', async () => {
|
||||
loggedInAs({role: 'USER', activeRole: 'USER', settings: {language: 'de'}});
|
||||
fetch.mockImplementation(async (url, init) => (init?.method === 'PUT'
|
||||
? {ok: false, status: 500, json: async () => ({})}
|
||||
: {ok: true, status: 200, json: async () => ({role: 'USER', activeRole: 'USER', settings: {language: 'de'}})}));
|
||||
loggedInAs({role: 'USER', activeRole: 'USER', settings: {language: 'de'}},
|
||||
{'PUT /api/users/settings': json(500, {})});
|
||||
|
||||
await screen.findByRole('button', {name: 'NAV.MENU'});
|
||||
openMenu();
|
||||
@@ -46,11 +38,8 @@ describe('AppHeader', () => {
|
||||
|
||||
// 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', () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<PreferencesProvider><AppHeader isAuthenticated={false} onLogout={() => {}}/></PreferencesProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
fetchRoutes({});
|
||||
renderWithProviders(<AppHeader isAuthenticated={false} onLogout={() => {}}/>);
|
||||
screen.getByText('ToBePlayed').style.display = 'none';
|
||||
|
||||
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 {useNavigate} from 'react-router-dom';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {toast} from 'sonner';
|
||||
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 {cn} from '@/lib/utils';
|
||||
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 useSessionExpired from '../hooks/useSessionExpired.js';
|
||||
import {hasSession} from '../utils/auth.js';
|
||||
import useUnsavedChanges from '../hooks/useUnsavedChanges.js';
|
||||
|
||||
const toForm = (entry) => ({
|
||||
status: entry.status ?? 'BACKLOG',
|
||||
@@ -25,8 +23,6 @@ const toForm = (entry) => ({
|
||||
|
||||
export default function BacklogCard({game, onSaved}) {
|
||||
const {t} = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const expireSession = useSessionExpired();
|
||||
const [saved, setSaved] = useState(() => toForm(game));
|
||||
const [form, setForm] = useState(saved);
|
||||
|
||||
@@ -37,21 +33,16 @@ export default function BacklogCard({game, onSaved}) {
|
||||
}, [game.status]);
|
||||
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 save = async () => {
|
||||
if (!hasSession()) {
|
||||
navigate('/login');
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
const response = await saveBacklogEntry(game.appid, toBacklogRequest(form));
|
||||
if (response.status === 401) {
|
||||
expireSession();
|
||||
return;
|
||||
}
|
||||
const response = await saveBacklogEntry(game.appid, changes);
|
||||
if (response.status === 401) return;
|
||||
if (!response.ok) {
|
||||
toast.error(t('BACKLOG.SAVE_FAILED'));
|
||||
return;
|
||||
@@ -77,6 +68,8 @@ export default function BacklogCard({game, onSaved}) {
|
||||
{t('BACKLOG.TITLE')}
|
||||
</h2>
|
||||
|
||||
{/* Locked while saving: the answer replaces the form, so an edit made meanwhile would be lost. */}
|
||||
<fieldset disabled={busy} className="contents">
|
||||
<div className="grid gap-5 sm:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="backlog-status">{t('BACKLOG.STATUS_LABEL')}</Label>
|
||||
@@ -143,6 +136,7 @@ export default function BacklogCard({game, onSaved}) {
|
||||
onChange={(e) => set('notes')(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="ghost" onClick={() => setForm(saved)} disabled={!dirty || busy}>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// @vitest-environment jsdom
|
||||
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 {fetchRoutes, json} from '../test/fetchRoutes.js';
|
||||
import BacklogCard from './BacklogCard.jsx';
|
||||
|
||||
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||
@@ -13,7 +14,8 @@ const saveButton = () => screen.getByRole('button', {name: 'BACKLOG.SAVE'});
|
||||
describe('BacklogCard', () => {
|
||||
beforeEach(() => {
|
||||
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(() => {
|
||||
@@ -41,8 +43,44 @@ describe('BacklogCard', () => {
|
||||
await waitFor(() => expect(onSaved).toHaveBeenCalled());
|
||||
const [url, init] = fetch.mock.calls[0];
|
||||
expect(url).toMatch(/\/api\/library\/games\/220\/backlog$/);
|
||||
expect(init.method).toBe('PUT');
|
||||
expect(JSON.parse(init.body)).toEqual({status: 'PLAYING', rating: null, notes: null, priority: null});
|
||||
expect(init.method).toBe('PATCH');
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,6 @@ import {Loader2, Trophy} from 'lucide-react';
|
||||
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {Card} from '@/components/ui/card';
|
||||
import useSessionExpired from '../hooks/useSessionExpired.js';
|
||||
import {saveGameStatus} from '../utils/backlog.js';
|
||||
import {allAchievementsUnlocked} from '../utils/confetti.js';
|
||||
|
||||
@@ -16,7 +15,6 @@ import {allAchievementsUnlocked} from '../utils/confetti.js';
|
||||
*/
|
||||
export default function CompletedHint({game, onChanged}) {
|
||||
const {t} = useTranslation();
|
||||
const expireSession = useSessionExpired();
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
if (!allAchievementsUnlocked(game) || game.status === 'COMPLETED') return null;
|
||||
@@ -25,10 +23,7 @@ export default function CompletedHint({game, onChanged}) {
|
||||
setBusy(true);
|
||||
try {
|
||||
const response = await saveGameStatus(game.appid, 'COMPLETED');
|
||||
if (response.status === 401) {
|
||||
expireSession();
|
||||
return;
|
||||
}
|
||||
if (response.status === 401) return;
|
||||
if (!response.ok) {
|
||||
toast.error(t('BACKLOG.SAVE_FAILED'));
|
||||
return;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
|
||||
import {MemoryRouter} from 'react-router-dom';
|
||||
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
||||
import CompletedHint from './CompletedHint.jsx';
|
||||
|
||||
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||
@@ -13,9 +14,8 @@ const renderHint = (props) => render(<MemoryRouter><CompletedHint game={game} on
|
||||
describe('CompletedHint', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.setItem('tbp_session', '1');
|
||||
globalThis.fetch = vi.fn(async (url, init) => ({
|
||||
ok: true, status: 200, json: async () => ({...JSON.parse(init.body), rating: 5, notes: 'keep', priority: null}),
|
||||
}));
|
||||
fetchRoutes({'PUT /api/library/games/220/status': (url, init) =>
|
||||
json(200, {...JSON.parse(init.body), rating: 5, notes: 'keep', priority: null})});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -49,6 +49,9 @@ function pieces(size, width, height) {
|
||||
export default function Confetti({size, onDone}) {
|
||||
const canvasRef = useRef(null);
|
||||
const [reduce] = useState(reducedMotion);
|
||||
// In a ref: a new function from a re-render must not restart the animation, and the latest one runs at the end.
|
||||
const onDoneRef = useRef(onDone);
|
||||
onDoneRef.current = onDone;
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
@@ -67,7 +70,7 @@ export default function Confetti({size, onDone}) {
|
||||
context.setTransform(1, 0, 0, 1, 0, 0);
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
if (elapsed >= DURATION_MS) {
|
||||
onDone?.();
|
||||
onDoneRef.current?.();
|
||||
return;
|
||||
}
|
||||
context.globalAlpha = 1 - elapsed / DURATION_MS;
|
||||
@@ -88,7 +91,6 @@ export default function Confetti({size, onDone}) {
|
||||
};
|
||||
frame = requestAnimationFrame(draw);
|
||||
return () => cancelAnimationFrame(frame);
|
||||
// Not on onDone: a new function from a re-render must not restart the animation.
|
||||
}, [size]);
|
||||
|
||||
if (reduce) return null;
|
||||
|
||||
@@ -4,6 +4,7 @@ import {act, cleanup, fireEvent, render, screen} from '@testing-library/react';
|
||||
import {toast} from 'sonner';
|
||||
import DashboardHint, {AUTO_CLOSE_MS} from './DashboardHint.jsx';
|
||||
import {hintText} from '../utils/hints.js';
|
||||
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
||||
|
||||
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||
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 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 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/>);
|
||||
await act(async () => undefined);
|
||||
};
|
||||
@@ -88,7 +91,7 @@ describe('DashboardHint', () => {
|
||||
});
|
||||
|
||||
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/>);
|
||||
await act(async () => undefined);
|
||||
|
||||
@@ -109,7 +112,7 @@ describe('DashboardHint', () => {
|
||||
});
|
||||
|
||||
it('shows nothing when the hints do not load', async () => {
|
||||
globalThis.fetch = vi.fn(async () => answer(500));
|
||||
hintRoutes(json(500));
|
||||
render(<DashboardHint/>);
|
||||
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';
|
||||
import {Input} from '@/components/ui/input';
|
||||
import {Label} from '@/components/ui/label';
|
||||
import {useResetAppearance} from '../context/PreferencesContext.jsx';
|
||||
import useSessionExpired from '../hooks/useSessionExpired.js';
|
||||
import {deleteAccount} from '../utils/account.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}) {
|
||||
const {t} = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const resetAppearance = useResetAppearance();
|
||||
const expireSession = useSessionExpired();
|
||||
const [typedEmail, setTypedEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -43,20 +39,19 @@ export default function DeleteAccountCard({email}) {
|
||||
try {
|
||||
const response = await deleteAccount(password);
|
||||
if (response.status === 204) {
|
||||
clearSession();
|
||||
resetAppearance();
|
||||
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;
|
||||
}
|
||||
if (response.status === 401) {
|
||||
expireSession();
|
||||
} else if (response.status === 403) {
|
||||
if (response.status === 403) {
|
||||
toast.error(t('SETTINGS.DELETE_ACCOUNT.WRONG_PASSWORD'));
|
||||
} else if (response.status === 429) {
|
||||
const minutes = Math.ceil(Number(response.headers.get('Retry-After') ?? 900) / 60);
|
||||
toast.error(t('SETTINGS.DELETE_ACCOUNT.TOO_MANY', {minutes}));
|
||||
} else {
|
||||
} else if (response.status !== 401) {
|
||||
toast.error(t('SETTINGS.DELETE_ACCOUNT.ERROR'));
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
// @vitest-environment jsdom
|
||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
|
||||
import {MemoryRouter, Route, Routes} from 'react-router-dom';
|
||||
import {createMemoryRouter, RouterProvider} from 'react-router-dom';
|
||||
import RequireSession from './RequireSession.jsx';
|
||||
import {toast} from 'sonner';
|
||||
import DeleteAccountCard from './DeleteAccountCard.jsx';
|
||||
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
||||
|
||||
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
|
||||
|
||||
const 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(
|
||||
<MemoryRouter initialEntries={['/settings']}>
|
||||
<Routes>
|
||||
<Route path="/settings" element={<DeleteAccountCard email={EMAIL}/>}/>
|
||||
<Route path="/" element={<p>landing</p>}/>
|
||||
<Route path="/login" element={<p>login</p>}/>
|
||||
</Routes>
|
||||
</MemoryRouter>);
|
||||
// A data router like main.jsx, without the providers: their /me load would change the request order.
|
||||
const renderCard = () => render(<RouterProvider router={createMemoryRouter([
|
||||
{element: <RequireSession/>, children: [{path: '/settings', element: <DeleteAccountCard email={EMAIL}/>}]},
|
||||
{path: '/', element: <p>landing</p>},
|
||||
{path: '/login', element: <p>login</p>},
|
||||
], {initialEntries: ['/settings']})}/>);
|
||||
|
||||
const openAndFill = (email, password) => {
|
||||
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', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.setItem('tbp_session', '1');
|
||||
globalThis.fetch = vi.fn(async () => answer(204));
|
||||
deleteAnswers(json(204, {}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -67,7 +67,7 @@ describe('DeleteAccountCard', () => {
|
||||
});
|
||||
|
||||
it('keeps the dialog open on a wrong password', async () => {
|
||||
fetch.mockResolvedValue(answer(403));
|
||||
deleteAnswers(json(403, {}));
|
||||
renderCard();
|
||||
openAndFill(EMAIL, 'wrong');
|
||||
|
||||
@@ -79,7 +79,7 @@ describe('DeleteAccountCard', () => {
|
||||
});
|
||||
|
||||
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();
|
||||
openAndFill(EMAIL, 'wrong');
|
||||
|
||||
@@ -89,7 +89,7 @@ describe('DeleteAccountCard', () => {
|
||||
});
|
||||
|
||||
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();
|
||||
openAndFill(EMAIL, 'secret');
|
||||
|
||||
@@ -99,7 +99,9 @@ describe('DeleteAccountCard', () => {
|
||||
});
|
||||
|
||||
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();
|
||||
openAndFill(EMAIL, 'secret');
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import {Flag, Loader2} from 'lucide-react';
|
||||
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {Card} from '@/components/ui/card';
|
||||
import useSessionExpired from '../hooks/useSessionExpired.js';
|
||||
import {dismissFinishedHint, saveGameStatus} from '../utils/backlog.js';
|
||||
import {formatPlaytime} from '../utils/games.js';
|
||||
|
||||
@@ -17,7 +16,6 @@ import {formatPlaytime} from '../utils/games.js';
|
||||
*/
|
||||
export default function FinishedHint({game, onChanged, onDismissed}) {
|
||||
const {t} = useTranslation();
|
||||
const expireSession = useSessionExpired();
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const hint = game.finishedHint;
|
||||
@@ -28,10 +26,7 @@ export default function FinishedHint({game, onChanged, onDismissed}) {
|
||||
setBusy(true);
|
||||
try {
|
||||
const response = await request();
|
||||
if (response.status === 401) {
|
||||
expireSession();
|
||||
return;
|
||||
}
|
||||
if (response.status === 401) return;
|
||||
if (!response.ok) {
|
||||
toast.error(t('BACKLOG.SAVE_FAILED'));
|
||||
return;
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
|
||||
import {MemoryRouter, Route, Routes} from 'react-router-dom';
|
||||
import RequireSession from './RequireSession.jsx';
|
||||
import FinishedHint from './FinishedHint.jsx';
|
||||
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
||||
|
||||
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
|
||||
@@ -12,7 +14,9 @@ const ending = {appid: 322110, status: 'PLAYING', playtimeMinutes: 600, timeToBe
|
||||
const renderHint = (props) => render(
|
||||
<MemoryRouter initialEntries={['/library/322110']}>
|
||||
<Routes>
|
||||
<Route element={<RequireSession/>}>
|
||||
<Route path="/library/:appid" element={<FinishedHint game={ending} onChanged={() => {}} onDismissed={() => {}} {...props}/>}/>
|
||||
</Route>
|
||||
<Route path="/login" element={<p>login page</p>}/>
|
||||
</Routes>
|
||||
</MemoryRouter>);
|
||||
@@ -21,9 +25,11 @@ const puts = () => fetch.mock.calls.filter(([, init]) => init?.method === 'PUT')
|
||||
describe('FinishedHint', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.setItem('tbp_session', '1');
|
||||
globalThis.fetch = vi.fn(async (url, init) => (String(url).endsWith('/finished-hint')
|
||||
? {ok: true, status: 204}
|
||||
: {ok: true, status: 200, json: async () => ({...JSON.parse(init.body), rating: null, notes: null, priority: null})}));
|
||||
fetchRoutes({
|
||||
'PUT /api/library/games/322110/status': (url, init) =>
|
||||
json(200, {...JSON.parse(init.body), rating: null, notes: null, priority: null}),
|
||||
'PUT /api/library/games/322110/finished-hint': json(204),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -80,7 +86,10 @@ describe('FinishedHint', () => {
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
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 {Input} from '@/components/ui/input';
|
||||
import {Label} from '@/components/ui/label';
|
||||
import useSessionExpired from '../hooks/useSessionExpired.js';
|
||||
import {CORRECTABLE_MOODS, IGDB_GENRES, resetCorrection, saveCorrection} from '../utils/corrections.js';
|
||||
import {genreKey} from '../utils/stats.js';
|
||||
import {MAX_CORRECTION_GENRES, MAX_CORRECTION_LENGTH} from '../utils/limits.js';
|
||||
@@ -35,7 +34,6 @@ const toggled = (list, item) => (list.includes(item) ? list.filter((each) => eac
|
||||
*/
|
||||
export default function GameCorrectionDialog({game, open, onOpenChange, onChanged}) {
|
||||
const {t} = useTranslation();
|
||||
const expireSession = useSessionExpired();
|
||||
const [values, setValues] = useState(() => initialValues(game));
|
||||
const [busy, setBusy] = useState(null);
|
||||
// The field whose save or reset the next game from GameDetail answers; the other fields keep their edits.
|
||||
@@ -44,6 +42,7 @@ export default function GameCorrectionDialog({game, open, onOpenChange, onChange
|
||||
useEffect(() => {
|
||||
if (open) setValues(initialValues(game));
|
||||
// Only when the dialog opens; a reload after a save is handled below.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -61,10 +60,7 @@ export default function GameCorrectionDialog({game, open, onOpenChange, onChange
|
||||
setBusy(field);
|
||||
try {
|
||||
const response = await request();
|
||||
if (response.status === 401) {
|
||||
expireSession();
|
||||
return;
|
||||
}
|
||||
if (response.status === 401) return;
|
||||
if (response.ok) {
|
||||
toast.success(t('CORRECTION.SAVED'));
|
||||
sentField.current = field;
|
||||
|
||||
@@ -4,6 +4,7 @@ import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/reac
|
||||
import {MemoryRouter} from 'react-router-dom';
|
||||
import {toast} from 'sonner';
|
||||
import GameCorrectionDialog from './GameCorrectionDialog.jsx';
|
||||
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
||||
|
||||
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
|
||||
@@ -14,7 +15,11 @@ const game = {
|
||||
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()) => {
|
||||
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(() => {
|
||||
localStorage.setItem('tbp_session', '1');
|
||||
globalThis.fetch = vi.fn(async () => answer(204));
|
||||
corrections(json(204, {}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -75,7 +80,7 @@ describe('GameCorrectionDialog', () => {
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
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 {Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle} from '@/components/ui/dialog';
|
||||
import GameSearch from './GameSearch.jsx';
|
||||
import useSessionExpired from '../hooks/useSessionExpired.js';
|
||||
import {mergeGame} from '../utils/corrections.js';
|
||||
import {isSteamKey} from '../utils/manualGames.js';
|
||||
|
||||
@@ -18,7 +17,6 @@ const KNOWN_ERRORS = new Set(['SAME_GAME', 'STEAM_GAMES', 'GAME_NOT_FOUND']);
|
||||
*/
|
||||
export default function GameMergeDialog({game, open, onOpenChange, onMerged}) {
|
||||
const {t} = useTranslation();
|
||||
const expireSession = useSessionExpired();
|
||||
const [query, setQuery] = useState('');
|
||||
const [picked, setPicked] = useState(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -34,10 +32,7 @@ export default function GameMergeDialog({game, open, onOpenChange, onMerged}) {
|
||||
setBusy(true);
|
||||
try {
|
||||
const response = await mergeGame(game.appid, picked.appid);
|
||||
if (response.status === 401) {
|
||||
expireSession();
|
||||
return;
|
||||
}
|
||||
if (response.status === 401) return;
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (response.ok) {
|
||||
toast.success(t('MERGE.DONE'));
|
||||
|
||||
@@ -4,6 +4,7 @@ import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/reac
|
||||
import {MemoryRouter} from 'react-router-dom';
|
||||
import {toast} from 'sonner';
|
||||
import GameMergeDialog from './GameMergeDialog.jsx';
|
||||
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
||||
|
||||
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
|
||||
@@ -12,12 +13,11 @@ const hades = {appid: 1145360, name: 'Hades'};
|
||||
const hit = (appid, name) => ({appid, igdbId: Math.abs(appid), name, year: 2020, imageUrl: null,
|
||||
platforms: ['Switch'], inLibrary: false, inCatalog: true});
|
||||
|
||||
const json = (status, body) => ({ok: status < 400, status, json: async () => body});
|
||||
|
||||
function renderDialog(game, post, results) {
|
||||
globalThis.fetch = vi.fn(async (url, init) => init?.method === 'POST'
|
||||
? post
|
||||
: json(200, {available: true, results}));
|
||||
fetchRoutes({
|
||||
'GET /api/library/game-search': json(200, {available: true, results}),
|
||||
[`POST /api/library/games/${game.appid}/merge`]: post,
|
||||
});
|
||||
const onMerged = vi.fn();
|
||||
render(<MemoryRouter><GameMergeDialog game={game} open onOpenChange={vi.fn()} onMerged={onMerged}/></MemoryRouter>);
|
||||
return onMerged;
|
||||
|
||||
@@ -5,7 +5,6 @@ import {Loader2, Search} from 'lucide-react';
|
||||
import {Badge} from '@/components/ui/badge';
|
||||
import {Input} from '@/components/ui/input';
|
||||
import GameCover from './GameCover.jsx';
|
||||
import useSessionExpired from '../hooks/useSessionExpired.js';
|
||||
import {searchGames} from '../utils/manualGames.js';
|
||||
import {MAX_GAME_SEARCH_LENGTH, MIN_GAME_SEARCH_LENGTH} from '../utils/limits.js';
|
||||
|
||||
@@ -81,7 +80,6 @@ export default function GameSearch({query, onQueryChange, onPick, hide = () => f
|
||||
|
||||
// Asks Core 400 ms after the last key press; searchState is idle, loading, done, unavailable or failed.
|
||||
function useGameSearch(query) {
|
||||
const expireSession = useSessionExpired();
|
||||
const [searchState, setSearchState] = useState('idle');
|
||||
const [results, setResults] = useState([]);
|
||||
|
||||
@@ -97,10 +95,7 @@ function useGameSearch(query) {
|
||||
setSearchState('loading');
|
||||
try {
|
||||
const response = await searchGames(trimmed, controller.signal);
|
||||
if (response.status === 401) {
|
||||
expireSession();
|
||||
return;
|
||||
}
|
||||
if (response.status === 401) return;
|
||||
if (!response.ok) {
|
||||
setSearchState('failed');
|
||||
return;
|
||||
@@ -116,7 +111,7 @@ function useGameSearch(query) {
|
||||
clearTimeout(timer);
|
||||
controller.abort();
|
||||
};
|
||||
}, [query, expireSession]);
|
||||
}, [query]);
|
||||
|
||||
return {searchState, results};
|
||||
}
|
||||
@@ -8,9 +8,9 @@ import {Button} from '@/components/ui/button';
|
||||
import {Card} from '@/components/ui/card';
|
||||
import {Input} from '@/components/ui/input';
|
||||
import {Label} from '@/components/ui/label';
|
||||
import useSessionExpired from '../hooks/useSessionExpired.js';
|
||||
import {hoursToMinutes, removeGame, toHours, updateManualGame} from '../utils/manualGames.js';
|
||||
import {MAX_PLATFORM_LENGTH} from '../utils/limits.js';
|
||||
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.
|
||||
@@ -20,7 +20,6 @@ import {MAX_PLATFORM_LENGTH} from '../utils/limits.js';
|
||||
export default function ManualGameCard({game, onSaved}) {
|
||||
const {t} = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const expireSession = useSessionExpired();
|
||||
const [platform, setPlatform] = useState(game.platform ?? '');
|
||||
const [hours, setHours] = useState(toHours(game.playtimeMinutes));
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -28,15 +27,13 @@ export default function ManualGameCard({game, onSaved}) {
|
||||
|
||||
const playtimeMinutes = hoursToMinutes(hours);
|
||||
const dirty = platform.trim() !== (game.platform ?? '') || playtimeMinutes !== game.playtimeMinutes;
|
||||
useUnsavedChanges(dirty);
|
||||
|
||||
const run = async (request, onOk) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const response = await request();
|
||||
if (response.status === 401) {
|
||||
expireSession();
|
||||
return;
|
||||
}
|
||||
if (response.status === 401) return;
|
||||
if (!response.ok) {
|
||||
toast.error(t('BACKLOG.SAVE_FAILED'));
|
||||
return;
|
||||
@@ -57,6 +54,8 @@ export default function ManualGameCard({game, onSaved}) {
|
||||
|
||||
const remove = () => run(() => removeGame(game.appid), () => {
|
||||
toast.success(t('MANUAL_GAME.REMOVED'));
|
||||
// The game is gone, so are its unsaved edits on this page.
|
||||
discardChanges();
|
||||
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', () => {
|
||||
// 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}));
|
||||
|
||||
const container = renderContainer();
|
||||
|
||||
@@ -5,8 +5,7 @@ import {Gamepad2, Loader2} from 'lucide-react';
|
||||
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {Card} from '@/components/ui/card';
|
||||
import useSessionExpired from '../hooks/useSessionExpired.js';
|
||||
import {saveBacklogEntry} from '../utils/backlog.js';
|
||||
import {saveGameStatus} from '../utils/backlog.js';
|
||||
import {formatPlaytime} from '../utils/games.js';
|
||||
|
||||
/**
|
||||
@@ -16,7 +15,6 @@ import {formatPlaytime} from '../utils/games.js';
|
||||
*/
|
||||
export default function PlayingHint({game, onChanged}) {
|
||||
const {t} = useTranslation();
|
||||
const expireSession = useSessionExpired();
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
if (game.status !== 'BACKLOG' || !(game.playtime2Weeks > 0)) return null;
|
||||
@@ -24,13 +22,8 @@ export default function PlayingHint({game, onChanged}) {
|
||||
const setPlaying = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const response = await saveBacklogEntry(game.appid, {
|
||||
status: 'PLAYING', rating: game.rating ?? null, notes: game.notes ?? null, priority: game.priority ?? null,
|
||||
});
|
||||
if (response.status === 401) {
|
||||
expireSession();
|
||||
return;
|
||||
}
|
||||
const response = await saveGameStatus(game.appid, 'PLAYING');
|
||||
if (response.status === 401) return;
|
||||
if (!response.ok) {
|
||||
toast.error(t('BACKLOG.SAVE_FAILED'));
|
||||
return;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
|
||||
import {MemoryRouter} from 'react-router-dom';
|
||||
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
||||
import PlayingHint from './PlayingHint.jsx';
|
||||
|
||||
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||
@@ -13,7 +14,8 @@ const renderHint = (props) => render(<MemoryRouter><PlayingHint game={game} onCh
|
||||
describe('PlayingHint', () => {
|
||||
beforeEach(() => {
|
||||
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(() => {
|
||||
@@ -30,15 +32,15 @@ describe('PlayingHint', () => {
|
||||
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();
|
||||
renderHint({onChanged});
|
||||
|
||||
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];
|
||||
expect(url).toMatch(/\/api\/library\/games\/220\/backlog$/);
|
||||
expect(JSON.parse(init.body)).toEqual({status: 'PLAYING', rating: 3, notes: 'keep', priority: 'HIGH'});
|
||||
expect(url).toMatch(/\/api\/library\/games\/220\/status$/);
|
||||
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 {Button} from '@/components/ui/button';
|
||||
import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
|
||||
import useSessionExpired from '../hooks/useSessionExpired.js';
|
||||
import {markSession} from '../utils/auth.js';
|
||||
import {startSteamLink, unlinkSteam} from '../utils/steamLink.js';
|
||||
|
||||
@@ -16,7 +15,6 @@ import {startSteamLink, unlinkSteam} from '../utils/steamLink.js';
|
||||
*/
|
||||
export default function SteamAccountCard({steamId, verified, onUnlinked}) {
|
||||
const {t} = useTranslation();
|
||||
const expireSession = useSessionExpired();
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const link = async () => {
|
||||
@@ -24,11 +22,7 @@ export default function SteamAccountCard({steamId, verified, onUnlinked}) {
|
||||
try {
|
||||
const response = await startSteamLink();
|
||||
if (response.ok) return; // on the way to Steam: the button stays busy
|
||||
if (response.status === 401) {
|
||||
expireSession();
|
||||
} else {
|
||||
toast.error(t('SETTINGS.STEAM_LINK.ERROR'));
|
||||
}
|
||||
if (response.status !== 401) toast.error(t('SETTINGS.STEAM_LINK.ERROR'));
|
||||
} catch (err) {
|
||||
console.error('Steam link error:', err);
|
||||
toast.error(t('SETTINGS.STEAM_LINK.ERROR'));
|
||||
@@ -40,10 +34,7 @@ export default function SteamAccountCard({steamId, verified, onUnlinked}) {
|
||||
setBusy(true);
|
||||
try {
|
||||
const response = await unlinkSteam();
|
||||
if (response.status === 401) {
|
||||
expireSession();
|
||||
return;
|
||||
}
|
||||
if (response.status === 401) return;
|
||||
if (!response.ok) {
|
||||
toast.error(t('SETTINGS.STEAM_LINK.ERROR'));
|
||||
return;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {CircleAlert, CircleCheck, RefreshCw} from 'lucide-react';
|
||||
import {CircleAlert, CircleCheck, Clock, RefreshCw} from 'lucide-react';
|
||||
|
||||
const formatRelative = (iso, locale) => {
|
||||
if (!iso) return null;
|
||||
@@ -12,7 +12,7 @@ const formatRelative = (iso, locale) => {
|
||||
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();
|
||||
|
||||
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') {
|
||||
return (
|
||||
<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 { cn } from "@/lib/utils"
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import * as React from "react"
|
||||
import { cva } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Slot } from "radix-ui"
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import * as React from "react"
|
||||
import { cva } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Slot } from "radix-ui"
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Card({
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import * as React from "react"
|
||||
import { XIcon } from "lucide-react"
|
||||
import { Dialog as DialogPrimitive } from "radix-ui"
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Input({
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Label as LabelPrimitive } from "radix-ui"
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
||||
import { Select as SelectPrimitive } from "radix-ui"
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Separator as SeparatorPrimitive } from "radix-ui"
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Switch as SwitchPrimitive } from "radix-ui"
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Textarea({ className, ...props }) {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
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,
|
||||
} from '../utils/appearance.js';
|
||||
import {hasSession} from '../utils/auth.js';
|
||||
import useSession from '../hooks/useSession.js';
|
||||
import {LANGUAGES, saveSettings} from '../utils/settings.js';
|
||||
|
||||
const DARK_MODE_STORAGE_KEY = 'tbp_dark_mode';
|
||||
@@ -124,6 +125,12 @@ export function PreferencesProvider({children}) {
|
||||
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.
|
||||
*
|
||||
@@ -161,12 +168,3 @@ export function usePreferences() {
|
||||
}
|
||||
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 {act, cleanup, render} from '@testing-library/react';
|
||||
import {PreferencesProvider, usePreferences} from './PreferencesContext.jsx';
|
||||
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
||||
|
||||
const i18n = vi.hoisted(() => ({language: 'de'}));
|
||||
vi.mock('react-i18next', async () => {
|
||||
@@ -31,11 +32,10 @@ afterEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
const answerMe = (settings) => {
|
||||
globalThis.fetch = vi.fn(async (url, init) => (init?.method === 'PUT'
|
||||
? {ok: true, status: 204}
|
||||
: {ok: true, status: 200, json: async () => ({displayName: 'Lobo', settings})}));
|
||||
};
|
||||
const answerMe = (settings) => fetchRoutes({
|
||||
'GET /api/users/me': json(200, {displayName: 'Lobo', settings}),
|
||||
'PUT /api/users/settings': json(204),
|
||||
});
|
||||
|
||||
describe('PreferencesProvider language', () => {
|
||||
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 {apiFetch} from '../utils/api.js';
|
||||
import {sessionStamp} from '../utils/auth.js';
|
||||
import {useMemo} from 'react';
|
||||
import {useCurrentUserContext} from '../context/CurrentUserContext.jsx';
|
||||
import {normalizeProfileStyle} from '../utils/profileStyle.js';
|
||||
|
||||
/**
|
||||
* The stored role, the active role ("view as"), the display name and the public profile style of the logged-in
|
||||
* user, or null while loading and on any error.
|
||||
* Loads again after a new login or a role switch (see sessionStamp) on the next render.
|
||||
* user, or null while loading, on any error and when not `enabled`.
|
||||
*/
|
||||
export default function useCurrentUser(enabled) {
|
||||
const [user, setUser] = useState(null);
|
||||
const stamp = sessionStamp();
|
||||
const {user} = useCurrentUserContext();
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !stamp) {
|
||||
setUser(null);
|
||||
return undefined;
|
||||
}
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const response = await apiFetch(`${import.meta.env.VITE_API_URL}/api/users/me?v=1`);
|
||||
const data = response.ok ? await response.json() : null;
|
||||
if (!cancelled) setUser(data && {role: data.role, activeRole: data.activeRole ?? data.role, displayName: data.displayName,
|
||||
profileStyle: normalizeProfileStyle(data.settings?.profileStyle)});
|
||||
} catch {
|
||||
if (!cancelled) setUser(null);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [enabled, stamp]);
|
||||
|
||||
return user;
|
||||
return useMemo(() => (enabled && user ? {
|
||||
role: user.role,
|
||||
activeRole: user.activeRole ?? user.role,
|
||||
displayName: user.displayName,
|
||||
profileStyle: normalizeProfileStyle(user.settings?.profileStyle),
|
||||
} : null), [enabled, user]);
|
||||
}
|
||||
@@ -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 {useNavigate} from 'react-router-dom';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {toast} from 'sonner';
|
||||
import useSessionExpired from './useSessionExpired.js';
|
||||
import {apiFetch} from '../utils/api.js';
|
||||
import {hasSession} from '../utils/auth.js';
|
||||
|
||||
const RELOAD_DELAY_MS = 4000;
|
||||
|
||||
/**
|
||||
* Starts a library sync (POST /api/library/sync), reports the result as a toast and calls
|
||||
* onStarted after a short delay, so the caller can reload once Steam data has arrived.
|
||||
* Starts a library sync (POST /api/library/sync), reports the result as a toast and calls onStarted, e.g. the
|
||||
* `watch` of useLibrarySync, which reloads the data when the run is over.
|
||||
*/
|
||||
export default function useForceSync(onStarted) {
|
||||
const {t} = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const expireSession = useSessionExpired();
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const trigger = async () => {
|
||||
if (!hasSession()) {
|
||||
navigate('/login');
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
const response = await apiFetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/sync`, {
|
||||
method: 'POST',
|
||||
});
|
||||
if (response.status === 401) {
|
||||
expireSession();
|
||||
return;
|
||||
}
|
||||
if (response.status === 401) return;
|
||||
if (response.status === 429) {
|
||||
const json = await response.json().catch(() => ({}));
|
||||
toast.warning(t('LIBRARY.SYNC_COOLDOWN', {seconds: json.retryAfterSeconds ?? 60}));
|
||||
@@ -42,7 +28,7 @@ export default function useForceSync(onStarted) {
|
||||
return;
|
||||
}
|
||||
toast.success(t('LIBRARY.SYNC_STARTED'));
|
||||
setTimeout(onStarted, RELOAD_DELAY_MS);
|
||||
onStarted();
|
||||
} catch (err) {
|
||||
console.error('sync trigger error:', err);
|
||||
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 {hasSession} from '../utils/auth.js';
|
||||
|
||||
@@ -7,31 +7,42 @@ const MAX_POLL_DURATION_MS = 120_000;
|
||||
const INITIAL_SYNC_GRACE_MS = 15_000;
|
||||
|
||||
/**
|
||||
* Polls /api/library/sync-status while a sync is running (or expected),
|
||||
* and fires onComplete once when the run transitions to OK / FAILED.
|
||||
* Polls /api/library/sync-status while a sync is running (or expected), and fires onComplete once when the run ends.
|
||||
* 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 {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
|
||||
* gap between async sync dispatch and first DB write)
|
||||
* @param {() => void} opts.onComplete - called once per transition to OK/FAILED
|
||||
* @param {() => void} [opts.onUnauthorized] - called on a 401; polling stops
|
||||
* @returns {{status: string|null, lastSyncedAt: string|null, isRunning: boolean, error: string|null}}
|
||||
* @param {() => void} opts.onComplete - called once per run that ends
|
||||
* @returns {{status: string|null, lastSyncedAt: string|null, isRunning: boolean, stale: boolean, error: string|null,
|
||||
* watch: () => void}}
|
||||
*/
|
||||
export default function useLibrarySync({enabled, expectInitialSync = false, onComplete, onUnauthorized}) {
|
||||
const [state, setState] = useState({status: null, lastSyncedAt: null, error: null});
|
||||
const previousRunningRef = useRef(null);
|
||||
export default function useLibrarySync({enabled, expectInitialSync = false, onComplete}) {
|
||||
const [state, setState] = useState({status: null, lastSyncedAt: null, error: null, stale: false});
|
||||
// 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);
|
||||
onCompleteRef.current = onComplete;
|
||||
const onUnauthorizedRef = useRef(onUnauthorized);
|
||||
onUnauthorizedRef.current = onUnauthorized;
|
||||
|
||||
const watch = useCallback(() => {
|
||||
setWatched((current) => ({count: current.count + 1, since: lastSyncedAtRef.current}));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return undefined;
|
||||
|
||||
let cancelled = false;
|
||||
let timer = null;
|
||||
let wasRunning = false;
|
||||
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 () => {
|
||||
if (!hasSession()) return;
|
||||
@@ -42,10 +53,8 @@ export default function useLibrarySync({enabled, expectInitialSync = false, onCo
|
||||
);
|
||||
if (cancelled) return;
|
||||
|
||||
if (response.status === 401) {
|
||||
onUnauthorizedRef.current?.();
|
||||
return;
|
||||
}
|
||||
// The session is over; polling stops.
|
||||
if (response.status === 401) return;
|
||||
if (!response.ok) {
|
||||
setState((s) => ({...s, error: 'sync-status fetch failed'}));
|
||||
return;
|
||||
@@ -56,22 +65,24 @@ export default function useLibrarySync({enabled, expectInitialSync = false, onCo
|
||||
|
||||
const status = json.lastSyncStatus;
|
||||
const lastSyncedAt = json.lastSyncedAt;
|
||||
setState({status, lastSyncedAt, error: null});
|
||||
|
||||
lastSyncedAtRef.current = lastSyncedAt;
|
||||
const isRunning = status === 'RUNNING';
|
||||
const wasRunning = previousRunningRef.current;
|
||||
|
||||
if (wasRunning === true && !isRunning) {
|
||||
onCompleteRef.current?.();
|
||||
}
|
||||
previousRunningRef.current = isRunning;
|
||||
|
||||
const elapsed = Date.now() - startedAt;
|
||||
const waitingForInitial =
|
||||
expectInitialSync && status == null && elapsed < INITIAL_SYNC_GRACE_MS;
|
||||
const stillWaiting = isRunning || waitingForInitial;
|
||||
const stale = isRunning && elapsed >= MAX_POLL_DURATION_MS;
|
||||
setState({status, lastSyncedAt, error: null, stale});
|
||||
|
||||
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);
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -87,12 +98,14 @@ export default function useLibrarySync({enabled, expectInitialSync = false, onCo
|
||||
cancelled = true;
|
||||
if (timer) clearTimeout(timer);
|
||||
};
|
||||
}, [enabled, expectInitialSync]);
|
||||
}, [enabled, expectInitialSync, watched]);
|
||||
|
||||
return {
|
||||
status: state.status,
|
||||
lastSyncedAt: state.lastSyncedAt,
|
||||
isRunning: state.status === 'RUNNING',
|
||||
isRunning: state.status === 'RUNNING' && !state.stale,
|
||||
stale: state.stale,
|
||||
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 {toast} from 'sonner';
|
||||
import {usePreferences} from '../context/PreferencesContext.jsx';
|
||||
import useSessionExpired from './useSessionExpired.js';
|
||||
|
||||
/**
|
||||
* Returns `save(changes)`: changeAppearance plus the error messages and the session end on a 401. Resolves to
|
||||
* true when saved.
|
||||
* Returns `save(changes)`: changeAppearance plus the error messages. Resolves to true when saved.
|
||||
*/
|
||||
export default function useSaveAppearance() {
|
||||
const {t} = useTranslation();
|
||||
const {changeAppearance} = usePreferences();
|
||||
const expireSession = useSessionExpired();
|
||||
|
||||
return useCallback(async (changes) => {
|
||||
try {
|
||||
const response = await changeAppearance(changes);
|
||||
if (!response) return false;
|
||||
if (response.status === 401) {
|
||||
expireSession();
|
||||
return false;
|
||||
}
|
||||
if (response.status === 401) return false;
|
||||
if (!response.ok) {
|
||||
toast.error(t('SETTINGS.ERROR_SAVE'));
|
||||
return false;
|
||||
@@ -31,5 +25,5 @@ export default function useSaveAppearance() {
|
||||
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
|
||||
return false;
|
||||
}
|
||||
}, [changeAppearance, expireSession, t]);
|
||||
}, [changeAppearance, t]);
|
||||
}
|
||||
@@ -1,19 +1,30 @@
|
||||
// @vitest-environment jsdom
|
||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
import {act, cleanup, renderHook} from '@testing-library/react';
|
||||
import {MemoryRouter} from 'react-router-dom';
|
||||
import {MemoryRouter, Route, Routes} from 'react-router-dom';
|
||||
import {toast} from 'sonner';
|
||||
import {PreferencesProvider, usePreferences} from '../context/PreferencesContext.jsx';
|
||||
import useSaveAppearance from './useSaveAppearance.js';
|
||||
import RequireSession from '../components/RequireSession.jsx';
|
||||
import {DEFAULT_APPEARANCE} from '../utils/appearance.js';
|
||||
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
||||
|
||||
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
|
||||
|
||||
// RequireSession ends the session on a 401, as on the pages that use the hook.
|
||||
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});
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -28,7 +39,7 @@ afterEach(() => {
|
||||
|
||||
describe('useSaveAppearance', () => {
|
||||
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();
|
||||
|
||||
let saved;
|
||||
@@ -46,7 +57,7 @@ describe('useSaveAppearance', () => {
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
await act(async () => {
|
||||
@@ -59,11 +70,9 @@ describe('useSaveAppearance', () => {
|
||||
|
||||
it('keeps a later change when an earlier save fails', async () => {
|
||||
let failFirst;
|
||||
globalThis.fetch = vi.fn()
|
||||
.mockImplementationOnce(() => new Promise((resolve) => {
|
||||
failFirst = () => resolve({ok: false, status: 500});
|
||||
}))
|
||||
.mockImplementation(async () => ({ok: true, status: 204}));
|
||||
fetchRoutes({[PUT]: () => (failFirst ? json(204) : new Promise((resolve) => {
|
||||
failFirst = () => resolve(json(500, {}));
|
||||
}))});
|
||||
const {result} = renderSave();
|
||||
|
||||
let first;
|
||||
@@ -85,7 +94,7 @@ describe('useSaveAppearance', () => {
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
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 () => {
|
||||
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();
|
||||
|
||||
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 () => {
|
||||
const fromServer = {accent: 'green', corners: 'default', wideLayout: false, backgroundAppid: null, bannerAppid: 620, showBanner: false};
|
||||
let answerMe;
|
||||
globalThis.fetch = vi.fn((url, init) => (init?.method === 'PUT'
|
||||
? Promise.resolve({ok: true, status: 204})
|
||||
: new Promise((resolve) => {
|
||||
answerMe = () => resolve({ok: true, status: 200, json: async () => ({settings: {appearance: fromServer}})});
|
||||
})));
|
||||
fetchRoutes({
|
||||
[PUT]: json(204),
|
||||
[ME]: () => new Promise((resolve) => {
|
||||
answerMe = () => resolve(json(200, {settings: {appearance: fromServer}}));
|
||||
}),
|
||||
});
|
||||
const {result} = renderSave();
|
||||
|
||||
let sync;
|
||||
@@ -142,9 +152,9 @@ describe('useSaveAppearance', () => {
|
||||
|
||||
it('does not bring back the look of the user before after a reset', async () => {
|
||||
let answerMe;
|
||||
globalThis.fetch = vi.fn(() => new Promise((resolve) => {
|
||||
answerMe = () => resolve({ok: true, status: 200, json: async () => ({settings: {appearance: {accent: 'red', corners: 'sharp'}}})});
|
||||
}));
|
||||
fetchRoutes({[ME]: () => new Promise((resolve) => {
|
||||
answerMe = () => resolve(json(200, {settings: {appearance: {accent: 'red', corners: 'sharp'}}}));
|
||||
})});
|
||||
const {result} = renderSave();
|
||||
|
||||
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 () => {
|
||||
const answers = [];
|
||||
globalThis.fetch = vi.fn((url, init) => (init?.method === 'PUT'
|
||||
? new Promise((resolve) => answers.push(() => resolve({ok: true, status: 204})))
|
||||
: Promise.resolve({ok: true, status: 200, json: async () => ({})})));
|
||||
fetchRoutes({[PUT]: () => new Promise((resolve) => answers.push(() => resolve(json(204))))});
|
||||
const puts = () => fetch.mock.calls.filter(([, init]) => init?.method === 'PUT').map(([, init]) => JSON.parse(init.body));
|
||||
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 () => {
|
||||
let answerMe;
|
||||
globalThis.fetch = vi.fn((url, init) => (init?.method === 'PUT'
|
||||
? Promise.resolve({ok: true, status: 204})
|
||||
: new Promise((resolve) => {
|
||||
answerMe = () => resolve({ok: true, status: 200, json: async () => ({})});
|
||||
})));
|
||||
fetchRoutes({
|
||||
[PUT]: json(204),
|
||||
[ME]: () => new Promise((resolve) => {
|
||||
answerMe = () => resolve(json(200, {}));
|
||||
}),
|
||||
});
|
||||
const {result} = renderSave();
|
||||
|
||||
act(() => {
|
||||
|
||||
@@ -1,23 +1,18 @@
|
||||
import {useCallback} from 'react';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
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
|
||||
* session on a 401. Resolves to true when saved.
|
||||
* Returns `run(send)` for a settings save: it awaits `send()` (a fetch) and shows the error message. Resolves to
|
||||
* true when saved.
|
||||
*/
|
||||
export default function useSaveResponse() {
|
||||
const {t} = useTranslation();
|
||||
const expireSession = useSessionExpired();
|
||||
|
||||
return useCallback(async (send) => {
|
||||
try {
|
||||
const response = await send();
|
||||
if (response.status === 401) {
|
||||
expireSession();
|
||||
return false;
|
||||
}
|
||||
if (response.status === 401) return false;
|
||||
if (!response.ok) {
|
||||
toast.error(t('SETTINGS.ERROR_SAVE'));
|
||||
return false;
|
||||
@@ -28,5 +23,5 @@ export default function useSaveResponse() {
|
||||
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
|
||||
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 {useTranslation} from 'react-i18next';
|
||||
import {toast} from 'sonner';
|
||||
import useSessionExpired from './useSessionExpired.js';
|
||||
import {createImport} from '../utils/imports.js';
|
||||
|
||||
/**
|
||||
* Returns `start(source, rows)`: it creates an import in Core and resolves to its id, or shows why not (or ends the
|
||||
* session on a 401) and resolves to null.
|
||||
* Returns `start(source, rows)`: it creates an import in Core and resolves to its id, or shows why not and
|
||||
* resolves to null.
|
||||
*/
|
||||
export default function useStartImport() {
|
||||
const {t} = useTranslation();
|
||||
const expireSession = useSessionExpired();
|
||||
|
||||
return useCallback(async (source, rows) => {
|
||||
try {
|
||||
const response = await createImport(source, rows);
|
||||
if (response.status === 401) {
|
||||
expireSession();
|
||||
} else if (response.status === 409) {
|
||||
if (response.status === 401) return null;
|
||||
if (response.status === 409) {
|
||||
toast.error(t('IMPORT.RUNNING'));
|
||||
} else if (response.status === 503) {
|
||||
toast.error(t('MANUAL_GAME.UNAVAILABLE'));
|
||||
@@ -31,5 +28,5 @@ export default function useStartImport() {
|
||||
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
|
||||
}
|
||||
return null;
|
||||
}, [expireSession, t]);
|
||||
}, [t]);
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
// @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 useSteamProfile from './useSteamProfile.js';
|
||||
|
||||
const ok = (body) => ({ok: true, status: 200, json: async () => body});
|
||||
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.setItem('tbp_session', 'first-login');
|
||||
@@ -17,7 +16,8 @@ afterEach(() => {
|
||||
describe('useSteamProfile', () => {
|
||||
it('loads again after the session stamp changed, e.g. with a new Steam ID', async () => {
|
||||
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));
|
||||
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 () => {
|
||||
localStorage.removeItem('tbp_session');
|
||||
globalThis.fetch = vi.fn(async () => ({ok: false, status: 401, json: async () => ({})}));
|
||||
fetchRoutes({});
|
||||
renderHook(() => useSteamProfile(true));
|
||||
|
||||
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.
|
||||
*
|
||||
* @param onUnauthorized called on a 401, e.g. useSessionExpired
|
||||
*/
|
||||
export default function useUserStats(groups, onUnauthorized, enabled = true) {
|
||||
export default function useUserStats(groups, enabled = true) {
|
||||
const [stats, setStats] = useState(null);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const key = groups.join(',');
|
||||
@@ -16,10 +14,7 @@ export default function useUserStats(groups, onUnauthorized, enabled = true) {
|
||||
setFailed(false);
|
||||
try {
|
||||
const response = await apiFetch(statsUrl(key));
|
||||
if (response.status === 401) {
|
||||
onUnauthorized();
|
||||
return;
|
||||
}
|
||||
if (response.status === 401) return;
|
||||
if (!response.ok) {
|
||||
setFailed(true);
|
||||
return;
|
||||
@@ -29,7 +24,7 @@ export default function useUserStats(groups, onUnauthorized, enabled = true) {
|
||||
console.error('Stats load error:', err);
|
||||
setFailed(true);
|
||||
}
|
||||
}, [key, onUnauthorized]);
|
||||
}, [key]);
|
||||
|
||||
useEffect(() => {
|
||||
if (enabled) load();
|
||||
|
||||
+15
-6
@@ -1,17 +1,26 @@
|
||||
import {StrictMode} from 'react';
|
||||
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 {PreferencesProvider} from './context/PreferencesContext.jsx';
|
||||
import {CurrentUserProvider} from './context/CurrentUserContext.jsx';
|
||||
import './i18n';
|
||||
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(
|
||||
<StrictMode>
|
||||
<BrowserRouter>
|
||||
<PreferencesProvider>
|
||||
<App/>
|
||||
</PreferencesProvider>
|
||||
</BrowserRouter>
|
||||
<RouterProvider router={router}/>
|
||||
</StrictMode>
|
||||
);
|
||||
@@ -1,5 +1,4 @@
|
||||
import {useCallback, useEffect, useState} from 'react';
|
||||
import {useNavigate} from 'react-router-dom';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {Loader2, Send, Square} from 'lucide-react';
|
||||
import {toast} from 'sonner';
|
||||
@@ -12,8 +11,6 @@ import {Label} from '@/components/ui/label';
|
||||
import {Textarea} from '@/components/ui/textarea';
|
||||
import HintCard from '../components/HintCard.jsx';
|
||||
import PageContainer from '../components/PageContainer.jsx';
|
||||
import useSessionExpired from '../hooks/useSessionExpired.js';
|
||||
import {hasSession} from '../utils/auth.js';
|
||||
import {createHint, endHint, fetchAdminHints} from '../utils/hints.js';
|
||||
import {MAX_HINT_LENGTH} from '../utils/limits.js';
|
||||
|
||||
@@ -35,8 +32,6 @@ const emptyForm = () => {
|
||||
*/
|
||||
export default function AdminHintsPage() {
|
||||
const {t, i18n} = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const expireSession = useSessionExpired();
|
||||
const [hints, setHints] = useState([]);
|
||||
const [status, setStatus] = useState('loading');
|
||||
const [form, setForm] = useState(emptyForm);
|
||||
@@ -45,10 +40,7 @@ export default function AdminHintsPage() {
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetchAdminHints();
|
||||
if (response.status === 401) {
|
||||
expireSession();
|
||||
return;
|
||||
}
|
||||
if (response.status === 401) return;
|
||||
if (response.status === 403) {
|
||||
setStatus('forbidden');
|
||||
return;
|
||||
@@ -63,24 +55,17 @@ export default function AdminHintsPage() {
|
||||
console.error('Admin hints error:', err);
|
||||
setStatus('error');
|
||||
}
|
||||
}, [expireSession]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasSession()) {
|
||||
navigate('/login');
|
||||
return;
|
||||
}
|
||||
load();
|
||||
}, [load, navigate]);
|
||||
}, [load]);
|
||||
|
||||
const run = async (request, doneKey, onOk) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const response = await request();
|
||||
if (response.status === 401) {
|
||||
expireSession();
|
||||
return;
|
||||
}
|
||||
if (response.status === 401) return;
|
||||
if (response.ok) {
|
||||
toast.success(t(doneKey));
|
||||
onOk?.();
|
||||
|
||||
@@ -5,6 +5,7 @@ import {MemoryRouter} from 'react-router-dom';
|
||||
import {toast} from 'sonner';
|
||||
import AdminHintsPage from './AdminHintsPage.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('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};
|
||||
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 renderPage = () => render(<MemoryRouter><PreferencesProvider><AdminHintsPage/></PreferencesProvider></MemoryRouter>);
|
||||
@@ -23,11 +29,7 @@ const renderPage = () => render(<MemoryRouter><PreferencesProvider><AdminHintsPa
|
||||
describe('AdminHintsPage', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.setItem('tbp_session', '1');
|
||||
globalThis.fetch = vi.fn(async (url, init) => {
|
||||
if (init?.method === 'POST') return answer(201, {id: 3});
|
||||
if (init?.method === 'PUT') return answer(204);
|
||||
return answer(200, {results: [ACTIVE, ENDED]});
|
||||
});
|
||||
withRoutes({});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -68,8 +70,7 @@ describe('AdminHintsPage', () => {
|
||||
});
|
||||
|
||||
it('explains an invalid time range', async () => {
|
||||
fetch.mockImplementation(async (url, init) =>
|
||||
(init?.method === 'POST' ? answer(400, {code: 'INVALID_HINT'}) : answer(200, {results: []})));
|
||||
withRoutes({'GET /api/admin/hints': json(200, {results: []}), 'POST /api/admin/hints': json(400, {code: 'INVALID_HINT'})});
|
||||
renderPage();
|
||||
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 () => {
|
||||
// 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'};
|
||||
fetch.mockImplementation(async () => answer(200, {results: [endedOnServer]}));
|
||||
withRoutes({'GET /api/admin/hints': json(200, {results: [endedOnServer]})});
|
||||
renderPage();
|
||||
|
||||
await screen.findByText('Server update tonight');
|
||||
@@ -103,7 +104,7 @@ describe('AdminHintsPage', () => {
|
||||
});
|
||||
|
||||
it('shows "no access" to a non-admin', async () => {
|
||||
fetch.mockImplementation(async () => answer(403));
|
||||
withRoutes({'GET /api/admin/hints': json(403, {})});
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByText('ADMIN_HINTS.NO_ACCESS')).toBeTruthy();
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {useCallback, useEffect, useRef, useState} from 'react';
|
||||
import {useNavigate} from 'react-router-dom';
|
||||
import {useCallback, useEffect, useState} from 'react';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {Ban, ChevronLeft, ChevronRight, Loader2, Search, ShieldCheck, Trash2} from 'lucide-react';
|
||||
import {toast} from 'sonner';
|
||||
@@ -19,9 +18,8 @@ import {
|
||||
import {Input} from '@/components/ui/input';
|
||||
import {Label} from '@/components/ui/label';
|
||||
import PageContainer from '../components/PageContainer.jsx';
|
||||
import useSessionExpired from '../hooks/useSessionExpired.js';
|
||||
import {deleteUser, fetchUsers, setUserBlocked} from '../utils/admin.js';
|
||||
import {hasSession} from '../utils/auth.js';
|
||||
import useAbortPrevious from '../hooks/useAbortPrevious.js';
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
const REFUSAL_KEY = {
|
||||
@@ -37,8 +35,6 @@ const formatDate = (value, locale) => (value ? new Date(value).toLocaleDateStrin
|
||||
*/
|
||||
export default function AdminUsersPage() {
|
||||
const {t, i18n} = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const expireSession = useSessionExpired();
|
||||
const [query, setQuery] = useState('');
|
||||
const [search, setSearch] = useState('');
|
||||
const [page, setPage] = useState(0);
|
||||
@@ -49,17 +45,14 @@ export default function AdminUsersPage() {
|
||||
const [typedEmail, setTypedEmail] = useState('');
|
||||
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.
|
||||
const loadCount = useRef(0);
|
||||
const nextSignal = useAbortPrevious();
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const current = ++loadCount.current;
|
||||
const signal = nextSignal();
|
||||
try {
|
||||
const response = await fetchUsers(search, page, PAGE_SIZE);
|
||||
if (current !== loadCount.current) return;
|
||||
if (response.status === 401) {
|
||||
expireSession();
|
||||
return;
|
||||
}
|
||||
const response = await fetchUsers(search, page, PAGE_SIZE, {signal});
|
||||
if (signal.aborted) return;
|
||||
if (response.status === 401) return;
|
||||
if (response.status === 403) {
|
||||
setStatus('forbidden');
|
||||
return;
|
||||
@@ -69,22 +62,19 @@ export default function AdminUsersPage() {
|
||||
return;
|
||||
}
|
||||
const data = await response.json();
|
||||
if (current !== loadCount.current) return;
|
||||
if (signal.aborted) return;
|
||||
setResult(data);
|
||||
setStatus('ready');
|
||||
} catch (err) {
|
||||
if (signal.aborted) return;
|
||||
console.error('Admin users error:', err);
|
||||
setStatus('error');
|
||||
}
|
||||
}, [search, page, expireSession]);
|
||||
}, [nextSignal, search, page]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasSession()) {
|
||||
navigate('/login');
|
||||
return;
|
||||
}
|
||||
load();
|
||||
}, [load, navigate]);
|
||||
}, [load]);
|
||||
|
||||
const closeDialog = () => {
|
||||
setPending(null);
|
||||
@@ -96,10 +86,7 @@ export default function AdminUsersPage() {
|
||||
setBusy(true);
|
||||
try {
|
||||
const response = kind === 'delete' ? await deleteUser(user.id) : await setUserBlocked(user.id, !user.blocked);
|
||||
if (response.status === 401) {
|
||||
expireSession();
|
||||
return;
|
||||
}
|
||||
if (response.status === 401) return;
|
||||
if (response.ok) {
|
||||
toast.success(t(kind === 'delete' ? 'ADMIN.DONE_DELETE' : user.blocked ? 'ADMIN.DONE_UNBLOCK' : 'ADMIN.DONE_BLOCK'));
|
||||
closeDialog();
|
||||
|
||||
@@ -5,6 +5,7 @@ import {MemoryRouter} from 'react-router-dom';
|
||||
import {toast} from 'sonner';
|
||||
import AdminUsersPage from './AdminUsersPage.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('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 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 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', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.setItem('tbp_session', '1');
|
||||
globalThis.fetch = vi.fn(async (url, init) => (init?.method ? answer(204) : answer(200, page([ADMIN, PLAYER]))));
|
||||
withRoutes({});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -78,8 +84,10 @@ describe('AdminUsersPage', () => {
|
||||
});
|
||||
|
||||
it('shows the reason when Auth refuses', async () => {
|
||||
fetch.mockImplementation(async (url, init) => (init?.method
|
||||
? answer(409, {code: 'TARGET_IS_ADMIN'}) : answer(200, page([PLAYER]))));
|
||||
withRoutes({
|
||||
'GET /api/admin/users': json(200, page([PLAYER])),
|
||||
'PUT /api/admin/users/7/blocked': json(409, {code: 'TARGET_IS_ADMIN'}),
|
||||
});
|
||||
renderPage();
|
||||
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 () => {
|
||||
fetch.mockResolvedValue(answer(403, {}));
|
||||
withRoutes({'GET /api/admin/users': json(403, {})});
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByText('ADMIN.NO_ACCESS')).toBeTruthy();
|
||||
|
||||
+23
-291
@@ -1,10 +1,8 @@
|
||||
import React, {useCallback, useEffect, useState} from 'react';
|
||||
import {Link, useNavigate} from 'react-router-dom';
|
||||
import {useState} from 'react';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
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 {Card} from '@/components/ui/card';
|
||||
import {Skeleton} from '@/components/ui/skeleton';
|
||||
@@ -12,51 +10,33 @@ import AchievementsCard from '../components/AchievementsCard.jsx';
|
||||
import ActivityCard from '../components/ActivityCard.jsx';
|
||||
import CurrentlyPlayingCard from '../components/CurrentlyPlayingCard.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 SteamGameCount from '../components/SteamGameCount';
|
||||
import SyncStatusBadge from '../components/SyncStatusBadge.jsx';
|
||||
import useDashboardData from '../hooks/useDashboardData.js';
|
||||
import useLibrarySync from '../hooks/useLibrarySync.js';
|
||||
import useForceSync from '../hooks/useForceSync.js';
|
||||
import {formatPlaytime} from '../utils/games.js';
|
||||
import useSessionExpired from '../hooks/useSessionExpired.js';
|
||||
import PageContainer from '../components/PageContainer.jsx';
|
||||
import HeroBanner from '../components/HeroBanner.jsx';
|
||||
import SteamAvatar from '../components/SteamAvatar.jsx';
|
||||
import useSteamProfile from '../hooks/useSteamProfile.js';
|
||||
import useUserStats from '../hooks/useUserStats.js';
|
||||
import useSaveResponse from '../hooks/useSaveResponse.js';
|
||||
import TileGrid from '../components/TileGrid.jsx';
|
||||
import statGroupTile from '../components/stats/statGroupTile.jsx';
|
||||
import {DASHBOARD_TILES, DASHBOARD_TITLES, DEFAULT_DASHBOARD, normalizeLayout, saveTiles, statGroup} from '../utils/tiles.js';
|
||||
import {
|
||||
DASHBOARD_TILES, DASHBOARD_TITLES, DEFAULT_DASHBOARD, normalizeLayout, PLAYING_GAMES, saveTiles, statGroup, TOP_GAMES,
|
||||
} from '../utils/tiles.js';
|
||||
import {usePreferences} from '../context/PreferencesContext.jsx';
|
||||
import {apiFetch} from '../utils/api.js';
|
||||
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'};
|
||||
import {useCurrentUserContext} from '../context/CurrentUserContext.jsx';
|
||||
|
||||
const Dashboard = () => {
|
||||
const {t} = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const expireSession = useSessionExpired();
|
||||
const [userData, setUserData] = useState(null);
|
||||
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 {user: userData, updateUser} = useCurrentUserContext();
|
||||
const {
|
||||
steamData, topGames, playing, summary, summaryFailed, activity, achievements, isLoading, error,
|
||||
fetchSummary, reloadLibrary,
|
||||
} = useDashboardData();
|
||||
const [shuffling, setShuffling] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const runSave = useSaveResponse();
|
||||
const layout = normalizeLayout(userData?.settings?.dashboardTiles, DASHBOARD_TILES, DEFAULT_DASHBOARD);
|
||||
@@ -67,109 +47,7 @@ const Dashboard = () => {
|
||||
])].sort();
|
||||
// No groups would mean "all groups" to the stats endpoint.
|
||||
const hasStatGroups = statGroups.length > 0;
|
||||
const stats = useUserStats(statGroups, expireSession, hasSession() && userData != null && hasStatGroups);
|
||||
const 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 stats = useUserStats(statGroups, userData != null && hasStatGroups);
|
||||
|
||||
const shuffleSuggestion = async () => {
|
||||
setShuffling(true);
|
||||
@@ -179,53 +57,6 @@ const Dashboard = () => {
|
||||
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 showLibrary = hasSteamId || steamData.count > 0;
|
||||
const {appearance} = usePreferences();
|
||||
@@ -236,10 +67,12 @@ const Dashboard = () => {
|
||||
const sync = useLibrarySync({
|
||||
enabled: hasSteamId,
|
||||
expectInitialSync: hasSteamId && steamData.count === 0,
|
||||
onComplete: reloadLibrary,
|
||||
onUnauthorized: expireSession,
|
||||
onComplete: () => {
|
||||
reloadLibrary();
|
||||
if (hasStatGroups) stats.reload();
|
||||
},
|
||||
});
|
||||
const forceSync = useForceSync(reloadLibrary);
|
||||
const forceSync = useForceSync(sync.watch);
|
||||
|
||||
if (isLoading) {
|
||||
return <DashboardSkeleton/>;
|
||||
@@ -253,13 +86,11 @@ const Dashboard = () => {
|
||||
);
|
||||
}
|
||||
|
||||
const syncBusy = forceSync.busy || sync.isRunning;
|
||||
|
||||
const saveLayout = async (next) => {
|
||||
// The default is saved as "never changed", so a later change of the default reaches this user too.
|
||||
const tiles = JSON.stringify(next) === JSON.stringify(DEFAULT_DASHBOARD) ? null : next;
|
||||
if (!await runSave(() => saveTiles('dashboard-tiles', tiles))) return;
|
||||
setUserData((d) => ({...d, settings: {...d.settings, dashboardTiles: tiles}}));
|
||||
updateUser((d) => ({...d, settings: {...d.settings, dashboardTiles: tiles}}));
|
||||
setEditing(false);
|
||||
};
|
||||
|
||||
@@ -294,53 +125,8 @@ const Dashboard = () => {
|
||||
|
||||
return (
|
||||
<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">
|
||||
{/* 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={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>
|
||||
<DashboardWelcomeCard bannerAppid={bannerAppid} avatarUrl={steamProfile?.avatarUrl}
|
||||
sync={sync} forceSync={forceSync} editing={editing} onCustomize={() => setEditing(true)}/>
|
||||
|
||||
<TileGrid tiles={DASHBOARD_TILES} titles={DASHBOARD_TITLES} layout={layout} renderTile={renderTile} editing={editing}
|
||||
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() {
|
||||
return (
|
||||
<PageContainer className="p-4 md:p-8 space-y-6">
|
||||
|
||||
@@ -1,23 +1,39 @@
|
||||
// @vitest-environment jsdom
|
||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
|
||||
import {MemoryRouter, Route, Routes, useLocation} from 'react-router-dom';
|
||||
import {cleanup, fireEvent, screen, waitFor} from '@testing-library/react';
|
||||
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('../hooks/useLibrarySync.js', () => ({default: () => ({status: 'OK', lastSyncedAt: null, isRunning: 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() {
|
||||
current.location = useLocation();
|
||||
return null;
|
||||
}
|
||||
// Every request of the page with a Steam ID; the test of the 401 answers all but the user with 401.
|
||||
const dashboardRoutes = (settings) => ({
|
||||
...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 unauthorized = () => ({ok: false, status: 401, json: async () => ({})});
|
||||
const renderDashboard = () => renderWithProviders(<Dashboard/>, {entries: ['/dashboard']});
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.setItem('tbp_session', '1');
|
||||
@@ -30,45 +46,24 @@ afterEach(() => {
|
||||
|
||||
describe('Dashboard', () => {
|
||||
it('opens the login page when the core API answers 401', async () => {
|
||||
globalThis.fetch = vi.fn(async (url) =>
|
||||
url.includes('/api/users/me') ? ok({email: 'a@b.de', steamId: '76561198000000000'}) : unauthorized());
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/dashboard']}><PreferencesProvider>
|
||||
<Routes>
|
||||
<Route path="/dashboard" element={<><Dashboard/><Spy/></>}/>
|
||||
<Route path="/login" element={<Spy/>}/>
|
||||
</Routes>
|
||||
</PreferencesProvider></MemoryRouter>,
|
||||
);
|
||||
fetchRoutes({
|
||||
...Object.fromEntries(Object.keys(dashboardRoutes()).map((key) => [key, json(401, {})])),
|
||||
'GET /api/users/me': json(200, {email: 'a@b.de', steamId: '76561198000000000'}),
|
||||
'POST /api/auth/refresh': json(401, {}),
|
||||
});
|
||||
const {router} = renderWithProviders(<Dashboard/>,
|
||||
{path: '/dashboard', entries: ['/dashboard'], guarded: true, routes: {'/login': <p>login</p>}});
|
||||
|
||||
await waitFor(() => expect(fetch.mock.calls.length).toBeGreaterThanOrEqual(5));
|
||||
|
||||
await waitFor(() => expect(current.location.pathname).toBe('/login'));
|
||||
expect(current.location.state).toEqual({sessionExpired: true, from: '/dashboard'});
|
||||
await waitFor(() => expect(router.state.location.pathname).toBe('/login'));
|
||||
expect(router.state.location.state).toEqual({sessionExpired: true, from: '/dashboard'});
|
||||
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) => {
|
||||
globalThis.fetch = vi.fn(async (url, init) => {
|
||||
if (init?.method === 'PUT') return {ok: true, status: 204, json: async () => ({})};
|
||||
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;
|
||||
fetchRoutes(dashboardRoutes(settings));
|
||||
return renderDashboard().container;
|
||||
};
|
||||
|
||||
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());
|
||||
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 {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 {toast} from 'sonner';
|
||||
import {ArrowLeft, EyeOff, ShieldOff, Trophy} from 'lucide-react';
|
||||
|
||||
import {Badge} from '@/components/ui/badge';
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {Card} from '@/components/ui/card';
|
||||
import {Skeleton} from '@/components/ui/skeleton';
|
||||
import {cn} from '@/lib/utils';
|
||||
import BacklogCard from '../components/BacklogCard.jsx';
|
||||
import Confetti from '../components/Confetti.jsx';
|
||||
import GameCover from '../components/GameCover.jsx';
|
||||
import GameStatusBadge from '../components/GameStatusBadge.jsx';
|
||||
import GameAchievements from '../components/GameAchievements.jsx';
|
||||
import GameHeroCard from '../components/GameHeroCard.jsx';
|
||||
import ManualGameCard from '../components/ManualGameCard.jsx';
|
||||
import CompletedHint from '../components/CompletedHint.jsx';
|
||||
import FinishedHint from '../components/FinishedHint.jsx';
|
||||
import GameCorrectionDialog from '../components/GameCorrectionDialog.jsx';
|
||||
import GameMergeDialog from '../components/GameMergeDialog.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 useSessionExpired from '../hooks/useSessionExpired.js';
|
||||
import useSaveAppearance from '../hooks/useSaveAppearance.js';
|
||||
import {usePreferences} from '../context/PreferencesContext.jsx';
|
||||
import useGame from '../hooks/useGame.js';
|
||||
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 {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() {
|
||||
const {t, i18n} = useTranslation();
|
||||
const {t} = useTranslation();
|
||||
const {appid} = useParams();
|
||||
const {appearance} = usePreferences();
|
||||
const saveAppearance = useSaveAppearance();
|
||||
const navigate = useNavigate();
|
||||
const expireSession = useSessionExpired();
|
||||
|
||||
const [data, setData] = useState(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [hiding, setHiding] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const {data, setData, isLoading, error, reload} = useGame(appid);
|
||||
const [correcting, setCorrecting] = useState(false);
|
||||
const [merging, setMerging] = useState(false);
|
||||
const [reloads, setReloads] = useState(0);
|
||||
const currentUser = useCurrentUser(true);
|
||||
const auditor = canCorrect(currentUser);
|
||||
const [savedProfileStyle, setSavedProfileStyle] = useState(null);
|
||||
const profileStyle = savedProfileStyle ?? currentUser?.profileStyle;
|
||||
const [celebration, setCelebration] = useState(null);
|
||||
const shownLevel = useRef(null);
|
||||
|
||||
@@ -89,106 +44,7 @@ export default function GameDetail() {
|
||||
shownLevel.current = {appid: data.appid, level: celebrationLevel(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 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 (
|
||||
<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')}
|
||||
</div>
|
||||
)}
|
||||
<Card className="relative overflow-hidden p-0 gap-0 md:flex-row">
|
||||
<GameCover appid={data.appid} imageUrl={data.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">
|
||||
{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>
|
||||
<GameHeroCard game={data} manual={manual} auditor={auditor} profileStyle={savedProfileStyle ?? currentUser?.profileStyle}
|
||||
onCorrect={() => setCorrecting(true)} onMerge={() => setMerging(true)}
|
||||
onHiddenChanged={(hidden) => setData((d) => ({...d, hidden}))}
|
||||
onProfileStyleSaved={setSavedProfileStyle}/>
|
||||
|
||||
{/* The finished question also answers "are you playing it?". */}
|
||||
{!(data.finishedHint && ['BACKLOG', 'PLAYING'].includes(data.status)) && (
|
||||
@@ -326,75 +109,19 @@ export default function GameDetail() {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{data.hasAchievements && data.statsVisible && (
|
||||
<>
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
{data.hasAchievements && data.statsVisible && <GameAchievements game={data}/>}
|
||||
</>
|
||||
)}
|
||||
{celebration && <Confetti key={celebration.id} size={celebration.size} onDone={() => setCelebration(null)}/>}
|
||||
{/* Outside the content: the reload after a save must not close it. */}
|
||||
{data && auditor && (
|
||||
<GameCorrectionDialog game={data} open={correcting} onOpenChange={setCorrecting}
|
||||
onChanged={() => setReloads((n) => n + 1)}/>
|
||||
onChanged={reload}/>
|
||||
)}
|
||||
{auditor && data && (
|
||||
<GameMergeDialog game={data} open={merging} onOpenChange={setMerging} onMerged={(merged) => {
|
||||
// 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});
|
||||
}}/>
|
||||
)}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// @vitest-environment jsdom
|
||||
import {afterEach, beforeEach, expect, it, vi} from 'vitest';
|
||||
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
|
||||
import {MemoryRouter, Route, Routes} from 'react-router-dom';
|
||||
import {cleanup, fireEvent, screen, waitFor} from '@testing-library/react';
|
||||
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'));
|
||||
// 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(() => {
|
||||
localStorage.setItem('tbp_session', '1');
|
||||
globalThis.fetch = vi.fn(async (url) => ({ok: true, status: 200,
|
||||
json: async () => (url.includes('/api/users/me') ? {role: 'AUDITOR', activeRole: 'AUDITOR'} : game)}));
|
||||
fetchRoutes({
|
||||
'GET /api/users/me': json(200, {role: 'AUDITOR', activeRole: 'AUDITOR'}),
|
||||
'GET /api/library/games/220': json(200, game),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -30,11 +32,7 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
it('keeps unsaved notes while the game loads again after a correction', async () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/library/220']}><PreferencesProvider>
|
||||
<Routes><Route path="/library/:appid" element={<GameDetail/>}/></Routes>
|
||||
</PreferencesProvider></MemoryRouter>,
|
||||
);
|
||||
renderWithProviders(<GameDetail/>, {path: '/library/:appid', entries: ['/library/220']});
|
||||
fireEvent.change(await screen.findByLabelText('BACKLOG.NOTES_LABEL'), {target: {value: 'my draft'}});
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', {name: 'correction saved'}));
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// @vitest-environment jsdom
|
||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
import {act, cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
|
||||
import {MemoryRouter, Route, Routes} from 'react-router-dom';
|
||||
import {act, cleanup, fireEvent, screen, waitFor} from '@testing-library/react';
|
||||
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 {toast} from 'sonner';
|
||||
|
||||
@@ -15,20 +15,20 @@ const game = {
|
||||
rating: null, notes: null, priority: null, hasAchievements: false, statsVisible: false, achievements: [],
|
||||
};
|
||||
|
||||
const renderDetail = (appid = 220) => render(
|
||||
<MemoryRouter initialEntries={[`/library/${appid}`]}><PreferencesProvider>
|
||||
<Routes>
|
||||
<Route path="/library/:appid" element={<GameDetail/>}/>
|
||||
<Route path="/library" element={<p>library page</p>}/>
|
||||
</Routes>
|
||||
</PreferencesProvider></MemoryRouter>,
|
||||
);
|
||||
const renderDetail = (appid = 220) => renderWithProviders(<GameDetail/>,
|
||||
{path: '/library/:appid', entries: [`/library/${appid}`], routes: {'/library': <p>library page</p>}});
|
||||
|
||||
const echo = (url, init) => json(200, JSON.parse(init.body));
|
||||
|
||||
// The user and the game at /library/<shown.appid>; `routes` adds or replaces answers.
|
||||
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(() => {
|
||||
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(() => {
|
||||
@@ -39,8 +39,7 @@ afterEach(() => {
|
||||
|
||||
describe('GameDetail', () => {
|
||||
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,
|
||||
source: 'STEAM', finishedHint: {type: 'ENDING', achievement: 'Survivor'}})}));
|
||||
answer({...game, playtime2Weeks: 90, source: 'STEAM', finishedHint: {type: 'ENDING', achievement: 'Survivor'}});
|
||||
renderDetail();
|
||||
|
||||
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 () => {
|
||||
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, source: 'STEAM', hidden: false})});
|
||||
answer({...game, source: 'STEAM', hidden: false}, {'PUT /api/library/games/220/hidden': echo});
|
||||
renderDetail();
|
||||
|
||||
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 () => {
|
||||
globalThis.fetch = vi.fn(async (url, init) => init?.method === 'PUT'
|
||||
? {ok: false, status: 409, json: async () => ({code: 'MANUAL_GAME'})}
|
||||
: {ok: true, status: 200, json: async () => ({...game, source: 'STEAM', hidden: false})});
|
||||
answer({...game, source: 'STEAM', hidden: false}, {'PUT /api/library/games/220/hidden': json(409, {code: 'MANUAL_GAME'})});
|
||||
renderDetail();
|
||||
|
||||
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 () => {
|
||||
globalThis.fetch = vi.fn(async () => ({ok: true, status: 200,
|
||||
json: async () => ({...game, source: 'MANUAL', platform: 'PS5', hidden: false})}));
|
||||
answer({...game, source: 'MANUAL', platform: 'PS5', hidden: false});
|
||||
renderDetail();
|
||||
|
||||
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 () => {
|
||||
const achievement = (apiName) => ({apiName, displayName: apiName, unlocked: false, iconUrl: `https://x/${apiName}.jpg`,
|
||||
iconGrayUrl: `https://x/${apiName}_gray.jpg`});
|
||||
globalThis.fetch = vi.fn(async (url, init) => init?.method === 'POST'
|
||||
? {ok: true, status: 204}
|
||||
: {ok: true, status: 200, json: async () => ({...game, hasAchievements: true, statsVisible: true,
|
||||
achievementsTotal: 2, achievementsUnlocked: 0, completionPercent: 0,
|
||||
achievements: [achievement('A'), achievement('B')]})});
|
||||
answer({...game, hasAchievements: true, statsVisible: true, achievementsTotal: 2, achievementsUnlocked: 0,
|
||||
completionPercent: 0, achievements: [achievement('A'), achievement('B')]}, {'POST /api/library/broken-images': json(204)});
|
||||
const {container} = renderDetail();
|
||||
|
||||
await screen.findByText('A');
|
||||
@@ -108,8 +99,7 @@ describe('GameDetail', () => {
|
||||
});
|
||||
|
||||
it('links the speedrun.com page when Core found one', async () => {
|
||||
globalThis.fetch = vi.fn(async () => ({ok: true, status: 200,
|
||||
json: async () => ({...game, speedrunUrl: 'https://www.speedrun.com/hl2'})}));
|
||||
answer({...game, speedrunUrl: 'https://www.speedrun.com/hl2'});
|
||||
renderDetail();
|
||||
|
||||
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])(
|
||||
'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();
|
||||
|
||||
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 () => {
|
||||
// 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();
|
||||
const notes = await screen.findByLabelText('BACKLOG.NOTES_LABEL');
|
||||
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 () => {
|
||||
answer(game, {'PUT /api/users/settings/appearance': echo});
|
||||
renderDetail();
|
||||
|
||||
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 () => {
|
||||
answer(game, {'PUT /api/users/settings/appearance': echo});
|
||||
renderDetail();
|
||||
|
||||
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 () => {
|
||||
localStorage.setItem('tbp_appearance', JSON.stringify({bannerAppid: 220, showBanner: false}));
|
||||
answer(game, {'PUT /api/users/settings/appearance': echo});
|
||||
renderDetail();
|
||||
|
||||
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',
|
||||
};
|
||||
|
||||
const noBody = {ok: true, status: 204, json: async () => { throw new Error('204 has no body'); }};
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.fetch = vi.fn(async (url, init) => (init?.method === 'PUT' || init?.method === 'DELETE')
|
||||
? {ok: true, status: 204, json: async () => { throw new Error('204 has no body'); }}
|
||||
: {ok: true, status: 200, json: async () => manualGame});
|
||||
answer(manualGame, {'PUT /api/library/games/-1025/manual': noBody, 'DELETE /api/library/games/-1025': noBody});
|
||||
});
|
||||
|
||||
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 () => {
|
||||
globalThis.fetch = vi.fn(async () => ({ok: true, status: 200, json: async () => ({...manualGame, playtimeMinutes: 75})}));
|
||||
answer({...manualGame, playtimeMinutes: 75});
|
||||
renderDetail(-1025);
|
||||
|
||||
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 () => {
|
||||
globalThis.fetch = vi.fn(async () => ({ok: true, status: 200, json: async () => ({...game, source: 'STEAM'})}));
|
||||
answer({...game, source: 'STEAM'});
|
||||
renderDetail();
|
||||
|
||||
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 () => {
|
||||
let backgroundAppid = null;
|
||||
globalThis.fetch = vi.fn(async (url, init) => ({ok: true, status: init?.method === 'PUT' ? 204 : 200,
|
||||
json: async () => (url.includes('/api/users/me')
|
||||
? {role: 'USER', settings: {profileStyle: {accent: 'red', corners: 'round', backgroundAppid}}}
|
||||
: {...game, source: 'STEAM'})}));
|
||||
answer({...game, source: 'STEAM'}, {
|
||||
'GET /api/users/me': () => json(200, {role: 'USER', settings: {profileStyle: {accent: 'red', corners: 'round', backgroundAppid}}}),
|
||||
'PUT /api/users/settings/profile-style': json(204),
|
||||
});
|
||||
renderDetail();
|
||||
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 () => {
|
||||
let bannerAppid = 220;
|
||||
globalThis.fetch = vi.fn(async (url, init) => ({ok: true, status: init?.method === 'PUT' ? 204 : 200,
|
||||
json: async () => (url.includes('/api/users/me')
|
||||
? {role: 'USER', settings: {profileStyle: {bannerAppid}}}
|
||||
: {...game, source: 'STEAM'})}));
|
||||
answer({...game, source: 'STEAM'}, {
|
||||
'GET /api/users/me': () => json(200, {role: 'USER', settings: {profileStyle: {bannerAppid}}}),
|
||||
'PUT /api/users/settings/profile-style': json(204),
|
||||
});
|
||||
renderDetail();
|
||||
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 () => {
|
||||
let stored = {accent: 'red', corners: 'round'};
|
||||
globalThis.fetch = vi.fn(async (url, init) => {
|
||||
if (init?.method === 'PUT') stored = JSON.parse(init.body);
|
||||
return {ok: true, status: init?.method === 'PUT' ? 204 : 200,
|
||||
json: async () => (url.includes('/api/users/me')
|
||||
? {role: 'USER', settings: {profileStyle: stored}}
|
||||
: {...game, source: 'STEAM'})};
|
||||
answer({...game, source: 'STEAM'}, {
|
||||
'GET /api/users/me': () => json(200, {role: 'USER', settings: {profileStyle: stored}}),
|
||||
'PUT /api/users/settings/profile-style': (url, init) => {
|
||||
stored = JSON.parse(init.body);
|
||||
return json(204);
|
||||
},
|
||||
});
|
||||
const styleBodies = () => fetch.mock.calls.filter(([url, init]) => init?.method === 'PUT' && url.endsWith('/profile-style'))
|
||||
.map(([, init]) => JSON.parse(init.body));
|
||||
@@ -307,10 +302,7 @@ describe('GameDetail of a game added by hand', () => {
|
||||
});
|
||||
|
||||
describe('corrections', () => {
|
||||
const loggedInAs = (me) => {
|
||||
globalThis.fetch = vi.fn(async (url) => ({ok: true, status: 200,
|
||||
json: async () => (url.includes('/api/users/me') ? me : game)}));
|
||||
};
|
||||
const loggedInAs = (me) => answer(game, {}, me);
|
||||
|
||||
it('offers "correct data" to an active auditor', async () => {
|
||||
loggedInAs({role: 'AUDITOR', activeRole: 'AUDITOR'});
|
||||
@@ -358,7 +350,7 @@ describe('GameDetail confetti', () => {
|
||||
});
|
||||
|
||||
const opened = async (entry) => {
|
||||
globalThis.fetch = vi.fn(async () => ({ok: true, status: 200, json: async () => ({...game, ...entry})}));
|
||||
answer({...game, ...entry});
|
||||
const {container} = renderDetail();
|
||||
await screen.findByText('Half-Life 2');
|
||||
return container.querySelector('canvas[data-size]');
|
||||
@@ -378,3 +370,21 @@ describe('GameDetail confetti', () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
+19
-41
@@ -1,10 +1,9 @@
|
||||
import {useCallback, useEffect, useState} from 'react';
|
||||
import {Link, useNavigate, useSearchParams} from 'react-router-dom';
|
||||
import {Link, useSearchParams} from 'react-router-dom';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import useLibrarySync from '../hooks/useLibrarySync.js';
|
||||
import SyncStatusBadge from '../components/SyncStatusBadge.jsx';
|
||||
import useForceSync from '../hooks/useForceSync.js';
|
||||
import useSessionExpired from '../hooks/useSessionExpired.js';
|
||||
import {formatPlaytime} from '../utils/games.js';
|
||||
import {libraryQuery, parseStatus} from '../utils/backlog.js';
|
||||
import {GAME_STATUSES, MAX_LIBRARY_SEARCH_LENGTH} from '../utils/limits.js';
|
||||
@@ -21,8 +20,8 @@ import GameCover from '../components/GameCover.jsx';
|
||||
import LibraryTabs from '../components/LibraryTabs.jsx';
|
||||
import GameStatusBadge from '../components/GameStatusBadge.jsx';
|
||||
import PageContainer from '../components/PageContainer.jsx';
|
||||
import {apiFetch} from '../utils/api.js';
|
||||
import {hasSession} from '../utils/auth.js';
|
||||
import {apiFetch, request} from '../utils/api.js';
|
||||
import useAbortPrevious from '../hooks/useAbortPrevious.js';
|
||||
import {platformLabel} from '../utils/platforms.js';
|
||||
|
||||
const PAGE_SIZES = [20, 40, 80];
|
||||
@@ -42,8 +41,6 @@ const SORT_OPTIONS = [
|
||||
|
||||
export default function GameLibrary() {
|
||||
const {t} = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const expireSession = useSessionExpired();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const page = Math.max(0, parseInt(searchParams.get('page') ?? '0', 10) || 0);
|
||||
@@ -69,47 +66,28 @@ export default function GameLibrary() {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const nextSignal = useAbortPrevious();
|
||||
const load = useCallback(async () => {
|
||||
if (!hasSession()) {
|
||||
navigate('/login');
|
||||
return;
|
||||
}
|
||||
const signal = nextSignal();
|
||||
setIsLoading(true);
|
||||
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 response = await apiFetch(url, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
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 {
|
||||
const {ok, status, data: json} = await request(url, {signal});
|
||||
if (signal.aborted) return;
|
||||
setIsLoading(false);
|
||||
if (status === 401) return;
|
||||
if (!ok || !json) {
|
||||
setError(status === 0 ? 'INFO.ERROR.NETWORK_FAILURE' : 'LIBRARY.ERROR_LOAD');
|
||||
return;
|
||||
}
|
||||
}, [navigate, expireSession, page, size, sort, listStatus, q, listShortOnly, hiddenOnly]);
|
||||
setData(json);
|
||||
}, [nextSignal, page, size, sort, listStatus, q, listShortOnly, hiddenOnly]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasSession()) return undefined;
|
||||
let cancelled = false;
|
||||
apiFetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/games-count`)
|
||||
.then((response) => (response.ok ? response.json() : null))
|
||||
@@ -127,26 +105,25 @@ export default function GameLibrary() {
|
||||
enabled: hasSteamId,
|
||||
expectInitialSync,
|
||||
onComplete: load,
|
||||
onUnauthorized: expireSession,
|
||||
});
|
||||
|
||||
const updateParams = (next) => {
|
||||
const updateParams = useCallback((next) => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
Object.entries(next).forEach(([k, v]) => (v === null || v === '' ? params.delete(k) : params.set(k, String(v))));
|
||||
setSearchParams(params);
|
||||
};
|
||||
}, [searchParams, setSearchParams]);
|
||||
|
||||
// Back/forward navigation changes q without typing. Keeps a trailing space the user is still typing.
|
||||
useEffect(() => {
|
||||
setSearch((current) => (current.trim() === q ? current : q));
|
||||
}, [q]);
|
||||
|
||||
// searchParams is a dependency, so a status click during the delay restarts the timer with fresh params.
|
||||
// updateParams changes with searchParams, so a status click during the delay restarts the timer with fresh params.
|
||||
useEffect(() => {
|
||||
if (search.trim() === q) return undefined;
|
||||
const timer = setTimeout(() => updateParams({q: search.trim(), page: 0}), SEARCH_DEBOUNCE_MS);
|
||||
return () => clearTimeout(timer);
|
||||
}, [search, q, searchParams]);
|
||||
}, [search, q, updateParams]);
|
||||
|
||||
const onStatusChange = (value) => {
|
||||
updateParams({status: value, hidden: null, page: 0});
|
||||
@@ -169,7 +146,7 @@ export default function GameLibrary() {
|
||||
updateParams({page: target});
|
||||
};
|
||||
|
||||
const {trigger: triggerSync, busy: syncBusy} = useForceSync(load);
|
||||
const {trigger: triggerSync, busy: syncBusy} = useForceSync(sync.watch);
|
||||
|
||||
const syncing = syncBusy || sync.isRunning;
|
||||
const ready = !isLoading && !error;
|
||||
@@ -187,6 +164,7 @@ export default function GameLibrary() {
|
||||
status={sync.status}
|
||||
lastSyncedAt={sync.lastSyncedAt}
|
||||
isRunning={sync.isRunning}
|
||||
stale={sync.stale}
|
||||
/>
|
||||
<Button variant="outline" size="sm" onClick={triggerSync} disabled={syncing}>
|
||||
<RefreshCw className={syncing ? 'animate-spin' : ''}/>
|
||||
|
||||
@@ -1,20 +1,31 @@
|
||||
// @vitest-environment jsdom
|
||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
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 {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('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
|
||||
vi.mock('../hooks/useLibrarySync.js', () => ({default: () => ({status: 'OK', lastSyncedAt: null, isRunning: false})}));
|
||||
vi.mock('../hooks/useForceSync.js', () => ({default: () => ({trigger: vi.fn(), busy: false})}));
|
||||
|
||||
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 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)));
|
||||
|
||||
// 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') {
|
||||
const lib = {navigations: 0, location: null, navigate: null};
|
||||
function Spy() {
|
||||
@@ -26,11 +37,11 @@ function renderLibrary(initialEntry = '/library') {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
render(
|
||||
<MemoryRouter initialEntries={[initialEntry]}><PreferencesProvider>
|
||||
<Routes><Route path="/library" element={<><GameLibrary/><Spy/></>}/></Routes>
|
||||
</PreferencesProvider></MemoryRouter>,
|
||||
);
|
||||
const router = createMemoryRouter([{
|
||||
path: '/library',
|
||||
element: <PreferencesProvider><CurrentUserProvider><GameLibrary/><Spy/></CurrentUserProvider></PreferencesProvider>,
|
||||
}], {initialEntries: [initialEntry]});
|
||||
render(<RouterProvider router={router}/>);
|
||||
return {
|
||||
navigations: () => lib.navigations,
|
||||
params: () => new URLSearchParams(lib.location.search),
|
||||
@@ -42,7 +53,7 @@ const typeInto = (value) => fireEvent.change(screen.getByRole('searchbox'), {tar
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.setItem('tbp_session', '1');
|
||||
globalThis.fetch = vi.fn(async () => ({ok: true, status: 200, json: async () => page}));
|
||||
answer();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -52,16 +63,46 @@ afterEach(() => {
|
||||
|
||||
describe('GameLibrary search', () => {
|
||||
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},
|
||||
{appid: 620, name: 'Portal 2', playtimeMinutes: 0, status: 'BACKLOG', achievementsUnlocked: null, achievementsTotal: 5},
|
||||
]})}));
|
||||
]);
|
||||
renderLibrary();
|
||||
|
||||
expect(await screen.findByText('3/4')).toBeTruthy();
|
||||
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 () => {
|
||||
const lib = renderLibrary('/library?page=3');
|
||||
|
||||
@@ -100,7 +141,7 @@ describe('GameLibrary search', () => {
|
||||
typeInto('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(''));
|
||||
});
|
||||
@@ -161,7 +202,7 @@ describe('GameLibrary short games', () => {
|
||||
});
|
||||
|
||||
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');
|
||||
|
||||
expect(await screen.findByText('LIBRARY.NO_HIDDEN')).toBeTruthy();
|
||||
@@ -169,15 +210,11 @@ describe('GameLibrary short games', () => {
|
||||
});
|
||||
|
||||
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',
|
||||
platform: 'Nintendo Switch', imageUrl: 'https://images.igdb.com/z.jpg'};
|
||||
|
||||
it('shows the platform and the IGDB picture of a manual game', async () => {
|
||||
globalThis.fetch = answer([zelda], true);
|
||||
answer([zelda], true);
|
||||
renderLibrary();
|
||||
|
||||
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 () => {
|
||||
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);
|
||||
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 () => {
|
||||
globalThis.fetch = answer([zelda], false);
|
||||
answer([zelda], false);
|
||||
renderLibrary();
|
||||
|
||||
await screen.findByText('Zelda');
|
||||
@@ -203,7 +240,7 @@ describe('GameLibrary with games added by hand', () => {
|
||||
});
|
||||
|
||||
it('opens the add panel', async () => {
|
||||
globalThis.fetch = answer([zelda], true);
|
||||
answer([zelda], true);
|
||||
renderLibrary();
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
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 {Link, useNavigate, useSearchParams} from 'react-router-dom';
|
||||
import {useCallback, useEffect, useState} from 'react';
|
||||
import {Link, useSearchParams} from 'react-router-dom';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {toast} from 'sonner';
|
||||
import {ArrowLeft, CheckCircle2, Circle, Loader2, RotateCcw, Search, Upload, XCircle} from 'lucide-react';
|
||||
import {ArrowLeft, Loader2, Upload} 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 {Textarea} from '@/components/ui/textarea';
|
||||
import {cn} from '@/lib/utils';
|
||||
import GameSearch from '../components/GameSearch.jsx';
|
||||
import ImportHistory from '../components/import/ImportHistory.jsx';
|
||||
import ImportInput from '../components/import/ImportInput.jsx';
|
||||
import ImportView from '../components/import/ImportView.jsx';
|
||||
import PageContainer from '../components/PageContainer.jsx';
|
||||
import useSessionExpired from '../hooks/useSessionExpired.js';
|
||||
import useStartImport from '../hooks/useStartImport.js';
|
||||
import {hasSession} from '../utils/auth.js';
|
||||
import {
|
||||
checkUndo, getImport, listImports, parseCsv, parseTextList, retryImport, saveImport, undoImport,
|
||||
} from '../utils/imports.js';
|
||||
import {GAME_STATUSES, MAX_IMPORT_ROWS, MAX_PLATFORM_LENGTH} from '../utils/limits.js';
|
||||
import {hoursToMinutes, toHours} from '../utils/manualGames.js';
|
||||
import {loadFavoritePlatform, preselectPlatform} from '../utils/platforms.js';
|
||||
|
||||
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';
|
||||
import {listImports, RUNNING} from '../utils/imports.js';
|
||||
|
||||
/**
|
||||
* 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() {
|
||||
const {t} = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const expireSession = useSessionExpired();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [imports, setImports] = useState(null);
|
||||
const id = searchParams.get('id');
|
||||
@@ -59,24 +23,17 @@ export default function ImportPage() {
|
||||
const loadImports = useCallback(async () => {
|
||||
try {
|
||||
const response = await listImports();
|
||||
if (response.status === 401) {
|
||||
expireSession();
|
||||
return;
|
||||
}
|
||||
if (response.status === 401) return;
|
||||
setImports(response.ok ? (await response.json()).imports : []);
|
||||
} catch (err) {
|
||||
console.error('Import list error:', err);
|
||||
setImports([]);
|
||||
}
|
||||
}, [expireSession]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasSession()) {
|
||||
navigate('/login');
|
||||
return;
|
||||
}
|
||||
loadImports();
|
||||
}, [navigate, loadImports]);
|
||||
}, [loadImports]);
|
||||
|
||||
// Without ?id=, an import a worker still has is shown first; one to review or failed is in the history.
|
||||
const open = imports?.find((entry) => RUNNING.includes(entry.status));
|
||||
@@ -103,452 +60,14 @@ export default function ImportPage() {
|
||||
{imports === null ? (
|
||||
<Loader2 className="animate-spin text-muted-foreground"/>
|
||||
) : shownId ? (
|
||||
<ImportView key={shownId} id={shownId} onDone={done} onChanged={loadImports} expireSession={expireSession}/>
|
||||
<ImportView key={shownId} id={shownId} onDone={done} onChanged={loadImports}/>
|
||||
) : (
|
||||
<ImportInput onCreated={(created) => {
|
||||
show(created);
|
||||
loadImports();
|
||||
}}/>
|
||||
)}
|
||||
{imports?.length > 0 && <History imports={imports} onOpen={show} onChanged={loadImports} expireSession={expireSession}/>}
|
||||
{imports?.length > 0 && <ImportHistory imports={imports} onOpen={show} onChanged={loadImports}/>}
|
||||
</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