Public Access
fix(ux): apply UX quick wins (#54)
Get started links to registration and says an admin approves accounts. Back navigation restores the library scroll; search no longer fills history. Steam and PSN unlink ask first. Any close marks news as seen. Confetti plays only when the level rises on the page. Count-ups respect reduced motion. Html lang follows the UI language. Add a 404 page, translated role and dialog close label.
This commit is contained in:
1 parent
60dcfe8992
commit
a60010e055
28 files changed
+427
-58
No files matched your search
@@ -132,6 +132,10 @@
|
|||||||
"VERIFY": "Mit Steam bestätigen",
|
"VERIFY": "Mit Steam bestätigen",
|
||||||
"CHANGE": "Anderes Steam-Konto verknüpfen",
|
"CHANGE": "Anderes Steam-Konto verknüpfen",
|
||||||
"UNLINK": "Trennen",
|
"UNLINK": "Trennen",
|
||||||
|
"UNLINK_TITLE": "Steam-Konto trennen?",
|
||||||
|
"UNLINK_EXPLAIN": "Der Steam-Sync stoppt. Deine Spiele bleiben in deiner Bibliothek. Für einen neuen Sync verknüpfst du das Konto wieder.",
|
||||||
|
"UNLINK_CONFIRM": "Ja, trennen",
|
||||||
|
"UNLINK_ERROR": "Das Steam-Konto konnte nicht getrennt werden.",
|
||||||
"VERIFIED": "Bestätigt",
|
"VERIFIED": "Bestätigt",
|
||||||
"NOT_VERIFIED": "Nicht bestätigt",
|
"NOT_VERIFIED": "Nicht bestätigt",
|
||||||
"NONE": "Noch kein Steam-Konto verknüpft. Du meldest dich bei Steam an, und Steam nennt uns deine Steam-ID; dein Steam-Passwort sehen wir nie.",
|
"NONE": "Noch kein Steam-Konto verknüpft. Du meldest dich bei Steam an, und Steam nennt uns deine Steam-ID; dein Steam-Passwort sehen wir nie.",
|
||||||
@@ -159,6 +163,9 @@
|
|||||||
"SYNC_HINT": "Jeder Sync holt neu gespielte Titel. Sichere Treffer werden hinzugefügt, unsichere warten in der Import-Prüfung.",
|
"SYNC_HINT": "Jeder Sync holt neu gespielte Titel. Sichere Treffer werden hinzugefügt, unsichere warten in der Import-Prüfung.",
|
||||||
"REVIEW": "Wartende Titel prüfen",
|
"REVIEW": "Wartende Titel prüfen",
|
||||||
"UNLINK": "Trennen",
|
"UNLINK": "Trennen",
|
||||||
|
"UNLINK_TITLE": "PlayStation-Konto trennen?",
|
||||||
|
"UNLINK_EXPLAIN": "Der PlayStation-Sync stoppt. Importierte Spiele bleiben in deiner Bibliothek. Für einen neuen Sync fügst du einen neuen NPSSO-Code ein.",
|
||||||
|
"UNLINK_CONFIRM": "Ja, trennen",
|
||||||
"UNLINKED": "PlayStation-Konto getrennt. Importierte Spiele bleiben in deiner Bibliothek.",
|
"UNLINKED": "PlayStation-Konto getrennt. Importierte Spiele bleiben in deiner Bibliothek.",
|
||||||
"INVALID": "PlayStation hat den Code nicht akzeptiert. Er ist vielleicht abgelaufen: melde dich neu an und kopiere einen neuen.",
|
"INVALID": "PlayStation hat den Code nicht akzeptiert. Er ist vielleicht abgelaufen: melde dich neu an und kopiere einen neuen.",
|
||||||
"UNAVAILABLE": "PlayStation hat nicht geantwortet. Bitte versuche es später noch einmal.",
|
"UNAVAILABLE": "PlayStation hat nicht geantwortet. Bitte versuche es später noch einmal.",
|
||||||
@@ -367,6 +374,7 @@
|
|||||||
"WELCOME": "Dein Game-Backlog unter Kontrolle",
|
"WELCOME": "Dein Game-Backlog unter Kontrolle",
|
||||||
"DESCRIPTION": "Organisiere deinen Pile of Shame. Verfolge, verwalte und erobere deinen Game-Backlog wie nie zuvor.",
|
"DESCRIPTION": "Organisiere deinen Pile of Shame. Verfolge, verwalte und erobere deinen Game-Backlog wie nie zuvor.",
|
||||||
"GET_STARTED": "Jetzt loslegen",
|
"GET_STARTED": "Jetzt loslegen",
|
||||||
|
"APPROVAL_HINT": "Ein Admin schaltet jedes neue Konto vor der ersten Anmeldung frei.",
|
||||||
"LOGIN": "Anmelden",
|
"LOGIN": "Anmelden",
|
||||||
"LOGOUT": "Abmelden"
|
"LOGOUT": "Abmelden"
|
||||||
},
|
},
|
||||||
@@ -674,6 +682,13 @@
|
|||||||
"GAMES_other": "{{count}} Spiele"
|
"GAMES_other": "{{count}} Spiele"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"DIALOG": {
|
||||||
|
"CANCEL": "Abbrechen",
|
||||||
|
"CLOSE": "Schließen"
|
||||||
|
},
|
||||||
|
"NOT_FOUND": {
|
||||||
|
"TITLE": "Seite nicht gefunden"
|
||||||
|
},
|
||||||
"ROLES": {
|
"ROLES": {
|
||||||
"USER": "Nutzer",
|
"USER": "Nutzer",
|
||||||
"AUDITOR": "Auditor",
|
"AUDITOR": "Auditor",
|
||||||
|
|||||||
@@ -132,6 +132,10 @@
|
|||||||
"VERIFY": "Verify with Steam",
|
"VERIFY": "Verify with Steam",
|
||||||
"CHANGE": "Link another Steam account",
|
"CHANGE": "Link another Steam account",
|
||||||
"UNLINK": "Unlink",
|
"UNLINK": "Unlink",
|
||||||
|
"UNLINK_TITLE": "Unlink Steam account?",
|
||||||
|
"UNLINK_EXPLAIN": "The Steam sync stops. Your games stay in your library. To sync again, link the account again.",
|
||||||
|
"UNLINK_CONFIRM": "Yes, unlink",
|
||||||
|
"UNLINK_ERROR": "Could not unlink the Steam account.",
|
||||||
"VERIFIED": "Verified",
|
"VERIFIED": "Verified",
|
||||||
"NOT_VERIFIED": "Not verified",
|
"NOT_VERIFIED": "Not verified",
|
||||||
"NONE": "No Steam account linked yet. You sign in on Steam, and Steam tells us your Steam ID; we never see your Steam password.",
|
"NONE": "No Steam account linked yet. You sign in on Steam, and Steam tells us your Steam ID; we never see your Steam password.",
|
||||||
@@ -159,6 +163,9 @@
|
|||||||
"SYNC_HINT": "Every sync brings in newly played titles. Clear matches are added; unclear ones wait in the import review.",
|
"SYNC_HINT": "Every sync brings in newly played titles. Clear matches are added; unclear ones wait in the import review.",
|
||||||
"REVIEW": "Review waiting titles",
|
"REVIEW": "Review waiting titles",
|
||||||
"UNLINK": "Unlink",
|
"UNLINK": "Unlink",
|
||||||
|
"UNLINK_TITLE": "Unlink PlayStation account?",
|
||||||
|
"UNLINK_EXPLAIN": "The PlayStation sync stops. Imported games stay in your library. To sync again, paste a new NPSSO code.",
|
||||||
|
"UNLINK_CONFIRM": "Yes, unlink",
|
||||||
"UNLINKED": "PlayStation account unlinked. Imported games stay in your library.",
|
"UNLINKED": "PlayStation account unlinked. Imported games stay in your library.",
|
||||||
"INVALID": "PlayStation did not accept the code. It may have expired: sign in again and copy a new one.",
|
"INVALID": "PlayStation did not accept the code. It may have expired: sign in again and copy a new one.",
|
||||||
"UNAVAILABLE": "PlayStation did not answer. Please try again later.",
|
"UNAVAILABLE": "PlayStation did not answer. Please try again later.",
|
||||||
@@ -367,6 +374,7 @@
|
|||||||
"WELCOME": "Control your game backlog",
|
"WELCOME": "Control your game backlog",
|
||||||
"DESCRIPTION": "Organize your pile of shame. Track, manage, and conquer your gaming backlog like never before.",
|
"DESCRIPTION": "Organize your pile of shame. Track, manage, and conquer your gaming backlog like never before.",
|
||||||
"GET_STARTED": "Get Started",
|
"GET_STARTED": "Get Started",
|
||||||
|
"APPROVAL_HINT": "An admin approves every new account before the first login.",
|
||||||
"LOGIN": "Log in",
|
"LOGIN": "Log in",
|
||||||
"LOGOUT": "Log out"
|
"LOGOUT": "Log out"
|
||||||
},
|
},
|
||||||
@@ -674,6 +682,13 @@
|
|||||||
"GAMES_other": "{{count}} games"
|
"GAMES_other": "{{count}} games"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"DIALOG": {
|
||||||
|
"CANCEL": "Cancel",
|
||||||
|
"CLOSE": "Close"
|
||||||
|
},
|
||||||
|
"NOT_FOUND": {
|
||||||
|
"TITLE": "Page not found"
|
||||||
|
},
|
||||||
"ROLES": {
|
"ROLES": {
|
||||||
"USER": "User",
|
"USER": "User",
|
||||||
"AUDITOR": "Auditor",
|
"AUDITOR": "Auditor",
|
||||||
|
|||||||
+5
-1
@@ -5,6 +5,7 @@ import {useEffect} from 'react';
|
|||||||
import LandingPage from './pages/LandingPage';
|
import LandingPage from './pages/LandingPage';
|
||||||
import LoginPage from './pages/LoginPage';
|
import LoginPage from './pages/LoginPage';
|
||||||
import ImprintPage from './pages/ImprintPage';
|
import ImprintPage from './pages/ImprintPage';
|
||||||
|
import NotFoundPage from './pages/NotFoundPage.jsx';
|
||||||
import Dashboard from "./pages/Dashboard.jsx";
|
import Dashboard from "./pages/Dashboard.jsx";
|
||||||
import RegistrationPage from './pages/RegistrationPage.jsx';
|
import RegistrationPage from './pages/RegistrationPage.jsx';
|
||||||
import ConfirmEmailPage from './pages/ConfirmEmailPage.jsx';
|
import ConfirmEmailPage from './pages/ConfirmEmailPage.jsx';
|
||||||
@@ -46,7 +47,7 @@ import {UnsavedChangesGuard} from './hooks/useUnsavedChanges.js';
|
|||||||
import {logout} from './utils/api.js';
|
import {logout} from './utils/api.js';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const {t} = useTranslation();
|
const {t, i18n} = useTranslation();
|
||||||
const {active: isAuthenticated} = useSession();
|
const {active: isAuthenticated} = useSession();
|
||||||
const {appearance, equipped, syncPreferences} = usePreferences();
|
const {appearance, equipped, syncPreferences} = usePreferences();
|
||||||
|
|
||||||
@@ -54,6 +55,8 @@ function App() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
|
useEffect(() => { document.documentElement.lang = i18n.language; }, [i18n.language]);
|
||||||
|
|
||||||
// On login and on page load; the cached look shows until Auth answers.
|
// On login and on page load; the cached look shows until Auth answers.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isAuthenticated) syncPreferences();
|
if (isAuthenticated) syncPreferences();
|
||||||
@@ -110,6 +113,7 @@ function App() {
|
|||||||
</Route>
|
</Route>
|
||||||
<Route path="/u/:name" element={<PublicProfilePage/>}/>
|
<Route path="/u/:name" element={<PublicProfilePage/>}/>
|
||||||
<Route path="/impressum" element={<ImprintPage/>}/>
|
<Route path="/impressum" element={<ImprintPage/>}/>
|
||||||
|
<Route path="*" element={<NotFoundPage/>}/>
|
||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,14 @@ afterEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('App', () => {
|
describe('App', () => {
|
||||||
|
it('sets the document language to the UI language', () => {
|
||||||
|
fetchRoutes({'GET /api/users/me': json(200, {settings: {}})});
|
||||||
|
localStorage.clear();
|
||||||
|
renderWithProviders(<App/>, {entries: ['/impressum']});
|
||||||
|
|
||||||
|
expect(document.documentElement.lang).toBe('de');
|
||||||
|
});
|
||||||
|
|
||||||
it('ends the session on a 401 that the refresh cannot fix', async () => {
|
it('ends the session on a 401 that the refresh cannot fix', async () => {
|
||||||
let expired = false;
|
let expired = false;
|
||||||
const library = () => (expired ? json(401, {}) : json(200, page));
|
const library = () => (expired ? json(401, {}) : json(200, page));
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export default function AccountInfoCard() {
|
|||||||
const {user: profile} = useCurrentUserContext();
|
const {user: profile} = useCurrentUserContext();
|
||||||
const accountRows = [
|
const accountRows = [
|
||||||
[t('SETTINGS.EMAIL'), profile?.email],
|
[t('SETTINGS.EMAIL'), profile?.email],
|
||||||
[t('SETTINGS.ROLE'), profile?.role],
|
[t('SETTINGS.ROLE'), profile?.role && t(`ROLES.${profile.role}`)],
|
||||||
[t('SETTINGS.CREATED_DATE'), formatDate(profile?.createdDate, i18n.language)],
|
[t('SETTINGS.CREATED_DATE'), formatDate(profile?.createdDate, i18n.language)],
|
||||||
[t('SETTINGS.LAST_LOGIN'), formatDate(profile?.lastLogin, i18n.language)],
|
[t('SETTINGS.LAST_LOGIN'), formatDate(profile?.lastLogin, i18n.language)],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||||
|
import {cleanup, screen} from '@testing-library/react';
|
||||||
|
import AccountInfoCard from './AccountInfoCard.jsx';
|
||||||
|
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
||||||
|
import {renderWithProviders} from '../test/renderWithProviders.jsx';
|
||||||
|
|
||||||
|
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||||
|
|
||||||
|
beforeEach(() => localStorage.setItem('tbp_session', '1'));
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('AccountInfoCard', () => {
|
||||||
|
it('shows the role by its translated name, not the enum value', async () => {
|
||||||
|
fetchRoutes({'GET /api/users/me': json(200, {email: 'a@b.de', role: 'PLATFORM_ADMIN', settings: {}})});
|
||||||
|
renderWithProviders(<AccountInfoCard/>, {path: '/settings', entries: ['/settings']});
|
||||||
|
|
||||||
|
expect(await screen.findByText('ROLES.PLATFORM_ADMIN')).toBeTruthy();
|
||||||
|
expect(screen.queryByText('PLATFORM_ADMIN')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
import {useState} from 'react';
|
import {useState} from 'react';
|
||||||
import {Link} from 'react-router-dom';
|
import {Link} from 'react-router-dom';
|
||||||
import {useTranslation} from 'react-i18next';
|
import {useTranslation} from 'react-i18next';
|
||||||
import {Gamepad, Link2, Loader2, Unlink} from 'lucide-react';
|
import {Gamepad, Link2, Loader2} from 'lucide-react';
|
||||||
import {toast} from 'sonner';
|
import {toast} from 'sonner';
|
||||||
|
|
||||||
import {Button} from '@/components/ui/button';
|
import {Button} from '@/components/ui/button';
|
||||||
import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
|
import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
|
||||||
import {Input} from '@/components/ui/input';
|
import {Input} from '@/components/ui/input';
|
||||||
|
import UnlinkDialog from './UnlinkDialog.jsx';
|
||||||
import usePsnAccount from '../hooks/usePsnAccount.js';
|
import usePsnAccount from '../hooks/usePsnAccount.js';
|
||||||
import {formatDate} from '../utils/format.js';
|
import {formatDate} from '../utils/format.js';
|
||||||
import {isExpired, linkPsn, NPSSO_PAGE_URL, unlinkPsn} from '../utils/psn.js';
|
import {isExpired, linkPsn, NPSSO_PAGE_URL, unlinkPsn} from '../utils/psn.js';
|
||||||
@@ -102,9 +103,9 @@ export default function PsnAccountCard() {
|
|||||||
<Link to={`/import?id=${account.reviewImportId}`}>{t('SETTINGS.PSN.REVIEW')}</Link>
|
<Link to={`/import?id=${account.reviewImportId}`}>{t('SETTINGS.PSN.REVIEW')}</Link>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button type="button" variant="outline" onClick={unlink} disabled={busy}>
|
<UnlinkDialog label={t('SETTINGS.PSN.UNLINK')} title={t('SETTINGS.PSN.UNLINK_TITLE')}
|
||||||
<Unlink/>{t('SETTINGS.PSN.UNLINK')}
|
description={t('SETTINGS.PSN.UNLINK_EXPLAIN')}
|
||||||
</Button>
|
confirmLabel={t('SETTINGS.PSN.UNLINK_CONFIRM')} onConfirm={unlink} disabled={busy}/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -118,13 +118,28 @@ describe('PsnAccountCard', () => {
|
|||||||
expect(screen.queryByText('SETTINGS.PSN.IMPORT')).toBeNull();
|
expect(screen.queryByText('SETTINGS.PSN.IMPORT')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('unlinks and shows the link form again', async () => {
|
it('asks before it unlinks and sends nothing on cancel', async () => {
|
||||||
|
renderCard({'GET /api/library/psn-account': json(200, linkedAccount(inDays(5)))});
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByRole('button', {name: 'SETTINGS.PSN.UNLINK'}));
|
||||||
|
|
||||||
|
expect(await screen.findByText('SETTINGS.PSN.UNLINK_EXPLAIN')).toBeTruthy();
|
||||||
|
expect(calls('DELETE')).toHaveLength(0);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', {name: 'DIALOG.CANCEL'}));
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.queryByText('SETTINGS.PSN.UNLINK_EXPLAIN')).toBeNull());
|
||||||
|
expect(calls('DELETE')).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unlinks after the confirmation and shows the link form again', async () => {
|
||||||
renderCard({
|
renderCard({
|
||||||
'GET /api/library/psn-account': json(200, linkedAccount(inDays(5))),
|
'GET /api/library/psn-account': json(200, linkedAccount(inDays(5))),
|
||||||
'DELETE /api/library/psn-account': noBody,
|
'DELETE /api/library/psn-account': noBody,
|
||||||
});
|
});
|
||||||
|
|
||||||
fireEvent.click(await screen.findByRole('button', {name: /SETTINGS.PSN.UNLINK/}));
|
fireEvent.click(await screen.findByRole('button', {name: 'SETTINGS.PSN.UNLINK'}));
|
||||||
|
fireEvent.click(await screen.findByRole('button', {name: 'SETTINGS.PSN.UNLINK_CONFIRM'}));
|
||||||
|
|
||||||
expect(await screen.findByLabelText('SETTINGS.PSN.NPSSO_LABEL')).toBeTruthy();
|
expect(await screen.findByLabelText('SETTINGS.PSN.NPSSO_LABEL')).toBeTruthy();
|
||||||
expect(toast.success).toHaveBeenCalledWith('SETTINGS.PSN.UNLINKED');
|
expect(toast.success).toHaveBeenCalledWith('SETTINGS.PSN.UNLINKED');
|
||||||
|
|||||||
@@ -2,14 +2,15 @@ import {useEffect, useState} from 'react';
|
|||||||
import {useTranslation} from 'react-i18next';
|
import {useTranslation} from 'react-i18next';
|
||||||
|
|
||||||
import {Card} from '@/components/ui/card';
|
import {Card} from '@/components/ui/card';
|
||||||
|
import useReducedMotion from '../hooks/useReducedMotion.js';
|
||||||
import {useDrillTarget} from './stats/StatDrill.jsx';
|
import {useDrillTarget} from './stats/StatDrill.jsx';
|
||||||
|
|
||||||
function useCountUp(target, durationMs = 1600) {
|
function useCountUp(target, instant, durationMs = 1600) {
|
||||||
const [value, setValue] = useState(0);
|
const [value, setValue] = useState(0);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const safeTarget = typeof target === 'number' && Number.isFinite(target) ? target : 0;
|
const safeTarget = typeof target === 'number' && Number.isFinite(target) ? target : 0;
|
||||||
if (safeTarget === 0) {
|
if (instant || safeTarget === 0) {
|
||||||
setValue(0);
|
setValue(safeTarget);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let raf;
|
let raf;
|
||||||
@@ -22,7 +23,7 @@ function useCountUp(target, durationMs = 1600) {
|
|||||||
};
|
};
|
||||||
raf = requestAnimationFrame(step);
|
raf = requestAnimationFrame(step);
|
||||||
return () => cancelAnimationFrame(raf);
|
return () => cancelAnimationFrame(raf);
|
||||||
}, [target, durationMs]);
|
}, [target, instant, durationMs]);
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,7 +33,8 @@ function useCountUp(target, durationMs = 1600) {
|
|||||||
*/
|
*/
|
||||||
export default function StatCard({label, value, icon: Icon, suffix, drill, onClick}) {
|
export default function StatCard({label, value, icon: Icon, suffix, drill, onClick}) {
|
||||||
const {i18n} = useTranslation();
|
const {i18n} = useTranslation();
|
||||||
const animated = useCountUp(value);
|
const reduce = useReducedMotion();
|
||||||
|
const animated = useCountUp(value, reduce);
|
||||||
const target = useDrillTarget({title: label, count: value, ...drill}) ?? (onClick && {onClick});
|
const target = useDrillTarget({title: label, count: value, ...drill}) ?? (onClick && {onClick});
|
||||||
const card = (
|
const card = (
|
||||||
<Card className="gap-3 p-5">
|
<Card className="gap-3 p-5">
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import {afterEach, describe, expect, it, vi} from 'vitest';
|
||||||
|
import {cleanup, render, screen} from '@testing-library/react';
|
||||||
|
import StatCard from './StatCard.jsx';
|
||||||
|
|
||||||
|
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
delete document.documentElement.dataset.reduceMotion;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('StatCard', () => {
|
||||||
|
it('shows the final number at once when the user wants reduced motion', () => {
|
||||||
|
document.documentElement.dataset.reduceMotion = 'true';
|
||||||
|
render(<StatCard label="Games" value={1234}/>);
|
||||||
|
|
||||||
|
expect(screen.getByText('1.234')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts up from 0 otherwise', () => {
|
||||||
|
render(<StatCard label="Games" value={1234}/>);
|
||||||
|
|
||||||
|
expect(screen.getByText('0')).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
import {useState} from 'react';
|
import {useState} from 'react';
|
||||||
import {useTranslation} from 'react-i18next';
|
import {useTranslation} from 'react-i18next';
|
||||||
import {BadgeCheck, CircleAlert, Gamepad2, Link2, Loader2, Unlink} from 'lucide-react';
|
import {BadgeCheck, CircleAlert, Gamepad2, Link2, Loader2} from 'lucide-react';
|
||||||
import {toast} from 'sonner';
|
import {toast} from 'sonner';
|
||||||
|
|
||||||
import {Badge} from '@/components/ui/badge';
|
import {Badge} from '@/components/ui/badge';
|
||||||
import {Button} from '@/components/ui/button';
|
import {Button} from '@/components/ui/button';
|
||||||
import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
|
import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
|
||||||
|
import UnlinkDialog from './UnlinkDialog.jsx';
|
||||||
import {markSession} from '../utils/auth.js';
|
import {markSession} from '../utils/auth.js';
|
||||||
import {startSteamLink, unlinkSteam} from '../utils/steamLink.js';
|
import {startSteamLink, unlinkSteam} from '../utils/steamLink.js';
|
||||||
|
|
||||||
@@ -36,7 +37,7 @@ export default function SteamAccountCard({steamId, verified, onUnlinked}) {
|
|||||||
const response = await unlinkSteam();
|
const response = await unlinkSteam();
|
||||||
if (response.status === 401) return;
|
if (response.status === 401) return;
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
toast.error(t('SETTINGS.STEAM_LINK.ERROR'));
|
toast.error(t('SETTINGS.STEAM_LINK.UNLINK_ERROR'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// The new access cookie has no Steam ID; the new stamp makes the header load again.
|
// The new access cookie has no Steam ID; the new stamp makes the header load again.
|
||||||
@@ -79,10 +80,10 @@ export default function SteamAccountCard({steamId, verified, onUnlinked}) {
|
|||||||
{t(!steamId ? 'SETTINGS.STEAM_LINK.LINK' : verified ? 'SETTINGS.STEAM_LINK.CHANGE' : 'SETTINGS.STEAM_LINK.VERIFY')}
|
{t(!steamId ? 'SETTINGS.STEAM_LINK.LINK' : verified ? 'SETTINGS.STEAM_LINK.CHANGE' : 'SETTINGS.STEAM_LINK.VERIFY')}
|
||||||
</Button>
|
</Button>
|
||||||
{steamId && (
|
{steamId && (
|
||||||
<Button type="button" variant="outline" onClick={unlink} disabled={busy}
|
<UnlinkDialog label={t('SETTINGS.STEAM_LINK.UNLINK')} title={t('SETTINGS.STEAM_LINK.UNLINK_TITLE')}
|
||||||
className="h-auto max-w-full whitespace-normal py-2 text-left">
|
description={t('SETTINGS.STEAM_LINK.UNLINK_EXPLAIN')}
|
||||||
<Unlink/>{t('SETTINGS.STEAM_LINK.UNLINK')}
|
confirmLabel={t('SETTINGS.STEAM_LINK.UNLINK_CONFIRM')} onConfirm={unlink} disabled={busy}
|
||||||
</Button>
|
className="h-auto max-w-full whitespace-normal py-2 text-left"/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -4,15 +4,21 @@ import {useTranslation} from 'react-i18next';
|
|||||||
import {ArrowRight, Gamepad2} from 'lucide-react';
|
import {ArrowRight, Gamepad2} from 'lucide-react';
|
||||||
|
|
||||||
import {Card} from '@/components/ui/card';
|
import {Card} from '@/components/ui/card';
|
||||||
|
import useReducedMotion from '../hooks/useReducedMotion.js';
|
||||||
|
|
||||||
const DURATION_MS = 1000;
|
const DURATION_MS = 1000;
|
||||||
const COUNT_CLASS = 'block text-5xl font-bold tracking-tight tabular-nums';
|
const COUNT_CLASS = 'block text-5xl font-bold tracking-tight tabular-nums';
|
||||||
|
|
||||||
const SteamGameCount = ({targetCount}) => {
|
const SteamGameCount = ({targetCount}) => {
|
||||||
const {t} = useTranslation();
|
const {t} = useTranslation();
|
||||||
|
const reduce = useReducedMotion();
|
||||||
const [count, setCount] = useState(0);
|
const [count, setCount] = useState(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (reduce) {
|
||||||
|
setCount(targetCount);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
let frame;
|
let frame;
|
||||||
let startTime;
|
let startTime;
|
||||||
const animate = (timestamp) => {
|
const animate = (timestamp) => {
|
||||||
@@ -25,7 +31,7 @@ const SteamGameCount = ({targetCount}) => {
|
|||||||
};
|
};
|
||||||
frame = requestAnimationFrame(animate);
|
frame = requestAnimationFrame(animate);
|
||||||
return () => cancelAnimationFrame(frame);
|
return () => cancelAnimationFrame(frame);
|
||||||
}, [targetCount]);
|
}, [targetCount, reduce]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="relative justify-between overflow-hidden p-6">
|
<Card className="relative justify-between overflow-hidden p-6">
|
||||||
|
|||||||
@@ -7,7 +7,10 @@ import SteamGameCount from './SteamGameCount.jsx';
|
|||||||
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||||
|
|
||||||
describe('SteamGameCount', () => {
|
describe('SteamGameCount', () => {
|
||||||
afterEach(cleanup);
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
delete document.documentElement.dataset.reduceMotion;
|
||||||
|
});
|
||||||
|
|
||||||
it('linksTheCountToTheLibraryOnlyWhenThereAreGames', () => {
|
it('linksTheCountToTheLibraryOnlyWhenThereAreGames', () => {
|
||||||
const {container, rerender} = render(<MemoryRouter><SteamGameCount targetCount={0}/></MemoryRouter>);
|
const {container, rerender} = render(<MemoryRouter><SteamGameCount targetCount={0}/></MemoryRouter>);
|
||||||
@@ -17,4 +20,12 @@ describe('SteamGameCount', () => {
|
|||||||
expect(container.querySelectorAll('a[href="/library"]')).toHaveLength(2);
|
expect(container.querySelectorAll('a[href="/library"]')).toHaveLength(2);
|
||||||
expect(screen.getByText('DASHBOARD.STEAM_GAMES')).toBeTruthy();
|
expect(screen.getByText('DASHBOARD.STEAM_GAMES')).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('showsTheFinalCountAtOnceUnderReducedMotion', () => {
|
||||||
|
document.documentElement.dataset.reduceMotion = 'true';
|
||||||
|
|
||||||
|
render(<MemoryRouter><SteamGameCount targetCount={123}/></MemoryRouter>);
|
||||||
|
|
||||||
|
expect(screen.getByText('123')).toBeTruthy();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import {useTranslation} from 'react-i18next';
|
||||||
|
import {Unlink} from 'lucide-react';
|
||||||
|
|
||||||
|
import {Button} from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Unlink" button that asks first. The description says what the unlink does and does not do.
|
||||||
|
*/
|
||||||
|
export default function UnlinkDialog({label, title, description, confirmLabel, onConfirm, disabled, className}) {
|
||||||
|
const {t} = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button type="button" variant="outline" disabled={disabled} className={className}>
|
||||||
|
<Unlink/>{label}
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{title}</DialogTitle>
|
||||||
|
<DialogDescription>{description}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button type="button" variant="outline">{t('DIALOG.CANCEL')}</Button>
|
||||||
|
</DialogClose>
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button type="button" variant="destructive" onClick={onConfirm}>{confirmLabel}</Button>
|
||||||
|
</DialogClose>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,8 +8,7 @@ import NewsEntry from './NewsEntry.jsx';
|
|||||||
import {fetchNews, markNewsSeen} from '../utils/news.js';
|
import {fetchNews, markNewsSeen} from '../utils/news.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The news entries the user has not seen, newest first. Only "Got it" marks them seen: closing the dialog any other
|
* The news entries the user has not seen, newest first. Any close (Got it, X, Esc, click outside) marks them seen.
|
||||||
* way shows them again on the next dashboard visit.
|
|
||||||
*/
|
*/
|
||||||
export default function WhatsNewDialog() {
|
export default function WhatsNewDialog() {
|
||||||
const {t} = useTranslation();
|
const {t} = useTranslation();
|
||||||
@@ -39,7 +38,7 @@ export default function WhatsNewDialog() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={(next) => { if (!next) gotIt(); }}>
|
||||||
<DialogContent className="max-h-[90vh] overflow-y-auto" aria-describedby={undefined}>
|
<DialogContent className="max-h-[90vh] overflow-y-auto" aria-describedby={undefined}>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{t('NEWS.TITLE')}</DialogTitle>
|
<DialogTitle>{t('NEWS.TITLE')}</DialogTitle>
|
||||||
|
|||||||
@@ -50,13 +50,24 @@ describe('WhatsNewDialog', () => {
|
|||||||
expect(screen.queryByRole('dialog')).toBeNull();
|
expect(screen.queryByRole('dialog')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('closes with X without marking anything seen', async () => {
|
it('marks the entries seen when closed with X', async () => {
|
||||||
await showNews(NEW);
|
await showNews(NEW);
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', {name: 'Close'}));
|
fireEvent.click(screen.getByRole('button', {name: 'DIALOG.CLOSE'}));
|
||||||
|
await act(async () => undefined);
|
||||||
|
|
||||||
expect(screen.queryByRole('dialog')).toBeNull();
|
expect(screen.queryByRole('dialog')).toBeNull();
|
||||||
expect(putCalls()).toEqual([]);
|
expect(putCalls()).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks the entries seen when closed with Esc', async () => {
|
||||||
|
await showNews(NEW);
|
||||||
|
|
||||||
|
fireEvent.keyDown(screen.getByRole('dialog'), {key: 'Escape'});
|
||||||
|
await act(async () => undefined);
|
||||||
|
|
||||||
|
expect(screen.queryByRole('dialog')).toBeNull();
|
||||||
|
expect(putCalls()).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { XIcon } from "lucide-react"
|
import { XIcon } from "lucide-react"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
import { Dialog as DialogPrimitive } from "radix-ui"
|
import { Dialog as DialogPrimitive } from "radix-ui"
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
@@ -26,6 +27,7 @@ function DialogContent({
|
|||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation()
|
||||||
return (
|
return (
|
||||||
<DialogPrimitive.Portal>
|
<DialogPrimitive.Portal>
|
||||||
<DialogPrimitive.Overlay
|
<DialogPrimitive.Overlay
|
||||||
@@ -43,7 +45,7 @@ function DialogContent({
|
|||||||
<DialogPrimitive.Close
|
<DialogPrimitive.Close
|
||||||
className="absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:outline-hidden focus-visible:ring-2 focus-visible:ring-ring">
|
className="absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:outline-hidden focus-visible:ring-2 focus-visible:ring-ring">
|
||||||
<XIcon className="size-4" />
|
<XIcon className="size-4" />
|
||||||
<span className="sr-only">Close</span>
|
<span className="sr-only">{t('DIALOG.CLOSE')}</span>
|
||||||
</DialogPrimitive.Close>
|
</DialogPrimitive.Close>
|
||||||
</DialogPrimitive.Content>
|
</DialogPrimitive.Content>
|
||||||
</DialogPrimitive.Portal>
|
</DialogPrimitive.Portal>
|
||||||
|
|||||||
+2
-1
@@ -1,6 +1,6 @@
|
|||||||
import {StrictMode} from 'react';
|
import {StrictMode} from 'react';
|
||||||
import {createRoot} from 'react-dom/client';
|
import {createRoot} from 'react-dom/client';
|
||||||
import {createBrowserRouter, RouterProvider} from 'react-router-dom';
|
import {createBrowserRouter, RouterProvider, ScrollRestoration} from 'react-router-dom';
|
||||||
import App from './App.jsx';
|
import App from './App.jsx';
|
||||||
import {PreferencesProvider} from './context/PreferencesContext.jsx';
|
import {PreferencesProvider} from './context/PreferencesContext.jsx';
|
||||||
import {CurrentUserProvider} from './context/CurrentUserContext.jsx';
|
import {CurrentUserProvider} from './context/CurrentUserContext.jsx';
|
||||||
@@ -14,6 +14,7 @@ const router = createBrowserRouter([{
|
|||||||
<PreferencesProvider>
|
<PreferencesProvider>
|
||||||
<CurrentUserProvider>
|
<CurrentUserProvider>
|
||||||
<App/>
|
<App/>
|
||||||
|
<ScrollRestoration/>
|
||||||
</CurrentUserProvider>
|
</CurrentUserProvider>
|
||||||
</PreferencesProvider>
|
</PreferencesProvider>
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -482,14 +482,26 @@ describe('GameDetail confetti', () => {
|
|||||||
return container.querySelector('canvas[data-size]');
|
return container.querySelector('canvas[data-size]');
|
||||||
};
|
};
|
||||||
|
|
||||||
it('plays a small one for a finished game', async () => {
|
it('plays nothing on opening a finished game', async () => {
|
||||||
expect((await opened({status: 'FINISHED'})).dataset.size).toBe('small');
|
expect(await opened({status: 'FINISHED'})).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('plays a big one when every achievement is unlocked too', async () => {
|
it('plays nothing on opening a completed game', async () => {
|
||||||
const canvas = await opened({status: 'COMPLETED', hasAchievements: true, statsVisible: true,
|
expect(await opened({status: 'COMPLETED', hasAchievements: true, statsVisible: true,
|
||||||
achievementsTotal: 3, achievementsUnlocked: 3});
|
achievementsTotal: 3, achievementsUnlocked: 3})).toBeNull();
|
||||||
expect(canvas.dataset.size).toBe('big');
|
});
|
||||||
|
|
||||||
|
it('plays a small one when the user sets the game to finished here', async () => {
|
||||||
|
answer({...game, status: 'PLAYING', finishedHint: {type: 'ENDING', achievement: 'Survivor'}}, {
|
||||||
|
'PUT /api/library/games/220/status': (url, init) => json(200, {...game, ...JSON.parse(init.body), finishedHint: null}),
|
||||||
|
});
|
||||||
|
const {container} = renderDetail();
|
||||||
|
await screen.findByText('Half-Life 2');
|
||||||
|
expect(container.querySelector('canvas[data-size]')).toBeNull();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', {name: 'GAME_DETAIL.SET_FINISHED'}));
|
||||||
|
|
||||||
|
await waitFor(() => expect(container.querySelector('canvas[data-size="small"]')).toBeTruthy());
|
||||||
});
|
});
|
||||||
|
|
||||||
it('plays nothing for a game not finished', async () => {
|
it('plays nothing for a game not finished', async () => {
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import {coverEffect} from '../utils/rewards.js';
|
|||||||
import {apiFetch, request} from '../utils/api.js';
|
import {apiFetch, request} from '../utils/api.js';
|
||||||
import useAbortPrevious from '../hooks/useAbortPrevious.js';
|
import useAbortPrevious from '../hooks/useAbortPrevious.js';
|
||||||
import {platformLabel} from '../utils/platforms.js';
|
import {platformLabel} from '../utils/platforms.js';
|
||||||
|
import {sessionStamp} from '../utils/auth.js';
|
||||||
|
|
||||||
const PAGE_SIZES = [20, 40, 80];
|
const PAGE_SIZES = [20, 40, 80];
|
||||||
const DEFAULT_SIZE = 20;
|
const DEFAULT_SIZE = 20;
|
||||||
@@ -45,6 +46,16 @@ const SORT_OPTIONS = [
|
|||||||
{value: 'priority,desc', labelKey: 'LIBRARY.SORT_PRIORITY'},
|
{value: 'priority,desc', labelKey: 'LIBRARY.SORT_PRIORITY'},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Last loaded page, so a back navigation from a game renders at once and the scroll position can be restored.
|
||||||
|
let lastPage = {query: null, data: null, stamp: null};
|
||||||
|
|
||||||
|
// A new login gets a new stamp, so one user's rows never show for the next.
|
||||||
|
const cachedFor = (query) => (lastPage.query === query && lastPage.stamp === sessionStamp() ? lastPage.data : null);
|
||||||
|
|
||||||
|
export function clearLibraryPageCache() {
|
||||||
|
lastPage = {query: null, data: null, stamp: null};
|
||||||
|
}
|
||||||
|
|
||||||
export default function GameLibrary() {
|
export default function GameLibrary() {
|
||||||
const {t} = useTranslation();
|
const {t} = useTranslation();
|
||||||
const {equipped} = usePreferences();
|
const {equipped} = usePreferences();
|
||||||
@@ -73,16 +84,18 @@ export default function GameLibrary() {
|
|||||||
const [canSync, setCanSync] = useState(true);
|
const [canSync, setCanSync] = useState(true);
|
||||||
const [ownSources, setOwnSources] = useState([]);
|
const [ownSources, setOwnSources] = useState([]);
|
||||||
|
|
||||||
const [data, setData] = useState({content: [], page: 0, size, totalElements: 0, totalPages: 0});
|
const query = libraryQuery({page, size, sort, status: listStatus, q, maxMinutes: listShortOnly ? SHORT_GAME_MINUTES : null, hidden: hiddenOnly, source: sourceKey});
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const cached = cachedFor(query);
|
||||||
|
const [data, setData] = useState(cached ?? {content: [], page: 0, size, totalElements: 0, totalPages: 0});
|
||||||
|
const [isLoading, setIsLoading] = useState(!cached);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
const nextSignal = useAbortPrevious();
|
const nextSignal = useAbortPrevious();
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
const signal = nextSignal();
|
const signal = nextSignal();
|
||||||
setIsLoading(true);
|
setIsLoading(!cachedFor(query));
|
||||||
setError(null);
|
setError(null);
|
||||||
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, source: sourceKey})}`;
|
const url = `${import.meta.env.VITE_CORE_API_URL}/api/library/games?${query}`;
|
||||||
const {ok, status, data: json} = await request(url, {signal});
|
const {ok, status, data: json} = await request(url, {signal});
|
||||||
if (signal.aborted) return;
|
if (signal.aborted) return;
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
@@ -91,8 +104,9 @@ export default function GameLibrary() {
|
|||||||
setError(status === 0 ? 'INFO.ERROR.NETWORK_FAILURE' : 'LIBRARY.ERROR_LOAD');
|
setError(status === 0 ? 'INFO.ERROR.NETWORK_FAILURE' : 'LIBRARY.ERROR_LOAD');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
lastPage = {query, data: json, stamp: sessionStamp()};
|
||||||
setData(json);
|
setData(json);
|
||||||
}, [nextSignal, page, size, sort, listStatus, q, listShortOnly, hiddenOnly, sourceKey]);
|
}, [nextSignal, query]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
load();
|
load();
|
||||||
@@ -129,10 +143,10 @@ export default function GameLibrary() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const updateParams = useCallback((next) => {
|
const updateParams = useCallback((next, options) => {
|
||||||
const params = new URLSearchParams(searchParams);
|
const params = new URLSearchParams(searchParams);
|
||||||
Object.entries(next).forEach(([k, v]) => (v === null || v === '' ? params.delete(k) : params.set(k, String(v))));
|
Object.entries(next).forEach(([k, v]) => (v === null || v === '' ? params.delete(k) : params.set(k, String(v))));
|
||||||
setSearchParams(params);
|
setSearchParams(params, options);
|
||||||
}, [searchParams, setSearchParams]);
|
}, [searchParams, setSearchParams]);
|
||||||
|
|
||||||
// Back/forward navigation changes q without typing. Keeps a trailing space the user is still typing.
|
// Back/forward navigation changes q without typing. Keeps a trailing space the user is still typing.
|
||||||
@@ -143,7 +157,7 @@ export default function GameLibrary() {
|
|||||||
// updateParams changes with searchParams, so a status click during the delay restarts the timer with fresh params.
|
// updateParams changes with searchParams, so a status click during the delay restarts the timer with fresh params.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (search.trim() === q) return undefined;
|
if (search.trim() === q) return undefined;
|
||||||
const timer = setTimeout(() => updateParams({q: search.trim(), page: 0}), SEARCH_DEBOUNCE_MS);
|
const timer = setTimeout(() => updateParams({q: search.trim(), page: 0}, {replace: true}), SEARCH_DEBOUNCE_MS);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [search, q, updateParams]);
|
}, [search, q, updateParams]);
|
||||||
|
|
||||||
|
|||||||
@@ -3,11 +3,12 @@ import {StrictMode} from 'react';
|
|||||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||||
import {act, cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
|
import {act, cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
|
||||||
import {createMemoryRouter, RouterProvider, useLocation, useNavigate} from 'react-router-dom';
|
import {createMemoryRouter, RouterProvider, useLocation, useNavigate} from 'react-router-dom';
|
||||||
import GameLibrary from './GameLibrary.jsx';
|
import GameLibrary, {clearLibraryPageCache} from './GameLibrary.jsx';
|
||||||
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
|
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
|
||||||
import {CurrentUserProvider} from '../context/CurrentUserContext.jsx';
|
import {CurrentUserProvider} from '../context/CurrentUserContext.jsx';
|
||||||
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
||||||
import {renderWithProviders} from '../test/renderWithProviders.jsx';
|
import {renderWithProviders} from '../test/renderWithProviders.jsx';
|
||||||
|
import {clearSession, markSession} from '../utils/auth.js';
|
||||||
import {toast} from 'sonner';
|
import {toast} from 'sonner';
|
||||||
|
|
||||||
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||||
@@ -48,6 +49,7 @@ function renderLibrary(initialEntry = '/library') {
|
|||||||
}], {initialEntries: [initialEntry]});
|
}], {initialEntries: [initialEntry]});
|
||||||
render(<RouterProvider router={router}/>);
|
render(<RouterProvider router={router}/>);
|
||||||
return {
|
return {
|
||||||
|
router,
|
||||||
navigations: () => lib.navigations,
|
navigations: () => lib.navigations,
|
||||||
params: () => new URLSearchParams(lib.location.search),
|
params: () => new URLSearchParams(lib.location.search),
|
||||||
back: () => lib.navigate(-1),
|
back: () => lib.navigate(-1),
|
||||||
@@ -57,6 +59,7 @@ function renderLibrary(initialEntry = '/library') {
|
|||||||
const typeInto = (value) => fireEvent.change(screen.getByRole('searchbox'), {target: {value}});
|
const typeInto = (value) => fireEvent.change(screen.getByRole('searchbox'), {target: {value}});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
clearLibraryPageCache();
|
||||||
localStorage.setItem('tbp_session', '1');
|
localStorage.setItem('tbp_session', '1');
|
||||||
answer();
|
answer();
|
||||||
});
|
});
|
||||||
@@ -66,6 +69,42 @@ afterEach(() => {
|
|||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('GameLibrary page cache', () => {
|
||||||
|
it('renders the last loaded rows at once on return, without a skeleton', async () => {
|
||||||
|
renderLibrary();
|
||||||
|
await screen.findByText('Half-Life 2');
|
||||||
|
cleanup();
|
||||||
|
fetchRoutes({
|
||||||
|
'GET /api/users/me': json(200, {settings: {}}),
|
||||||
|
'GET /api/library/games': () => new Promise(() => {}),
|
||||||
|
'GET /api/library/games-count': json(200, {count: 1, hasSteamId: true, canSync: true, sources: []}),
|
||||||
|
});
|
||||||
|
|
||||||
|
renderLibrary();
|
||||||
|
|
||||||
|
expect(screen.getByText('Half-Life 2')).toBeTruthy();
|
||||||
|
expect(document.querySelector('[data-slot="skeleton"]')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
// REVIEW-FINDING: the module cache survives a logout, so the next login on the same tab renders the previous user's rows
|
||||||
|
it('reviewFinding_cacheDoesNotOutliveTheSession', async () => {
|
||||||
|
renderLibrary();
|
||||||
|
await screen.findByText('Half-Life 2');
|
||||||
|
cleanup();
|
||||||
|
clearSession();
|
||||||
|
markSession();
|
||||||
|
fetchRoutes({
|
||||||
|
'GET /api/users/me': json(200, {settings: {}}),
|
||||||
|
'GET /api/library/games': () => new Promise(() => {}),
|
||||||
|
'GET /api/library/games-count': json(200, {count: 1, hasSteamId: true, canSync: true, sources: []}),
|
||||||
|
});
|
||||||
|
|
||||||
|
renderLibrary();
|
||||||
|
|
||||||
|
expect(screen.queryByText('Half-Life 2')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('GameLibrary search', () => {
|
describe('GameLibrary search', () => {
|
||||||
it('shows the coming badge only for a game that is not released yet', async () => {
|
it('shows the coming badge only for a game that is not released yet', async () => {
|
||||||
answer([
|
answer([
|
||||||
@@ -163,14 +202,26 @@ describe('GameLibrary search', () => {
|
|||||||
expect(lib.params().get('status')).toBe('DROPPED');
|
expect(lib.params().get('status')).toBe('DROPPED');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('replaces the history entry when the search is typed', async () => {
|
||||||
|
const lib = renderLibrary();
|
||||||
|
|
||||||
|
typeInto('half');
|
||||||
|
await waitFor(() => expect(lib.params().get('q')).toBe('half'));
|
||||||
|
|
||||||
|
expect(lib.router.state.historyAction).toBe('REPLACE');
|
||||||
|
});
|
||||||
|
|
||||||
it('shows the URL search again after back navigation', async () => {
|
it('shows the URL search again after back navigation', async () => {
|
||||||
const lib = renderLibrary();
|
const lib = renderLibrary();
|
||||||
|
fireEvent.click(screen.getByRole('button', {name: 'BACKLOG.STATUS.DROPPED'}));
|
||||||
|
await waitFor(() => expect(lib.params().get('status')).toBe('DROPPED'));
|
||||||
typeInto('half');
|
typeInto('half');
|
||||||
await waitFor(() => expect(lib.params().get('q')).toBe('half'));
|
await waitFor(() => expect(lib.params().get('q')).toBe('half'));
|
||||||
|
|
||||||
await act(async () => lib.back());
|
await act(async () => lib.back());
|
||||||
|
|
||||||
await waitFor(() => expect(screen.getByRole('searchbox').value).toBe(''));
|
await waitFor(() => expect(screen.getByRole('searchbox').value).toBe(''));
|
||||||
|
expect(lib.params().get('status')).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -131,9 +131,10 @@ export default function LandingPage() {
|
|||||||
{active ? (
|
{active ? (
|
||||||
<Link to="/dashboard">{t('NAV.TO_DASHBOARD')}<ArrowRight/></Link>
|
<Link to="/dashboard">{t('NAV.TO_DASHBOARD')}<ArrowRight/></Link>
|
||||||
) : (
|
) : (
|
||||||
<Link to="/login">{t('MAIN.GET_STARTED')}<ArrowRight/></Link>
|
<Link to="/register">{t('MAIN.GET_STARTED')}<ArrowRight/></Link>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
{!active && <p className="mt-4 text-sm text-muted-foreground">{t('MAIN.APPROVAL_HINT')}</p>}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,21 @@ describe('LandingPage', () => {
|
|||||||
expect(screen.getByText('2025')).toBeTruthy();
|
expect(screen.getByText('2025')).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('links "get started" to the registration and shows the approval hint, but not when logged in', async () => {
|
||||||
|
answer({});
|
||||||
|
const {unmount} = renderPage();
|
||||||
|
|
||||||
|
expect(screen.getByRole('link', {name: /MAIN.GET_STARTED/}).getAttribute('href')).toBe('/register');
|
||||||
|
expect(screen.getByText('MAIN.APPROVAL_HINT')).toBeTruthy();
|
||||||
|
await vi.waitFor(() => expect(fetch).toHaveBeenCalled());
|
||||||
|
unmount();
|
||||||
|
|
||||||
|
session.active = true;
|
||||||
|
renderPage();
|
||||||
|
expect(screen.queryByText('MAIN.APPROVAL_HINT')).toBeNull();
|
||||||
|
await vi.waitFor(() => expect(fetch).toHaveBeenCalled());
|
||||||
|
});
|
||||||
|
|
||||||
it('hides the three blocks while their lists are empty', async () => {
|
it('hides the three blocks while their lists are empty', async () => {
|
||||||
answer({gamesPerPlatform: [], topGenres: [], finishedPerYear: []});
|
answer({gamesPerPlatform: [], topGenres: [], finishedPerYear: []});
|
||||||
renderPage();
|
renderPage();
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import {Link} from 'react-router-dom';
|
||||||
|
import {useTranslation} from 'react-i18next';
|
||||||
|
|
||||||
|
import {Button} from '@/components/ui/button';
|
||||||
|
import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
|
||||||
|
import useSession from '../hooks/useSession.js';
|
||||||
|
|
||||||
|
export default function NotFoundPage() {
|
||||||
|
const {t} = useTranslation();
|
||||||
|
const {active} = useSession();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full max-w-3xl mx-auto p-4 md:p-8">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-3xl font-bold tracking-tight">{t('NOT_FOUND.TITLE')}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Button asChild>
|
||||||
|
{active ? <Link to="/dashboard">{t('NAV.TO_DASHBOARD')}</Link> : <Link to="/">{t('PROFILE.TO_START')}</Link>}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||||
|
import {cleanup, screen, waitFor} from '@testing-library/react';
|
||||||
|
import App from '../App.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})}));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fetchRoutes({
|
||||||
|
'GET /api/users/me': json(200, {settings: {}}),
|
||||||
|
'GET /api/rewards/wallet': json(200, {balance: 0, catalog: [], owned: [], equipped: {}, history: []}),
|
||||||
|
'GET /api/library/games-count': json(200, {hasSteamId: true}),
|
||||||
|
'GET /api/library/steam-profile': json(200, null),
|
||||||
|
'GET /api/inbox': json(200, {results: [], unread: 0}),
|
||||||
|
'GET /api/library/questions/count': json(200, {count: 0}),
|
||||||
|
'GET /api/library/removed-games': json(200, {results: []}),
|
||||||
|
'GET /api/news': json(200, {results: []}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('NotFoundPage', () => {
|
||||||
|
it('shows a not-found page with a link to the start page without a session', async () => {
|
||||||
|
renderWithProviders(<App/>, {entries: ['/no-such-page']});
|
||||||
|
|
||||||
|
expect(await screen.findByText('NOT_FOUND.TITLE')).toBeTruthy();
|
||||||
|
expect(screen.getByRole('link', {name: 'PROFILE.TO_START'}).getAttribute('href')).toBe('/');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('links to the dashboard with a session', async () => {
|
||||||
|
localStorage.setItem('tbp_session', '1');
|
||||||
|
renderWithProviders(<App/>, {entries: ['/no-such-page']});
|
||||||
|
|
||||||
|
await screen.findByText('NOT_FOUND.TITLE');
|
||||||
|
await waitFor(() => expect(screen.getAllByRole('link', {name: 'NAV.TO_DASHBOARD'}).some(
|
||||||
|
(link) => link.getAttribute('href') === '/dashboard')).toBe(true));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -24,11 +24,11 @@ const calls = (method) => fetch.mock.calls.filter(([, init]) => init?.method ===
|
|||||||
|
|
||||||
const noBody = {ok: true, status: 204, json: async () => { throw new SyntaxError('no body'); }};
|
const noBody = {ok: true, status: 204, json: async () => { throw new SyntaxError('no body'); }};
|
||||||
|
|
||||||
function renderPage(me, {redirectUrl = STEAM_URL, entry = '/settings'} = {}) {
|
function renderPage(me, {redirectUrl = STEAM_URL, entry = '/settings', unlinkAnswer = noBody} = {}) {
|
||||||
fetchRoutes({
|
fetchRoutes({
|
||||||
'GET /api/users/me': json(200, me),
|
'GET /api/users/me': json(200, me),
|
||||||
'POST /api/users/steam-link': json(200, {redirectUrl}),
|
'POST /api/users/steam-link': json(200, {redirectUrl}),
|
||||||
'DELETE /api/users/steam-link': noBody,
|
'DELETE /api/users/steam-link': unlinkAnswer,
|
||||||
'PUT /api/users/settings': noBody,
|
'PUT /api/users/settings': noBody,
|
||||||
'POST /api/auth/refresh': noBody,
|
'POST /api/auth/refresh': noBody,
|
||||||
'GET /api/version': json(404, {}),
|
'GET /api/version': json(404, {}),
|
||||||
@@ -97,15 +97,39 @@ describe('SettingsPage Steam account', () => {
|
|||||||
expect(calls('POST').some(([url]) => url.endsWith('/api/auth/refresh'))).toBe(false);
|
expect(calls('POST').some(([url]) => url.endsWith('/api/auth/refresh'))).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('unlinks the Steam account', async () => {
|
it('asks before it unlinks and sends nothing on cancel', async () => {
|
||||||
renderPage(profile({steamIdVerified: true}));
|
renderPage(profile({steamIdVerified: true}));
|
||||||
|
|
||||||
fireEvent.click(await screen.findByRole('button', {name: /SETTINGS.STEAM_LINK.UNLINK/}));
|
fireEvent.click(await screen.findByRole('button', {name: 'SETTINGS.STEAM_LINK.UNLINK'}));
|
||||||
|
|
||||||
|
expect(await screen.findByText('SETTINGS.STEAM_LINK.UNLINK_EXPLAIN')).toBeTruthy();
|
||||||
|
expect(calls('DELETE')).toHaveLength(0);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', {name: 'DIALOG.CANCEL'}));
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.queryByText('SETTINGS.STEAM_LINK.UNLINK_EXPLAIN')).toBeNull());
|
||||||
|
expect(calls('DELETE')).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unlinks the Steam account after the confirmation', async () => {
|
||||||
|
renderPage(profile({steamIdVerified: true}));
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByRole('button', {name: 'SETTINGS.STEAM_LINK.UNLINK'}));
|
||||||
|
fireEvent.click(await screen.findByRole('button', {name: 'SETTINGS.STEAM_LINK.UNLINK_CONFIRM'}));
|
||||||
|
|
||||||
expect(await screen.findByText('SETTINGS.STEAM_LINK.NONE')).toBeTruthy();
|
expect(await screen.findByText('SETTINGS.STEAM_LINK.NONE')).toBeTruthy();
|
||||||
expect(calls('DELETE')[0][0]).toMatch(/\/api\/users\/steam-link$/);
|
expect(calls('DELETE')[0][0]).toMatch(/\/api\/users\/steam-link$/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('says the unlink failed with its own message', async () => {
|
||||||
|
renderPage(profile({steamIdVerified: true}), {unlinkAnswer: json(500, {})});
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByRole('button', {name: 'SETTINGS.STEAM_LINK.UNLINK'}));
|
||||||
|
fireEvent.click(await screen.findByRole('button', {name: 'SETTINGS.STEAM_LINK.UNLINK_CONFIRM'}));
|
||||||
|
|
||||||
|
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('SETTINGS.STEAM_LINK.UNLINK_ERROR'));
|
||||||
|
});
|
||||||
|
|
||||||
it('never sends a Steam ID with the settings', async () => {
|
it('never sends a Steam ID with the settings', async () => {
|
||||||
renderPage(profile());
|
renderPage(profile());
|
||||||
|
|
||||||
|
|||||||
@@ -15,15 +15,15 @@ export const celebrationLevel = (game) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The confetti to play when the page shows this game: on opening a finished game, and when the level rises on the
|
* The confetti to play: only when the level rises while the page shows the game (the user sets Finished or completes
|
||||||
* same page (e.g. the user sets FINISHED). A reload of the same game plays nothing.
|
* it here). Opening a finished game and a reload play nothing.
|
||||||
*
|
*
|
||||||
* @param before {appid, level} of the game shown before, null on opening the page
|
* @param before {appid, level} of the game shown before, null on opening the page
|
||||||
* @returns 'small', 'big' or null
|
* @returns 'small', 'big' or null
|
||||||
*/
|
*/
|
||||||
export const celebrationFor = (before, game) => {
|
export const celebrationFor = (before, game) => {
|
||||||
|
if (before?.appid !== game.appid) return null;
|
||||||
const level = celebrationLevel(game);
|
const level = celebrationLevel(game);
|
||||||
const from = before?.appid === game.appid ? before.level : 0;
|
if (level <= before.level) return null;
|
||||||
if (level <= from) return null;
|
|
||||||
return level === 2 ? 'big' : 'small';
|
return level === 2 ? 'big' : 'small';
|
||||||
};
|
};
|
||||||
@@ -19,9 +19,9 @@ describe('celebrationLevel', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('celebrationFor', () => {
|
describe('celebrationFor', () => {
|
||||||
it('plays on opening a finished game', () => {
|
it('plays nothing on opening a game, finished or not', () => {
|
||||||
expect(celebrationFor(null, game('FINISHED'))).toBe('small');
|
expect(celebrationFor(null, game('FINISHED'))).toBeNull();
|
||||||
expect(celebrationFor(null, game('COMPLETED', 4, 4))).toBe('big');
|
expect(celebrationFor(null, game('COMPLETED', 4, 4))).toBeNull();
|
||||||
expect(celebrationFor(null, game('BACKLOG'))).toBeNull();
|
expect(celebrationFor(null, game('BACKLOG'))).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -32,7 +32,7 @@ describe('celebrationFor', () => {
|
|||||||
expect(celebrationFor({appid: 620, level: 2}, game('PLAYING'))).toBeNull();
|
expect(celebrationFor({appid: 620, level: 2}, game('PLAYING'))).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('plays for another game opened from the page', () => {
|
it('plays nothing for another game opened from the page', () => {
|
||||||
expect(celebrationFor({appid: 620, level: 1}, game('FINISHED', 0, 0, 220))).toBe('small');
|
expect(celebrationFor({appid: 620, level: 1}, game('FINISHED', 0, 0, 220))).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
Reference in new issue
Block a user