Compare commits

..
10 Commits
Author SHA1 Message Date
lobo aa5832225d refactor(import): move the import parts to components/import
Build, Test & Push Frontend / docker (push) Successful in 33s
Build, Test & Push Frontend / quality-check (push) Successful in 23s
2026-09-30 08:46:51 +02:00
lobo 28ff6eae52 refactor(settings): one card per section
The name field takes its value from the page's own load, not from the user context: the context can still be empty when the form mounts.
2026-09-30 08:46:51 +02:00
lobo 6da612bebd refactor(dashboard): load in useDashboardData, welcome and most played cards 2026-09-30 08:46:51 +02:00
lobo 05f4ca42e5 refactor(game-detail): load in useGame, hero and achievements cards 2026-09-30 08:46:51 +02:00
lobo 628920a006 feat(backlog): save only the changed fields 2026-09-30 07:57:52 +02:00
lobo 9ddc50f819 fix: abort old loads, stale sync state, warn before unsaved edits are lost 2026-09-30 07:51:09 +02:00
lobo cbafa3d540 refactor(session): one session store for login, logout and 401 2026-09-30 07:36:08 +02:00
lobo 2a22789435 test: data router, one render helper, fetchRoutes in every test 2026-09-29 16:15:50 +02:00
lobo d0ea9e900d refactor(frontend): lint, one session guard, one request helper, one user context 2026-09-29 15:40:55 +02:00
lobo 111ab1e200 fix(import): readable dropdowns in dark mode, select all and none 2026-09-29 15:10:41 +02:00
133 changed files with 4924 additions and 2459 deletions

No files matched your search

