refactor(frontend): lint, one session guard, one request helper, one user context

This commit is contained in:
lobo committed 2026-09-29 15:40:55 +02:00
1 parent 111ab1e200
commit d0ea9e900d
78 files changed
+2138 -624

No files matched your search

+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?.();
+4 -19
View File
@@ -1,5 +1,4 @@
import {useCallback, useEffect, useRef, useState} from 'react';
import {useNavigate} from 'react-router-dom';
import {useTranslation} from 'react-i18next';
import {Ban, ChevronLeft, ChevronRight, Loader2, Search, ShieldCheck, Trash2} from 'lucide-react';
import {toast} from 'sonner';
@@ -19,9 +18,7 @@ import {
import {Input} from '@/components/ui/input';
import {Label} from '@/components/ui/label';
import PageContainer from '../components/PageContainer.jsx';
import useSessionExpired from '../hooks/useSessionExpired.js';
import {deleteUser, fetchUsers, setUserBlocked} from '../utils/admin.js';
import {hasSession} from '../utils/auth.js';
const PAGE_SIZE = 20;
const REFUSAL_KEY = {
@@ -37,8 +34,6 @@ const formatDate = (value, locale) => (value ? new Date(value).toLocaleDateStrin
*/
export default function AdminUsersPage() {
const {t, i18n} = useTranslation();
const navigate = useNavigate();
const expireSession = useSessionExpired();
const [query, setQuery] = useState('');
const [search, setSearch] = useState('');
const [page, setPage] = useState(0);
@@ -56,10 +51,7 @@ export default function AdminUsersPage() {
try {
const response = await fetchUsers(search, page, PAGE_SIZE);
if (current !== loadCount.current) return;
if (response.status === 401) {
expireSession();
return;
}
if (response.status === 401) return;
if (response.status === 403) {
setStatus('forbidden');
return;
@@ -76,15 +68,11 @@ export default function AdminUsersPage() {
console.error('Admin users error:', err);
setStatus('error');
}
}, [search, page, expireSession]);
}, [search, page]);
useEffect(() => {
if (!hasSession()) {
navigate('/login');
return;
}
load();
}, [load, navigate]);
}, [load]);
const closeDialog = () => {
setPending(null);
@@ -96,10 +84,7 @@ export default function AdminUsersPage() {
setBusy(true);
try {
const response = kind === 'delete' ? await deleteUser(user.id) : await setUserBlocked(user.id, !user.blocked);
if (response.status === 401) {
expireSession();
return;
}
if (response.status === 401) return;
if (response.ok) {
toast.success(t(kind === 'delete' ? 'ADMIN.DONE_DELETE' : user.blocked ? 'ADMIN.DONE_UNBLOCK' : 'ADMIN.DONE_BLOCK'));
closeDialog();
+33 -135
View File
@@ -1,5 +1,5 @@
import React, {useCallback, useEffect, useState} from 'react';
import {Link, useNavigate} from 'react-router-dom';
import {useCallback, useEffect, useState} from 'react';
import {Link} from 'react-router-dom';
import {useTranslation} from 'react-i18next';
import {toast} from 'sonner';
import {ArrowRight, Hash, LayoutGrid, Link2, RefreshCw, Trophy} from 'lucide-react';
@@ -19,7 +19,6 @@ import SyncStatusBadge from '../components/SyncStatusBadge.jsx';
import useLibrarySync from '../hooks/useLibrarySync.js';
import useForceSync from '../hooks/useForceSync.js';
import {formatPlaytime} from '../utils/games.js';
import useSessionExpired from '../hooks/useSessionExpired.js';
import PageContainer from '../components/PageContainer.jsx';
import HeroBanner from '../components/HeroBanner.jsx';
import SteamAvatar from '../components/SteamAvatar.jsx';
@@ -30,8 +29,8 @@ import TileGrid from '../components/TileGrid.jsx';
import statGroupTile from '../components/stats/statGroupTile.jsx';
import {DASHBOARD_TILES, DASHBOARD_TITLES, DEFAULT_DASHBOARD, normalizeLayout, saveTiles, statGroup} from '../utils/tiles.js';
import {usePreferences} from '../context/PreferencesContext.jsx';
import {apiFetch} from '../utils/api.js';
import {hasSession} from '../utils/auth.js';
import {useCurrentUserContext} from '../context/CurrentUserContext.jsx';
import {request} from '../utils/api.js';
// Games per tile size; the page loads the most for the large tile.
const TOP_GAMES = {S: 3, M: 5, L: 10};
@@ -40,13 +39,15 @@ const PLAYING_GAMES = {S: 2, M: 4, L: 8};
const TOP_GAMES_URL = '/api/library/games?page=0&size=20&sort=playtimeMinutes%2Cdesc';
const PLAYING_URL = '/api/library/games?page=0&size=20&sort=playtimeMinutes%2Cdesc&status=PLAYING';
const JSON_HEADERS = {'Content-Type': 'application/json'};
// A card whose request fails or has no JSON body stays empty.
async function loadInto(path, set, pick = (data) => data) {
const {ok, data} = await request(`${import.meta.env.VITE_CORE_API_URL}${path}`);
if (ok && data != null) set(pick(data));
}
const Dashboard = () => {
const {t} = useTranslation();
const navigate = useNavigate();
const expireSession = useSessionExpired();
const [userData, setUserData] = useState(null);
const {user: userData, updateUser, reload: reloadUser} = useCurrentUserContext();
const [steamData, setSteamData] = useState({count: 0, hasSteamId: false});
const [topGames, setTopGames] = useState([]);
const [playing, setPlaying] = useState([]);
@@ -67,109 +68,26 @@ const Dashboard = () => {
])].sort();
// No groups would mean "all groups" to the stats endpoint.
const hasStatGroups = statGroups.length > 0;
const stats = useUserStats(statGroups, expireSession, hasSession() && userData != null && hasStatGroups);
const stats = useUserStats(statGroups, userData != null && hasStatGroups);
const reloadStats = stats.reload;
const fetchSteamCount = useCallback(async () => {
try {
const r = await apiFetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/games-count`, {headers: JSON_HEADERS});
if (r.status === 401) {
expireSession();
return;
}
if (r.ok) {
setSteamData(await r.json());
}
} catch (err) {
console.error('Error fetching steam data:', err);
}
}, [expireSession]);
const fetchTopGames = useCallback(async () => {
try {
const r = await apiFetch(`${import.meta.env.VITE_CORE_API_URL}${TOP_GAMES_URL}`, {headers: JSON_HEADERS});
if (r.status === 401) {
expireSession();
return;
}
if (r.ok) {
const page = await r.json();
setTopGames((page.content ?? []).slice(0, TOP_GAMES.L));
}
} catch (err) {
console.error('Error fetching top games:', err);
}
}, [expireSession]);
const fetchPlaying = useCallback(async () => {
try {
const r = await apiFetch(`${import.meta.env.VITE_CORE_API_URL}${PLAYING_URL}`, {headers: JSON_HEADERS});
if (r.status === 401) {
expireSession();
return;
}
if (r.ok) {
const page = await r.json();
setPlaying((page.content ?? []).slice(0, PLAYING_GAMES.L));
}
} catch (err) {
console.error('Error fetching playing games:', err);
}
}, [expireSession]);
const fetchSteamCount = useCallback(() => loadInto('/api/library/games-count', setSteamData), []);
const fetchTopGames = useCallback(() => loadInto(TOP_GAMES_URL, setTopGames, (page) => (page.content ?? []).slice(0, TOP_GAMES.L)), []);
const fetchPlaying = useCallback(() => loadInto(PLAYING_URL, setPlaying, (page) => (page.content ?? []).slice(0, PLAYING_GAMES.L)), []);
const fetchActivity = useCallback(() => loadInto('/api/library/activity', setActivity), []);
const fetchAchievements = useCallback(() => loadInto('/api/library/achievements/summary', setAchievements), []);
/**
* @param excludeAppid the current suggestion, so the new pick differs from it
* @returns false when the request failed
* @returns false when the request failed; true on a 401, which ends the session without a "failed" message
*/
const fetchSummary = useCallback(async (excludeAppid) => {
const query = excludeAppid ? `?exclude=${excludeAppid}` : '';
try {
const r = await apiFetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/summary${query}`, {headers: JSON_HEADERS});
if (r.status === 401) {
expireSession();
return true;
}
if (r.ok) {
setSummary(await r.json());
setSummaryFailed(false);
return true;
}
} catch (err) {
console.error('Error fetching library summary:', err);
}
setSummaryFailed(true);
return false;
}, [expireSession]);
const fetchActivity = useCallback(async () => {
try {
const r = await apiFetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/activity`, {headers: JSON_HEADERS});
if (r.status === 401) {
expireSession();
return;
}
if (r.ok) {
setActivity(await r.json());
}
} catch (err) {
console.error('Error fetching activity:', err);
}
}, [expireSession]);
const fetchAchievements = useCallback(async () => {
try {
const r = await apiFetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/achievements/summary`, {headers: JSON_HEADERS});
if (r.status === 401) {
expireSession();
return;
}
if (r.ok) {
setAchievements(await r.json());
}
} catch (err) {
console.error('Error fetching achievements:', err);
}
}, [expireSession]);
const {ok, status, data} = await request(`${import.meta.env.VITE_CORE_API_URL}/api/library/summary${query}`);
if (ok) setSummary(data);
setSummaryFailed(!ok && status !== 401);
return ok || status === 401;
}, []);
const shuffleSuggestion = async () => {
setShuffling(true);
@@ -191,40 +109,21 @@ const Dashboard = () => {
useEffect(() => {
const fetchUserData = async () => {
if (!hasSession()) {
navigate('/login');
return;
}
try {
const profileResponse = await apiFetch(`${import.meta.env.VITE_API_URL}/api/users/me?v=1`, {
method: 'GET',
headers: JSON_HEADERS,
});
if (profileResponse.ok) {
const data = await profileResponse.json();
setUserData(data);
// Games added by hand need no Steam ID; history and achievements come only from Steam.
await Promise.all(data.steamId
? [fetchSteamCount(), fetchTopGames(), fetchPlaying(), fetchSummary(), fetchActivity(), fetchAchievements()]
: [fetchSteamCount(), fetchTopGames(), fetchPlaying(), fetchSummary()]);
} else if (profileResponse.status === 401) {
expireSession();
} else {
setError('INFO.ERROR.LOAD_PROFILE');
}
} catch (err) {
console.error('Dashboard fetch error:', err);
const {ok, status, data} = await reloadUser();
if (ok) {
// Games added by hand need no Steam ID; history and achievements come only from Steam.
await Promise.all([fetchSteamCount(), fetchTopGames(), fetchPlaying(), fetchSummary(),
...(data.steamId ? [fetchActivity(), fetchAchievements()] : [])]);
} else if (status === 0) {
setError('INFO.ERROR.NETWORK_FAILURE');
} finally {
setIsLoading(false);
} else if (status !== 401) {
setError('INFO.ERROR.LOAD_PROFILE');
}
setIsLoading(false);
};
fetchUserData();
}, [navigate, expireSession, fetchSteamCount, fetchTopGames, fetchPlaying, fetchSummary, fetchActivity, fetchAchievements]);
}, [reloadUser, fetchSteamCount, fetchTopGames, fetchPlaying, fetchSummary, fetchActivity, fetchAchievements]);
const hasSteamId = !!userData?.steamId;
const showLibrary = hasSteamId || steamData.count > 0;
@@ -237,7 +136,6 @@ const Dashboard = () => {
enabled: hasSteamId,
expectInitialSync: hasSteamId && steamData.count === 0,
onComplete: reloadLibrary,
onUnauthorized: expireSession,
});
const forceSync = useForceSync(reloadLibrary);
@@ -259,7 +157,7 @@ const Dashboard = () => {
// The default is saved as "never changed", so a later change of the default reaches this user too.
const tiles = JSON.stringify(next) === JSON.stringify(DEFAULT_DASHBOARD) ? null : next;
if (!await runSave(() => saveTiles('dashboard-tiles', tiles))) return;
setUserData((d) => ({...d, settings: {...d.settings, dashboardTiles: tiles}}));
updateUser((d) => ({...d, settings: {...d.settings, dashboardTiles: tiles}}));
setEditing(false);
};
+26 -5
View File
@@ -2,8 +2,10 @@
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
import {MemoryRouter, Route, Routes, useLocation} from 'react-router-dom';
import RequireSession from '../components/RequireSession.jsx';
import Dashboard from './Dashboard.jsx';
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
import {CurrentUserProvider} from '../context/CurrentUserContext.jsx';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
vi.mock('../hooks/useLibrarySync.js', () => ({default: () => ({status: 'OK', lastSyncedAt: null, isRunning: false})}));
@@ -33,12 +35,12 @@ describe('Dashboard', () => {
globalThis.fetch = vi.fn(async (url) =>
url.includes('/api/users/me') ? ok({email: 'a@b.de', steamId: '76561198000000000'}) : unauthorized());
render(
<MemoryRouter initialEntries={['/dashboard']}><PreferencesProvider>
<MemoryRouter initialEntries={['/dashboard']}><PreferencesProvider><CurrentUserProvider>
<Routes>
<Route path="/dashboard" element={<><Dashboard/><Spy/></>}/>
<Route element={<RequireSession/>}><Route path="/dashboard" element={<><Dashboard/><Spy/></>}/></Route>
<Route path="/login" element={<Spy/>}/>
</Routes>
</PreferencesProvider></MemoryRouter>,
</CurrentUserProvider></PreferencesProvider></MemoryRouter>,
);
await waitFor(() => expect(fetch.mock.calls.length).toBeGreaterThanOrEqual(5));
@@ -65,9 +67,9 @@ describe('Dashboard', () => {
return ok({content: [], total: 0, byStatus: {}});
});
return render(
<MemoryRouter initialEntries={['/dashboard']}><PreferencesProvider>
<MemoryRouter initialEntries={['/dashboard']}><PreferencesProvider><CurrentUserProvider>
<Dashboard/>
</PreferencesProvider></MemoryRouter>,
</CurrentUserProvider></PreferencesProvider></MemoryRouter>,
).container;
};
@@ -150,4 +152,23 @@ describe('Dashboard', () => {
await waitFor(() => expect(container.querySelector(`img[src="${avatar}"]`)).not.toBeNull());
expect(container.querySelector('img[src*="library_hero"]')).toBeNull();
});
it('shows the page when a card answers 200 without a JSON body', async () => {
// No Steam ID, so no activity and achievements are asked.
globalThis.fetch = vi.fn(async (url) => {
if (url.includes('/api/users/me')) return ok({email: 'a@b.de', displayName: 'Lobo'});
if (url.includes('/api/library/games-count')) return ok({count: 1, hasSteamId: true});
if (url.includes('sort=playtimeMinutes') && !url.includes('status=')) {
return {ok: true, status: 200, json: async () => { throw new SyntaxError('Unexpected token <'); }};
}
return ok({content: [], total: 0, byStatus: {}});
});
render(
<MemoryRouter initialEntries={['/dashboard']}><PreferencesProvider><CurrentUserProvider>
<Dashboard/>
</CurrentUserProvider></PreferencesProvider></MemoryRouter>,
);
expect(await screen.findByText(/DASHBOARD.HELLO/)).toBeTruthy();
});
});
+3 -16
View File
@@ -22,7 +22,6 @@ import PlayingHint from '../components/PlayingHint.jsx';
import TimeToBeat from '../components/TimeToBeat.jsx';
import {formatPlaytime} from '../utils/games.js';
import useCurrentUser from '../hooks/useCurrentUser.js';
import useSessionExpired from '../hooks/useSessionExpired.js';
import useSaveAppearance from '../hooks/useSaveAppearance.js';
import {usePreferences} from '../context/PreferencesContext.jsx';
import PageContainer from '../components/PageContainer.jsx';
@@ -32,7 +31,6 @@ import {ACHIEVEMENT_ICONS, reportBrokenImage} from '../utils/brokenImages.js';
import {canCorrect} from '../utils/corrections.js';
import {celebrationFor, celebrationLevel} from '../utils/confetti.js';
import {setGameHidden} from '../utils/hiddenGames.js';
import {hasSession} from '../utils/auth.js';
import {isSteamKey} from '../utils/manualGames.js';
import {platformLabel} from '../utils/platforms.js';
@@ -65,7 +63,6 @@ export default function GameDetail() {
const {appearance} = usePreferences();
const saveAppearance = useSaveAppearance();
const navigate = useNavigate();
const expireSession = useSessionExpired();
const [data, setData] = useState(null);
const [isLoading, setIsLoading] = useState(true);
@@ -99,10 +96,6 @@ export default function GameDetail() {
useEffect(() => {
let cancelled = false;
if (!hasSession()) {
navigate('/login');
return;
}
setIsLoading(true);
setError(null);
(async () => {
@@ -114,10 +107,7 @@ export default function GameDetail() {
},
});
if (cancelled) return;
if (response.status === 401) {
expireSession();
return;
}
if (response.status === 401) return;
if (response.status === 404) {
setError('GAME_DETAIL.NOT_FOUND');
return;
@@ -140,7 +130,7 @@ export default function GameDetail() {
return () => {
cancelled = true;
};
}, [appid, navigate, expireSession, reloads]);
}, [appid, reloads]);
const sortedAchievements = useMemo(() => {
if (!data?.achievements) return [];
@@ -166,10 +156,7 @@ export default function GameDetail() {
setHiding(true);
try {
const response = await setGameHidden(data.appid, hidden);
if (response.status === 401) {
expireSession();
return;
}
if (response.status === 401) return;
if (!response.ok) {
toast.error(t('GAME_DETAIL.HIDE_ERROR'));
return;
+3 -2
View File
@@ -4,6 +4,7 @@ import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/reac
import {MemoryRouter, Route, Routes} from 'react-router-dom';
import GameDetail from './GameDetail.jsx';
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
import {CurrentUserProvider} from '../context/CurrentUserContext.jsx';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
// Stands in for a saved correction, which loads the game again.
@@ -31,9 +32,9 @@ afterEach(() => {
it('keeps unsaved notes while the game loads again after a correction', async () => {
render(
<MemoryRouter initialEntries={['/library/220']}><PreferencesProvider>
<MemoryRouter initialEntries={['/library/220']}><PreferencesProvider><CurrentUserProvider>
<Routes><Route path="/library/:appid" element={<GameDetail/>}/></Routes>
</PreferencesProvider></MemoryRouter>,
</CurrentUserProvider></PreferencesProvider></MemoryRouter>,
);
fireEvent.change(await screen.findByLabelText('BACKLOG.NOTES_LABEL'), {target: {value: 'my draft'}});
+3 -2
View File
@@ -4,6 +4,7 @@ import {act, cleanup, fireEvent, render, screen, waitFor} from '@testing-library
import {MemoryRouter, Route, Routes} from 'react-router-dom';
import GameDetail from './GameDetail.jsx';
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
import {CurrentUserProvider} from '../context/CurrentUserContext.jsx';
import {resetBrokenImageReports} from '../utils/brokenImages.js';
import {toast} from 'sonner';
@@ -16,12 +17,12 @@ const game = {
};
const renderDetail = (appid = 220) => render(
<MemoryRouter initialEntries={[`/library/${appid}`]}><PreferencesProvider>
<MemoryRouter initialEntries={[`/library/${appid}`]}><PreferencesProvider><CurrentUserProvider>
<Routes>
<Route path="/library/:appid" element={<GameDetail/>}/>
<Route path="/library" element={<p>library page</p>}/>
</Routes>
</PreferencesProvider></MemoryRouter>,
</CurrentUserProvider></PreferencesProvider></MemoryRouter>,
);
beforeEach(() => {
+7 -20
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';
@@ -22,7 +21,6 @@ import LibraryTabs from '../components/LibraryTabs.jsx';
import GameStatusBadge from '../components/GameStatusBadge.jsx';
import PageContainer from '../components/PageContainer.jsx';
import {apiFetch} from '../utils/api.js';
import {hasSession} from '../utils/auth.js';
import {platformLabel} from '../utils/platforms.js';
const PAGE_SIZES = [20, 40, 80];
@@ -42,8 +40,6 @@ const SORT_OPTIONS = [
export default function GameLibrary() {
const {t} = useTranslation();
const navigate = useNavigate();
const expireSession = useSessionExpired();
const [searchParams, setSearchParams] = useSearchParams();
const page = Math.max(0, parseInt(searchParams.get('page') ?? '0', 10) || 0);
@@ -70,10 +66,6 @@ export default function GameLibrary() {
const [error, setError] = useState(null);
const load = useCallback(async () => {
if (!hasSession()) {
navigate('/login');
return;
}
setIsLoading(true);
setError(null);
try {
@@ -84,10 +76,7 @@ export default function GameLibrary() {
},
});
if (response.status === 401) {
expireSession();
return;
}
if (response.status === 401) return;
if (!response.ok) {
setError('LIBRARY.ERROR_LOAD');
@@ -102,14 +91,13 @@ export default function GameLibrary() {
} finally {
setIsLoading(false);
}
}, [navigate, expireSession, page, size, sort, listStatus, q, listShortOnly, hiddenOnly]);
}, [page, size, sort, listStatus, q, listShortOnly, hiddenOnly]);
useEffect(() => {
load();
}, [load]);
useEffect(() => {
if (!hasSession()) return undefined;
let cancelled = false;
apiFetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/games-count`)
.then((response) => (response.ok ? response.json() : null))
@@ -127,26 +115,25 @@ export default function GameLibrary() {
enabled: hasSteamId,
expectInitialSync,
onComplete: load,
onUnauthorized: expireSession,
});
const updateParams = (next) => {
const updateParams = useCallback((next) => {
const params = new URLSearchParams(searchParams);
Object.entries(next).forEach(([k, v]) => (v === null || v === '' ? params.delete(k) : params.set(k, String(v))));
setSearchParams(params);
};
}, [searchParams, setSearchParams]);
// Back/forward navigation changes q without typing. Keeps a trailing space the user is still typing.
useEffect(() => {
setSearch((current) => (current.trim() === q ? current : q));
}, [q]);
// searchParams is a dependency, so a status click during the delay restarts the timer with fresh params.
// updateParams changes with searchParams, so a status click during the delay restarts the timer with fresh params.
useEffect(() => {
if (search.trim() === q) return undefined;
const timer = setTimeout(() => updateParams({q: search.trim(), page: 0}), SEARCH_DEBOUNCE_MS);
return () => clearTimeout(timer);
}, [search, q, searchParams]);
}, [search, q, updateParams]);
const onStatusChange = (value) => {
updateParams({status: value, hidden: null, page: 0});
+22 -45
View File
@@ -1,5 +1,5 @@
import {memo, useCallback, useEffect, useRef, useState} from 'react';
import {Link, useNavigate, useSearchParams} from 'react-router-dom';
import {Link, useSearchParams} from 'react-router-dom';
import {useTranslation} from 'react-i18next';
import {toast} from 'sonner';
import {ArrowLeft, CheckCircle2, Circle, Loader2, RotateCcw, Search, Upload, XCircle} from 'lucide-react';
@@ -12,15 +12,14 @@ import {Textarea} from '@/components/ui/textarea';
import {cn} from '@/lib/utils';
import GameSearch from '../components/GameSearch.jsx';
import PageContainer from '../components/PageContainer.jsx';
import useSessionExpired from '../hooks/useSessionExpired.js';
import useStartImport from '../hooks/useStartImport.js';
import {hasSession} from '../utils/auth.js';
import {
checkUndo, getImport, listImports, parseCsv, parseTextList, retryImport, saveImport, undoImport,
} from '../utils/imports.js';
import {GAME_STATUSES, MAX_IMPORT_ROWS, MAX_PLATFORM_LENGTH} from '../utils/limits.js';
import {hoursToMinutes, toHours} from '../utils/manualGames.js';
import {loadFavoritePlatform, preselectPlatform} from '../utils/platforms.js';
import {preselectPlatform} from '../utils/platforms.js';
import {useCurrentUserContext} from '../context/CurrentUserContext.jsx';
const POLL_MS = 2000;
// Statuses a worker still has: the page polls while it shows one.
@@ -50,8 +49,6 @@ const SELECT_CLASS = 'h-9 w-full min-w-0 rounded-md border border-input bg-trans
*/
export default function ImportPage() {
const {t} = useTranslation();
const navigate = useNavigate();
const expireSession = useSessionExpired();
const [searchParams, setSearchParams] = useSearchParams();
const [imports, setImports] = useState(null);
const id = searchParams.get('id');
@@ -59,24 +56,17 @@ export default function ImportPage() {
const loadImports = useCallback(async () => {
try {
const response = await listImports();
if (response.status === 401) {
expireSession();
return;
}
if (response.status === 401) return;
setImports(response.ok ? (await response.json()).imports : []);
} catch (err) {
console.error('Import list error:', err);
setImports([]);
}
}, [expireSession]);
}, []);
useEffect(() => {
if (!hasSession()) {
navigate('/login');
return;
}
loadImports();
}, [navigate, loadImports]);
}, [loadImports]);
// Without ?id=, an import a worker still has is shown first; one to review or failed is in the history.
const open = imports?.find((entry) => RUNNING.includes(entry.status));
@@ -103,14 +93,14 @@ export default function ImportPage() {
{imports === null ? (
<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 && <History imports={imports} onOpen={show} onChanged={loadImports}/>}
</PageContainer>
);
}
@@ -176,7 +166,7 @@ function ImportInput({onCreated}) {
}
// Polls while a worker has the import.
function ImportView({id, onDone, onChanged, expireSession}) {
function ImportView({id, onDone, onChanged}) {
const {t} = useTranslation();
const [data, setData] = useState(null);
const [failed, setFailed] = useState(false);
@@ -185,11 +175,8 @@ function ImportView({id, onDone, onChanged, expireSession}) {
const reload = () => setVersion((current) => current + 1);
const lastStatus = useRef(null);
// undefined while loading: the review preselects with it, so it waits.
const [favorite, setFavorite] = useState(undefined);
useEffect(() => {
loadFavoritePlatform().then(setFavorite);
}, []);
const {user} = useCurrentUserContext();
const favorite = user === undefined ? undefined : user?.settings?.favoritePlatform ?? null;
useEffect(() => {
let timer;
@@ -198,10 +185,7 @@ function ImportView({id, onDone, onChanged, expireSession}) {
try {
const response = await getImport(id);
if (cancelled) return;
if (response.status === 401) {
expireSession();
return;
}
if (response.status === 401) return;
// A 4xx ends the polling; a 5xx while Core restarts is asked again.
if (response.status >= 400 && response.status < 500) {
setFailed(true);
@@ -228,7 +212,7 @@ function ImportView({id, onDone, onChanged, expireSession}) {
cancelled = true;
clearTimeout(timer);
};
}, [id, expireSession, version, onChanged]);
}, [id, version, onChanged]);
if (failed) return <p className="text-sm text-destructive">{t('IMPORT.LOAD_FAILED')}</p>;
if (!data || favorite === undefined) return <Loader2 className="animate-spin text-muted-foreground"/>;
@@ -236,9 +220,9 @@ function ImportView({id, onDone, onChanged, expireSession}) {
<div className="space-y-4">
<ImportSteps data={data}/>
{data.status === 'READY' ? (
<Review data={data} favorite={favorite} onSaved={reload} onDone={onDone} expireSession={expireSession}/>
<Review data={data} favorite={favorite} onSaved={reload} onDone={onDone}/>
) : (
<ImportProgress data={data} onRetried={reload} onDone={onDone} expireSession={expireSession}/>
<ImportProgress data={data} onRetried={reload} onDone={onDone}/>
)}
</div>
);
@@ -271,7 +255,7 @@ function ImportSteps({data}) {
}
// What the current step does, with its numbers; Retry for a FAILED import.
function ImportProgress({data, onRetried, onDone, expireSession}) {
function ImportProgress({data, onRetried, onDone}) {
const {t} = useTranslation();
const [busy, setBusy] = useState(false);
const step = stepOf(data);
@@ -281,9 +265,8 @@ function ImportProgress({data, onRetried, onDone, expireSession}) {
setBusy(true);
try {
const response = await retryImport(data.id);
if (response.status === 401) {
expireSession();
} else if (response.status === 409) {
if (response.status === 401) return;
if (response.status === 409) {
toast.error(t('IMPORT.RUNNING'));
} else if (!response.ok) {
toast.error(t('IMPORT.RETRY_FAILED'));
@@ -356,7 +339,7 @@ export const initialChoice = (row, favorite) => {
};
};
function Review({data, favorite, onSaved, onDone, expireSession}) {
function Review({data, favorite, onSaved, onDone}) {
const {t} = useTranslation();
const [choices, setChoices] = useState(() => data.rows.map((row) => initialChoice(row, favorite)));
const [busy, setBusy] = useState(false);
@@ -387,10 +370,7 @@ function Review({data, favorite, onSaved, onDone, expireSession}) {
lineNo: row.lineNo, igdbId: choice.igdbId, platform: choice.platform.trim(), status: choice.status,
playtimeMinutes: hoursToMinutes(choice.hours),
})));
if (response.status === 401) {
expireSession();
return;
}
if (response.status === 401) return;
if (!response.ok) {
toast.error(t('IMPORT.SAVE_FAILED'));
return;
@@ -491,7 +471,7 @@ const ReviewRow = memo(function ReviewRow({index, row, choice, onChange: changeL
);
});
function History({imports, onOpen, onChanged, expireSession}) {
function History({imports, onOpen, onChanged}) {
const {t, i18n} = useTranslation();
const [confirm, setConfirm] = useState(null);
const [busy, setBusy] = useState(false);
@@ -500,10 +480,7 @@ function History({imports, onOpen, onChanged, expireSession}) {
setBusy(true);
try {
const response = await call();
if (response.status === 401) {
expireSession();
return;
}
if (response.status === 401) return;
if (!response.ok) {
toast.error(t('IMPORT.UNDO_FAILED'));
return;
+32 -9
View File
@@ -5,11 +5,12 @@ import {MemoryRouter, Route, Routes} from 'react-router-dom';
import {toast} from 'sonner';
import ImportPage, {initialChoice, stepOf} from './ImportPage.jsx';
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
import {CurrentUserProvider} from '../context/CurrentUserContext.jsx';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
const json = (status, body) => ({ok: status < 400, status, json: async () => body});
const hades = {igdbId: 1, name: 'Hades', year: 2020, imageUrl: null, platforms: ['PC', 'Switch'], inLibrary: false};
const hades2 = {igdbId: 2, name: 'Hades II', year: 2025, imageUrl: null, platforms: ['PC'], inLibrary: false};
@@ -25,19 +26,20 @@ const ready = {
],
};
// routes: 'METHOD path after /imports' -> response or (init) => response; any other URL (Auth) answers the favorite
// routes: 'METHOD path after /imports' -> response or (init) => response; Auth answers the favorite
function renderPage(routes, entry = '/import') {
globalThis.fetch = vi.fn(async (url, init) => {
if (!url.includes('/api/library/imports')) return json(200, {settings: {favoritePlatform: 'Switch'}});
const path = new URL(url, 'http://x').pathname.replace(/.*\/api\/library\/imports/, '');
const answer = routes[`${init?.method ?? 'GET'} ${path}`];
if (!answer) throw new Error(`no route ${init?.method ?? 'GET'} ${path}`);
return typeof answer === 'function' ? answer(init) : answer;
fetchRoutes({
'GET /api/users/me': json(200, {settings: {favoritePlatform: 'Switch'}}),
...Object.fromEntries(Object.entries(routes).map(([key, answer]) => {
const [method, path] = key.split(' ');
return [`${method} /api/library/imports${path}`,
typeof answer === 'function' ? (url, init) => answer(init) : answer];
})),
});
render(
<MemoryRouter initialEntries={[entry]}>
<Routes>
<Route path="/import" element={<PreferencesProvider><ImportPage/></PreferencesProvider>}/>
<Route path="/import" element={<PreferencesProvider><CurrentUserProvider><ImportPage/></CurrentUserProvider></PreferencesProvider>}/>
<Route path="/login" element={<p>login</p>}/>
</Routes>
</MemoryRouter>,
@@ -250,3 +252,24 @@ describe('ImportPage', () => {
expect(await screen.findByText('IMPORT.MATCHING', {}, {timeout: 4000})).toBeTruthy();
}, 10000);
});
describe('review findings', () => {
it('shows the review when Auth cannot be reached for the favorite', async () => {
vi.spyOn(console, 'error').mockImplementation(() => undefined);
fetchRoutes({
'GET /api/users/me': () => Promise.reject(new TypeError('Failed to fetch')),
'GET /api/library/imports': json(200, {imports: []}),
'GET /api/library/imports/5': json(200, ready),
});
render(
<MemoryRouter initialEntries={['/import?id=5']}>
<Routes>
<Route path="/import" element={<PreferencesProvider><CurrentUserProvider><ImportPage/></CurrentUserProvider></PreferencesProvider>}/>
</Routes>
</MemoryRouter>,
);
expect(await screen.findByLabelText('IMPORT.LINE: Hades', {}, {timeout: 2000})).toBeTruthy();
});
});
+3
View File
@@ -10,6 +10,7 @@ import AuthLayout from '../components/AuthLayout.jsx';
import FeedbackAlert from '../components/FeedbackAlert.jsx';
import {hasSession, markSession, safeReturnPath} from '../utils/auth.js';
import {clientFetch} from '../utils/api.js';
import {useCurrentUserActions} from '../context/CurrentUserContext.jsx';
const LOGIN_ERROR_KEY = {
INVALID_CREDENTIALS: 'LOGIN.ERROR_INVALID_CREDENTIALS',
@@ -20,6 +21,7 @@ const LOGIN_ERROR_KEY = {
export default function LoginPage() {
const {t} = useTranslation();
const navigate = useNavigate();
const {reload: reloadUser} = useCurrentUserActions();
const {state} = useLocation();
const target = safeReturnPath(state?.from) ?? '/dashboard';
@@ -54,6 +56,7 @@ export default function LoginPage() {
if (response.ok) {
markSession();
reloadUser();
navigate(target, {replace: true});
} else {
const minutes = Math.ceil(Number(response.headers.get('Retry-After') ?? 900) / 60);
+6 -20
View File
@@ -1,5 +1,5 @@
import {useCallback, useEffect, useRef, useState} from 'react';
import {Link, useNavigate} from 'react-router-dom';
import {Link} from 'react-router-dom';
import {useTranslation} from 'react-i18next';
import {toast} from 'sonner';
import {Check, Loader2, Play, Shuffle, Timer} from 'lucide-react';
@@ -10,11 +10,9 @@ import {Card} from '@/components/ui/card';
import {Skeleton} from '@/components/ui/skeleton';
import GameCover from '../components/GameCover.jsx';
import PageContainer from '../components/PageContainer.jsx';
import useSessionExpired from '../hooks/useSessionExpired.js';
import {saveGameStatus} from '../utils/backlog.js';
import {formatPlaytime} from '../utils/games.js';
import {apiFetch} from '../utils/api.js';
import {hasSession} from '../utils/auth.js';
// maxMinutes is IGDB's normal time to beat; a limit leaves out games without IGDB data.
const BUDGETS = [
@@ -40,8 +38,6 @@ const pickerQuery = (maxMinutes, mood, exclude) => {
export default function PlayNextPage() {
const {t} = useTranslation();
const navigate = useNavigate();
const expireSession = useSessionExpired();
const [budget, setBudget] = useState(BUDGETS[3]);
const [mood, setMood] = useState('ANY');
const [games, setGames] = useState(null);
@@ -52,10 +48,6 @@ export default function PlayNextPage() {
const latestRequest = useRef(0);
const load = useCallback(async (maxMinutes, moodKey, exclude) => {
if (!hasSession()) {
navigate('/login');
return;
}
const request = ++latestRequest.current;
setLoading(true);
setError(null);
@@ -63,10 +55,7 @@ export default function PlayNextPage() {
const response = await apiFetch(
`${import.meta.env.VITE_CORE_API_URL}/api/library/picker?${pickerQuery(maxMinutes, moodKey, exclude)}`);
if (request !== latestRequest.current) return;
if (response.status === 401) {
expireSession();
return;
}
if (response.status === 401) return;
if (!response.ok) {
setError('PLAY_NEXT.ERROR_LOAD');
return;
@@ -82,7 +71,7 @@ export default function PlayNextPage() {
if (request === latestRequest.current) setLoading(false);
}
// No `t` here: a language change gives a new `t`, and a reload would replace the random picks.
}, [navigate, expireSession]);
}, []);
useEffect(() => {
load(budget.maxMinutes, mood, []);
@@ -161,7 +150,7 @@ export default function PlayNextPage() {
{games?.length > 0 && (
<div className="grid gap-4 sm:grid-cols-3">
{games.map((game) => <PickCard key={game.appid} game={game} expireSession={expireSession}/>)}
{games.map((game) => <PickCard key={game.appid} game={game}/>)}
</div>
)}
@@ -197,7 +186,7 @@ const emptyKey = (budget, mood) => {
return budget.maxMinutes ? 'PLAY_NEXT.EMPTY_BUDGET' : 'PLAY_NEXT.EMPTY';
};
function PickCard({game, expireSession}) {
function PickCard({game}) {
const {t} = useTranslation();
const [busy, setBusy] = useState(false);
const [started, setStarted] = useState(false);
@@ -206,10 +195,7 @@ function PickCard({game, expireSession}) {
setBusy(true);
try {
const response = await saveGameStatus(game.appid, 'PLAYING');
if (response.status === 401) {
expireSession();
return;
}
if (response.status === 401) return;
if (!response.ok) {
toast.error(t('BACKLOG.SAVE_FAILED'));
return;
+3 -2
View File
@@ -4,6 +4,7 @@ import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/reac
import {MemoryRouter, Route, Routes} from 'react-router-dom';
import PublicProfilePage from './PublicProfilePage.jsx';
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
import {CurrentUserProvider} from '../context/CurrentUserContext.jsx';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
@@ -27,9 +28,9 @@ function mockBackends({profileStatus = 200, libraryStatus = 200, style, answer =
}
const renderPage = (name = 'Lobo') => render(
<MemoryRouter initialEntries={[`/u/${name}`]}><PreferencesProvider>
<MemoryRouter initialEntries={[`/u/${name}`]}><PreferencesProvider><CurrentUserProvider>
<Routes><Route path="/u/:name" element={<PublicProfilePage/>}/></Routes>
</PreferencesProvider></MemoryRouter>,
</CurrentUserProvider></PreferencesProvider></MemoryRouter>,
);
describe('PublicProfilePage', () => {
+20 -60
View File
@@ -1,5 +1,5 @@
import {useEffect, useRef, useState} from 'react';
import {useNavigate, useSearchParams} from 'react-router-dom';
import {useSearchParams} from 'react-router-dom';
import {useTranslation} from 'react-i18next';
import {useForm} from 'react-hook-form';
import {zodResolver} from '@hookform/resolvers/zod';
@@ -20,7 +20,7 @@ import FormField from '../components/FormField.jsx';
import SteamAccountCard from '../components/SteamAccountCard.jsx';
import {settingsSchema} from '../utils/validation.js';
import {usePreferences} from '../context/PreferencesContext.jsx';
import useSessionExpired from '../hooks/useSessionExpired.js';
import {useCurrentUserContext} from '../context/CurrentUserContext.jsx';
import useSaveAppearance from '../hooks/useSaveAppearance.js';
import {AccentPicker, CornersPicker} from '../components/StylePickers.jsx';
import ProfileStyleSettings from '../components/ProfileStyleSettings.jsx';
@@ -28,8 +28,8 @@ import {normalizeProfileStyle} from '../utils/profileStyle.js';
import useSaveProfileStyle from '../hooks/useSaveProfileStyle.js';
import useSaveResponse from '../hooks/useSaveResponse.js';
import {coverUrl} from '../utils/games.js';
import {apiFetch, refreshSession} from '../utils/api.js';
import {hasSession, markSession} from '../utils/auth.js';
import {refreshSession} from '../utils/api.js';
import {markSession} from '../utils/auth.js';
import {FAVORITE_PLATFORMS, saveFavoritePlatform} from '../utils/platforms.js';
import {LANGUAGES, saveSettings} from '../utils/settings.js';
import {MAX_DISPLAY_NAME_LENGTH} from '../utils/limits.js';
@@ -60,14 +60,12 @@ const formatDate = (value, locale) => {
export default function SettingsPage() {
const {t, i18n} = useTranslation();
const navigate = useNavigate();
const expireSession = useSessionExpired();
const {isDarkMode, setDarkMode, setLanguage, appearance} = usePreferences();
const saveAppearance = useSaveAppearance();
const saveStyle = useSaveProfileStyle();
const runSave = useSaveResponse();
const [profile, setProfile] = useState(null);
const {user: profile, updateUser: updateProfile, reload: reloadUser} = useCurrentUserContext();
const [isLoading, setIsLoading] = useState(true);
const [loadError, setLoadError] = useState(null);
const [savingProfileStyle, setSavingProfileStyle] = useState(false);
@@ -99,62 +97,26 @@ export default function SettingsPage() {
}, [steamOutcome, setSearchParams, t]);
useEffect(() => {
if (!hasSession()) {
navigate('/login');
return;
}
const load = async () => {
try {
if (steamOutcome === 'linked') {
// Refresh reads the new Steam ID from the database, so Core gets it in the token at once.
await refreshSession().catch(() => false);
markSession();
}
const response = await apiFetch(`${import.meta.env.VITE_API_URL}/api/users/me?v=1`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
if (response.status === 401) {
expireSession();
return;
}
if (!response.ok) {
setLoadError('INFO.ERROR.LOAD_PROFILE');
return;
}
const data = await response.json();
setProfile(data);
reset({displayName: data.displayName ?? ''});
} catch (err) {
console.error('Settings load error:', err);
setLoadError('INFO.ERROR.NETWORK_FAILURE');
} finally {
setIsLoading(false);
if (steamOutcome === 'linked') {
// Refresh reads the new Steam ID from the database, so Core gets it in the token at once.
await refreshSession().catch(() => false);
markSession();
}
const {ok, status, data} = await reloadUser();
if (ok) reset({displayName: data.displayName ?? ''});
else if (status !== 401) setLoadError(status === 0 ? 'INFO.ERROR.NETWORK_FAILURE' : 'INFO.ERROR.LOAD_PROFILE');
setIsLoading(false);
};
load();
}, [navigate, expireSession, reset, steamOutcome]);
}, [reloadUser, reset, steamOutcome]);
const persistSettings = async (send) => {
if (!hasSession()) {
navigate('/login');
return {ok: false};
}
try {
const response = await send();
if (response.status === 401) {
expireSession();
return {ok: false};
}
if (response.status === 401) return {ok: false};
if (!response.ok) {
const data = await response.json().catch(() => ({}));
@@ -185,7 +147,7 @@ export default function SettingsPage() {
const handleFavoritePlatformChange = async (value) => {
const platform = value === NO_FAVORITE ? null : value;
if (await runSave(() => saveFavoritePlatform(platform))) {
setProfile((p) => ({...p, settings: {...p.settings, favoritePlatform: platform}}));
updateProfile((p) => ({...p, settings: {...p.settings, favoritePlatform: platform}}));
toast.success(t('SETTINGS.SAVED'));
}
};
@@ -194,14 +156,14 @@ export default function SettingsPage() {
const next = !profile?.settings?.publicProfile;
const result = await persistSettings(() => saveSettings({publicProfile: next}));
if (result.ok) {
setProfile((p) => ({...p, settings: {...p.settings, publicProfile: next}}));
updateProfile((p) => ({...p, settings: {...p.settings, publicProfile: next}}));
}
};
// Shown at once; the pickers wait for the answer, so the saves reach Auth one after the other.
const handleProfileStyleChange = async (changes) => {
const previous = normalizeProfileStyle(profile?.settings?.profileStyle);
const showProfileStyle = (next) => setProfile((p) => ({...p, settings: {...p.settings, profileStyle: next}}));
const showProfileStyle = (next) => updateProfile((p) => ({...p, settings: {...p.settings, profileStyle: next}}));
showProfileStyle({...previous, ...changes});
setSavingProfileStyle(true);
showProfileStyle(await saveStyle(changes, previous) ?? previous);
@@ -222,9 +184,7 @@ export default function SettingsPage() {
const publicProfile = !!profile?.settings?.publicProfile && !!values.displayName;
const result = await persistSettings(() => saveSettings({displayName: values.displayName, publicProfile}));
if (result.ok) {
setProfile((p) => (p ? {
...p, displayName: values.displayName, settings: {...p.settings, publicProfile},
} : p));
updateProfile((p) => ({...p, displayName: values.displayName, settings: {...p.settings, publicProfile}}));
}
};
@@ -382,7 +342,7 @@ export default function SettingsPage() {
</Card>
<SteamAccountCard steamId={profile?.steamId} verified={!!profile?.steamIdVerified}
onUnlinked={() => setProfile((p) => ({...p, steamId: null, steamIdVerified: false}))}/>
onUnlinked={() => updateProfile((p) => ({...p, steamId: null, steamIdVerified: false}))}/>
<Card>
<CardHeader>
+2 -1
View File
@@ -5,6 +5,7 @@ import {MemoryRouter} from 'react-router-dom';
import {toast} from 'sonner';
import SettingsPage from './SettingsPage.jsx';
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
import {CurrentUserProvider} from '../context/CurrentUserContext.jsx';
vi.mock('react-i18next', async () => {
const mock = await import('../test/i18n-mock.js');
@@ -32,7 +33,7 @@ function renderPage(me, {redirectUrl = STEAM_URL, entry = '/settings'} = {}) {
}
return {ok: true, status: 200, json: async () => me};
});
render(<MemoryRouter initialEntries={[entry]}><PreferencesProvider><SettingsPage/></PreferencesProvider></MemoryRouter>);
render(<MemoryRouter initialEntries={[entry]}><PreferencesProvider><CurrentUserProvider><SettingsPage/></CurrentUserProvider></PreferencesProvider></MemoryRouter>);
}
describe('SettingsPage Steam account', () => {
+2 -1
View File
@@ -5,6 +5,7 @@ import {MemoryRouter} from 'react-router-dom';
import {toast} from 'sonner';
import SettingsPage from './SettingsPage.jsx';
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
import {CurrentUserProvider} from '../context/CurrentUserContext.jsx';
// Like react-i18next: a language change re-renders every subscriber with a fresh `t`.
vi.mock('react-i18next', async () => {
@@ -44,7 +45,7 @@ describe('SettingsPage Steam toast', () => {
});
it('shows the linked toast once, also after a language change', async () => {
render(<MemoryRouter initialEntries={['/settings?steam=linked']}><PreferencesProvider><SettingsPage/></PreferencesProvider></MemoryRouter>);
render(<MemoryRouter initialEntries={['/settings?steam=linked']}><PreferencesProvider><CurrentUserProvider><SettingsPage/></CurrentUserProvider></PreferencesProvider></MemoryRouter>);
await screen.findByText('SETTINGS.STEAM_LINK.VERIFIED');
expect(toast.success).toHaveBeenCalledTimes(1);
+3 -2
View File
@@ -4,6 +4,7 @@ import {act, cleanup, fireEvent, render, screen, waitFor, within} from '@testing
import {MemoryRouter} from 'react-router-dom';
import SettingsPage from './SettingsPage.jsx';
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
import {CurrentUserProvider} from '../context/CurrentUserContext.jsx';
vi.mock('react-i18next', async () => {
const mock = await import('../test/i18n-mock.js');
@@ -26,7 +27,7 @@ function renderPage(me) {
// Both PUTs answer 204 without a body, like Auth does.
? {ok: true, status: 204, json: async () => { throw new SyntaxError('no body'); }}
: {ok: true, status: 200, json: async () => me}));
render(<MemoryRouter><PreferencesProvider><SettingsPage/></PreferencesProvider></MemoryRouter>);
render(<MemoryRouter><PreferencesProvider><CurrentUserProvider><SettingsPage/></CurrentUserProvider></PreferencesProvider></MemoryRouter>);
}
/**
@@ -66,7 +67,7 @@ describe('SettingsPage public profile', () => {
it('builds a save on the one before it, also while that one is still open', async () => {
const auth = fakeAuth(profile());
render(<MemoryRouter><PreferencesProvider><SettingsPage/></PreferencesProvider></MemoryRouter>);
render(<MemoryRouter><PreferencesProvider><CurrentUserProvider><SettingsPage/></CurrentUserProvider></PreferencesProvider></MemoryRouter>);
fireEvent.click(await screen.findByRole('switch', {name: 'SETTINGS.PUBLIC_PROFILE'}));
await waitFor(() => expect(puts()).toHaveLength(1));
+1 -12
View File
@@ -1,5 +1,3 @@
import {useEffect} from 'react';
import {useNavigate} from 'react-router-dom';
import {useTranslation} from 'react-i18next';
import {RefreshCw} from 'lucide-react';
@@ -13,22 +11,13 @@ import TopGamesStats from '../components/stats/TopGamesStats.jsx';
import TasteStats from '../components/stats/TasteStats.jsx';
import HabitsStats from '../components/stats/HabitsStats.jsx';
import FinishedStats from '../components/stats/FinishedStats.jsx';
import useSessionExpired from '../hooks/useSessionExpired.js';
import useUserStats from '../hooks/useUserStats.js';
import {hasSession} from '../utils/auth.js';
const ALL_GROUPS = [];
export default function StatsPage() {
const {t} = useTranslation();
const navigate = useNavigate();
const expireSession = useSessionExpired();
const loggedIn = hasSession();
const {stats, failed, reload} = useUserStats(ALL_GROUPS, expireSession, loggedIn);
useEffect(() => {
if (!loggedIn) navigate('/login');
}, [loggedIn, navigate]);
const {stats, failed, reload} = useUserStats(ALL_GROUPS);
return (
<PageContainer className="p-4 md:p-8 space-y-6">
+2 -14
View File
@@ -1,5 +1,4 @@
import {useEffect, useState} from 'react';
import {useNavigate} from 'react-router-dom';
import {useTranslation} from 'react-i18next';
import {ExternalLink, Gift, Percent} from 'lucide-react';
@@ -9,36 +8,25 @@ import {Card} from '@/components/ui/card';
import {Skeleton} from '@/components/ui/skeleton';
import GameCover from '../components/GameCover.jsx';
import LibraryTabs from '../components/LibraryTabs.jsx';
import useSessionExpired from '../hooks/useSessionExpired.js';
import PageContainer from '../components/PageContainer.jsx';
import {apiFetch} from '../utils/api.js';
import {hasSession} from '../utils/auth.js';
const formatAdded = (instant, locale) =>
instant ? new Date(instant).toLocaleDateString(locale, {year: 'numeric', month: 'short', day: 'numeric'}) : null;
export default function WishlistPage() {
const {t, i18n} = useTranslation();
const navigate = useNavigate();
const expireSession = useSessionExpired();
const [games, setGames] = useState(null);
const [error, setError] = useState(null);
const [onlyOnSale, setOnlyOnSale] = useState(false);
useEffect(() => {
if (!hasSession()) {
navigate('/login');
return undefined;
}
let cancelled = false;
(async () => {
try {
const response = await apiFetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/wishlist`);
if (cancelled) return;
if (response.status === 401) {
expireSession();
return;
}
if (response.status === 401) return;
if (!response.ok) {
setError('WISHLIST.ERROR_LOAD');
return;
@@ -53,7 +41,7 @@ export default function WishlistPage() {
return () => {
cancelled = true;
};
}, [navigate, expireSession]);
}, []);
const shown = (games ?? []).filter((g) => !onlyOnSale || g.discountPct > 0);
const onSaleCount = (games ?? []).filter((g) => g.discountPct > 0).length;
+2 -1
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, Route, Routes, useLocation} from 'react-router-dom';
import RequireSession from '../components/RequireSession.jsx';
import WishlistPage from './WishlistPage.jsx';
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
@@ -19,7 +20,7 @@ function LoginState() {
const renderPage = () => render(
<MemoryRouter initialEntries={['/wishlist']}><PreferencesProvider>
<Routes>
<Route path="/wishlist" element={<WishlistPage/>}/>
<Route element={<RequireSession/>}><Route path="/wishlist" element={<WishlistPage/>}/></Route>
<Route path="/login" element={<LoginState/>}/>
</Routes>
</PreferencesProvider></MemoryRouter>,
+9 -8
View File
@@ -4,6 +4,7 @@ import {cleanup, render, screen} from '@testing-library/react';
import {MemoryRouter, Route, Routes} from 'react-router-dom';
import {switchLanguage} from '../test/i18n-mock.js';
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
import {CurrentUserProvider} from '../context/CurrentUserContext.jsx';
import Dashboard from './Dashboard.jsx';
import GameDetail from './GameDetail.jsx';
import GameLibrary from './GameLibrary.jsx';
@@ -14,12 +15,12 @@ import WishlistPage from './WishlistPage.jsx';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
const pages = [
['Dashboard', '/', '/', <Dashboard/>, 'INFO.ERROR.LOAD_PROFILE'],
['GameDetail', '/games/:appid', '/games/220', <GameDetail/>, 'GAME_DETAIL.ERROR_LOAD'],
['GameLibrary', '/library', '/library', <GameLibrary/>, 'LIBRARY.ERROR_LOAD'],
['PublicProfilePage', '/u/:name', '/u/Lobo', <PublicProfilePage/>, 'PROFILE.ERROR_LOAD'],
['SettingsPage', '/settings', '/settings', <SettingsPage/>, 'INFO.ERROR.LOAD_PROFILE'],
['WishlistPage', '/wishlist', '/wishlist', <WishlistPage/>, 'WISHLIST.ERROR_LOAD'],
['Dashboard', '/', '/', Dashboard, 'INFO.ERROR.LOAD_PROFILE'],
['GameDetail', '/games/:appid', '/games/220', GameDetail, 'GAME_DETAIL.ERROR_LOAD'],
['GameLibrary', '/library', '/library', GameLibrary, 'LIBRARY.ERROR_LOAD'],
['PublicProfilePage', '/u/:name', '/u/Lobo', PublicProfilePage, 'PROFILE.ERROR_LOAD'],
['SettingsPage', '/settings', '/settings', SettingsPage, 'INFO.ERROR.LOAD_PROFILE'],
['WishlistPage', '/wishlist', '/wishlist', WishlistPage, 'WISHLIST.ERROR_LOAD'],
];
beforeEach(() => {
@@ -34,10 +35,10 @@ afterEach(() => {
});
describe('a language change', () => {
it.each(pages)('%s: loads nothing again and shows the error in the new language', async (name, path, url, page, errorKey) => {
it.each(pages)('%s: loads nothing again and shows the error in the new language', async (name, path, url, Page, errorKey) => {
const tree = () => (
<MemoryRouter initialEntries={[url]}>
<PreferencesProvider><Routes><Route path={path} element={page}/></Routes></PreferencesProvider>
<PreferencesProvider><CurrentUserProvider><Routes><Route path={path} element={<Page/>}/></Routes></CurrentUserProvider></PreferencesProvider>
</MemoryRouter>
);
const {rerender} = render(tree());