+1
View File
@@ -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
+20 -6
View File
@@ -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
```
+28
View File
@@ -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}},
];
+1312
View File
File diff suppressed because it is too large. Load diff
+6 -1
View File
@@ -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",
+6
View File
@@ -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?"
}
}
+6
View File
@@ -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?"
}
}
+20 -22
View File
@@ -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,17 +62,19 @@ function App() {
<Route path="/" element={<LandingPage/>}/>
<Route path="/login" element={<LoginPage/>}/>
<Route path="/register" element={<RegistrationPage/>}/>
<Route path="/dashboard" element={<Dashboard/>}/>
<Route path="/library" element={<GameLibrary/>}/>
<Route path="/library/:appid" element={<GameDetail/>}/>
<Route path="/wishlist" element={<WishlistPage/>}/>
<Route path="/play-next" element={<PlayNextPage/>}/>
<Route path="/stats" element={<StatsPage/>}/>
<Route path="/import" element={<ImportPage/>}/>
<Route element={<RequireSession/>}>
<Route path="/dashboard" element={<Dashboard/>}/>
<Route path="/library" element={<GameLibrary/>}/>
<Route path="/library/:appid" element={<GameDetail/>}/>
<Route path="/wishlist" element={<WishlistPage/>}/>
<Route path="/play-next" element={<PlayNextPage/>}/>
<Route path="/stats" element={<StatsPage/>}/>
<Route path="/import" element={<ImportPage/>}/>
<Route path="/settings" element={<SettingsPage/>}/>
<Route path="/admin/users" element={<AdminUsersPage/>}/>
<Route path="/admin/hints" element={<AdminHintsPage/>}/>
</Route>
<Route path="/u/:name" element={<PublicProfilePage/>}/>
<Route path="/settings" element={<SettingsPage/>}/>
<Route path="/admin/users" element={<AdminUsersPage/>}/>
<Route path="/admin/hints" element={<AdminHintsPage/>}/>
<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
View File
@@ -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'}));
});
+43
View File
@@ -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>
);
}
+6 -14
View File
@@ -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;
+13 -19
View File
@@ -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();
+2 -4
View File
@@ -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;
+10 -21
View File
@@ -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();
+81
View File
@@ -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>
);
}
+70 -76
View File
@@ -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,72 +68,75 @@ export default function BacklogCard({game, onSaved}) {
{t('BACKLOG.TITLE')}
</h2>
<div className="grid gap-5 sm:grid-cols-3">
<div className="space-y-2">
<Label htmlFor="backlog-status">{t('BACKLOG.STATUS_LABEL')}</Label>
<Select value={form.status} onValueChange={set('status')}>
<SelectTrigger id="backlog-status" className="w-full"><SelectValue/></SelectTrigger>
<SelectContent>
{GAME_STATUSES.map((s) => {
const {icon: Icon, text} = STATUS_META[s];
return (
<SelectItem key={s} value={s}>
<Icon className={text}/>
{t(`BACKLOG.STATUS.${s}`)}
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
{/* 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>
<Select value={form.status} onValueChange={set('status')}>
<SelectTrigger id="backlog-status" className="w-full"><SelectValue/></SelectTrigger>
<SelectContent>
{GAME_STATUSES.map((s) => {
const {icon: Icon, text} = STATUS_META[s];
return (
<SelectItem key={s} value={s}>
<Icon className={text}/>
{t(`BACKLOG.STATUS.${s}`)}
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="backlog-priority">{t('BACKLOG.PRIORITY_LABEL')}</Label>
<Select value={form.priority} onValueChange={set('priority')}>
<SelectTrigger id="backlog-priority" className="w-full"><SelectValue/></SelectTrigger>
<SelectContent>
<SelectItem value={NO_PRIORITY}>{t('BACKLOG.PRIORITY.NONE')}</SelectItem>
{GAME_PRIORITIES.map((p) => (
<SelectItem key={p} value={p}>{t(`BACKLOG.PRIORITY.${p}`)}</SelectItem>
<div className="space-y-2">
<Label htmlFor="backlog-priority">{t('BACKLOG.PRIORITY_LABEL')}</Label>
<Select value={form.priority} onValueChange={set('priority')}>
<SelectTrigger id="backlog-priority" className="w-full"><SelectValue/></SelectTrigger>
<SelectContent>
<SelectItem value={NO_PRIORITY}>{t('BACKLOG.PRIORITY.NONE')}</SelectItem>
{GAME_PRIORITIES.map((p) => (
<SelectItem key={p} value={p}>{t(`BACKLOG.PRIORITY.${p}`)}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label id="backlog-rating">{t('BACKLOG.RATING_LABEL')}</Label>
<div role="group" aria-labelledby="backlog-rating" className="flex h-9 items-center gap-1">
{[1, 2, 3, 4, 5].map((n) => (
<button
key={n}
type="button"
aria-label={t('BACKLOG.RATING_STARS', {count: n})}
aria-pressed={form.rating === n}
onClick={() => set('rating')(form.rating === n ? 0 : n)}
className="rounded-sm p-0.5 outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
>
<Star className={cn('size-6 transition-colors',
n <= form.rating ? 'fill-amber-400 text-amber-400' : 'text-muted-foreground/40 hover:text-amber-400')}/>
</button>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label id="backlog-rating">{t('BACKLOG.RATING_LABEL')}</Label>
<div role="group" aria-labelledby="backlog-rating" className="flex h-9 items-center gap-1">
{[1, 2, 3, 4, 5].map((n) => (
<button
key={n}
type="button"
aria-label={t('BACKLOG.RATING_STARS', {count: n})}
aria-pressed={form.rating === n}
onClick={() => set('rating')(form.rating === n ? 0 : n)}
className="rounded-sm p-0.5 outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
>
<Star className={cn('size-6 transition-colors',
n <= form.rating ? 'fill-amber-400 text-amber-400' : 'text-muted-foreground/40 hover:text-amber-400')}/>
</button>
))}
</div>
</div>
</div>
</div>
<div className="space-y-2">
<div className="flex items-baseline justify-between">
<Label htmlFor="backlog-notes">{t('BACKLOG.NOTES_LABEL')}</Label>
<span className="text-xs text-muted-foreground tabular-nums">{form.notes.length} / {MAX_NOTES_LENGTH}</span>
<div className="space-y-2">
<div className="flex items-baseline justify-between">
<Label htmlFor="backlog-notes">{t('BACKLOG.NOTES_LABEL')}</Label>
<span className="text-xs text-muted-foreground tabular-nums">{form.notes.length} / {MAX_NOTES_LENGTH}</span>
</div>
<Textarea
id="backlog-notes"
rows={3}
maxLength={MAX_NOTES_LENGTH}
placeholder={t('BACKLOG.NOTES_PLACEHOLDER')}
value={form.notes}
onChange={(e) => set('notes')(e.target.value)}
/>
</div>
<Textarea
id="backlog-notes"
rows={3}
maxLength={MAX_NOTES_LENGTH}
placeholder={t('BACKLOG.NOTES_PLACEHOLDER')}
value={form.notes}
onChange={(e) => set('notes')(e.target.value)}
/>
</div>
</fieldset>
<div className="flex justify-end gap-2">
<Button variant="ghost" onClick={() => setForm(saved)} disabled={!dirty || busy}>
+42 -4
View File
@@ -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);
});
});
+1 -6
View File
@@ -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;
+3 -3
View File
@@ -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(() => {
+4 -2
View File
@@ -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;
+7 -4
View File
@@ -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);
+69
View File
@@ -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>
);
}
+6 -11
View File
@@ -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) {
+17 -15
View File
@@ -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');
+1 -6
View File
@@ -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;
+14 -5
View File
@@ -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 path="/library/:appid" element={<FinishedHint game={ending} onChanged={() => {}} onDismissed={() => {}} {...props}/>}/>
<Route element={<RequireSession/>}>
<Route path="/library/:appid" element={<FinishedHint game={ending} onChanged={() => {}} onDismissed={() => {}} {...props}/>}/>
</Route>
<Route path="/login" element={<p>login page</p>}/>
</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'}));
+107
View File
@@ -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>
</>
);
}
+2 -6
View File
@@ -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;
+8 -3
View File
@@ -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'}});
+134
View File
@@ -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>
);
}
+1 -6
View File
@@ -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'));
+5 -5
View File
@@ -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;
+2 -7
View File
@@ -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};
}
+5 -6
View File
@@ -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});
});
+93
View File
@@ -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();
});
});
+62
View File
@@ -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>
);
}
+2
View File
@@ -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();
+3 -10
View File
@@ -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;
+7 -5
View File
@@ -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'});
});
});
+89
View File
@@ -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>
);
}
+139
View File
@@ -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>
);
}
+16
View File
@@ -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}}/>;
}
+81
View File
@@ -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();
});
});
+2 -11
View File
@@ -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;
+11 -2
View File
@@ -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">
+76
View File
@@ -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>
);
}
+71
View File
@@ -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>
);
}
+152
View File
@@ -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>
);
});
+170
View File
@@ -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
View File
@@ -1,4 +1,3 @@
import * as React from "react"
import { cva } from "class-variance-authority";
import { cn } from "@/lib/utils"
-1
View File
@@ -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
View File
@@ -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
View File
@@ -1,4 +1,3 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({
-1
View File
@@ -1,4 +1,3 @@
import * as React from "react"
import { XIcon } from "lucide-react"
import { Dialog as DialogPrimitive } from "radix-ui"
-1
View File
@@ -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
View File
@@ -1,4 +1,3 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({
-1
View File
@@ -1,4 +1,3 @@
import * as React from "react"
import { cn } from "@/lib/utils"
import { Label as LabelPrimitive } from "radix-ui"
-1
View File
@@ -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
View File
@@ -1,4 +1,3 @@
import * as React from "react"
import { cn } from "@/lib/utils"
import { Separator as SeparatorPrimitive } from "radix-ui"
-1
View File
@@ -1,4 +1,3 @@
import * as React from "react"
import { cn } from "@/lib/utils"
import { Switch as SwitchPrimitive } from "radix-ui"
-2
View File
@@ -1,5 +1,3 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }) {
-1
View File
@@ -1,4 +1,3 @@
import * as React from "react"
import { cn } from "@/lib/utils"
import { Tooltip as TooltipPrimitive } from "radix-ui"
+59
View File
@@ -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;
}
+165
View File
@@ -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();
});
});
+7 -9
View File
@@ -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 = () => {};
+5 -5
View File
@@ -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 () => {
+17
View File
@@ -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
View File
@@ -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]);
}
+54
View File
@@ -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();
});
});
+77
View File
@@ -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,
};
}
+4 -18
View File
@@ -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'));
+66
View File
@@ -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');
});
});
+39
View File
@@ -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
View File
@@ -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,
};
}
+91
View File
@@ -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);
});
});
+34
View File
@@ -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]);
}
+3 -9
View File
@@ -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]);
}
+36 -27
View File
@@ -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(() => {
+4 -9
View File
@@ -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]);
}
+17
View File
@@ -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]);
}
-24
View File
@@ -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]);
}
-39
View File
@@ -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();
});
});
+5 -8
View File
@@ -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]);
}
+5 -5
View File
@@ -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));
+58
View File
@@ -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;
}
+118
View File
@@ -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');
});
});
+3 -8
View File
@@ -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
View File
@@ -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>
);
+4 -19
View File
@@ -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?.();
+11 -10
View File
@@ -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();
+12 -25
View File
@@ -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();
+13 -5
View File
@@ -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
View File
@@ -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">
+53 -42
View File
@@ -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
View File
@@ -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});
}}/>
)}
+8 -10
View File
@@ -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'}));
+66 -56
View File
@@ -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();
});
});
+21 -43
View File
@@ -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 {
setIsLoading(false);
const url = `${import.meta.env.VITE_CORE_API_URL}/api/library/games?${libraryQuery({page, size, sort, status: listStatus, q, maxMinutes: listShortOnly ? SHORT_GAME_MINUTES : null, hidden: hiddenOnly})}`;
const {ok, status, data: json} = await request(url, {signal});
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' : ''}/>
+75 -20
View File
@@ -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
View File
@@ -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