Public Access
fix(settings): keep the language and save settings one after the other
This commit is contained in:
1 parent
14dd59f9e0
commit
f605917874
25 files changed
+423
-98
No files matched your search
@@ -67,6 +67,8 @@ To Be Played is split into three repositories:
|
|||||||
("Profile art" on a game's page). Visitors see the owner's look on that page only; light or dark mode stays theirs.
|
("Profile art" on a game's page). Visitors see the owner's look on that page only; light or dark mode stays theirs.
|
||||||
- **Appearance**: accent color, corner style, wide layout, page background and banner from your own game art.
|
- **Appearance**: accent color, corner style, wide layout, page background and banner from your own game art.
|
||||||
Saved in Auth, so it follows you to every device.
|
Saved in Auth, so it follows you to every device.
|
||||||
|
- **Language**: kept in the browser and saved in Auth; after the login Auth's language wins when one is saved. Dark
|
||||||
|
mode stays per device.
|
||||||
- **Broken pictures**: a Steam cover or achievement icon that does not load shows a placeholder and is reported
|
- **Broken pictures**: a Steam cover or achievement icon that does not load shows a placeholder and is reported
|
||||||
to Core once per page load (`POST /api/library/broken-images`, only with a session). Core repairs it at night;
|
to Core once per page load (`POST /api/library/broken-images`, only with a session). Core repairs it at night;
|
||||||
a repaired cover comes back as `imageUrl`.
|
a repaired cover comes back as `imageUrl`.
|
||||||
@@ -130,6 +132,10 @@ flowchart LR
|
|||||||
- `src/utils/brokenImages.js` reports pictures that fail to load (`GameCover`, achievement icons on the game
|
- `src/utils/brokenImages.js` reports pictures that fail to load (`GameCover`, achievement icons on the game
|
||||||
detail page) to Core.
|
detail page) to Core.
|
||||||
- `useLibrarySync` polls Core's `/api/library/sync-status` while a Steam sync runs, then loads the data again.
|
- `useLibrarySync` polls Core's `/api/library/sync-status` while a Steam sync runs, then loads the data again.
|
||||||
|
- `PUT /api/users/settings` replaces dark mode, language, display name and public profile at once, so every save
|
||||||
|
goes through `saveSettings` in `src/utils/settings.js`: one after the other, each on a fresh `GET /api/users/me`.
|
||||||
|
- Pages keep a load error as its i18n key and translate it when rendering, so a language switch loads nothing
|
||||||
|
again.
|
||||||
|
|
||||||
## Project structure
|
## Project structure
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -134,10 +134,10 @@
|
|||||||
"EMAIL_REQUIRED": "Email ist erforderlich.",
|
"EMAIL_REQUIRED": "Email ist erforderlich.",
|
||||||
"EMAIL_INVALID": "Gib eine gültige Email-Adresse ein.",
|
"EMAIL_INVALID": "Gib eine gültige Email-Adresse ein.",
|
||||||
"PASSWORD_TOO_SHORT": "Passwort muss mindestens 8 Zeichen haben.",
|
"PASSWORD_TOO_SHORT": "Passwort muss mindestens 8 Zeichen haben.",
|
||||||
|
"PASSWORD_TOO_LONG": "Passwort ist zu lang: höchstens 72 Zeichen, mit Umlauten oder Emoji weniger.",
|
||||||
"PASSWORD_CONFIRMATION_REQUIRED": "Bitte Passwort bestätigen.",
|
"PASSWORD_CONFIRMATION_REQUIRED": "Bitte Passwort bestätigen.",
|
||||||
"PASSWORD_MISMATCH": "Die Passwörter stimmen nicht überein.",
|
"PASSWORD_MISMATCH": "Die Passwörter stimmen nicht überein.",
|
||||||
"INVITE_CODE_REQUIRED": "Einladungscode ist erforderlich.",
|
"INVITE_CODE_REQUIRED": "Einladungscode ist erforderlich.",
|
||||||
"LANGUAGE_INVALID": "Nicht unterstützte Sprache.",
|
|
||||||
"DISPLAY_NAME_INVALID": "3–24 Zeichen: Buchstaben, Zahlen, Leerzeichen, _ . - (kein Leerzeichen am Anfang oder Ende)."
|
"DISPLAY_NAME_INVALID": "3–24 Zeichen: Buchstaben, Zahlen, Leerzeichen, _ . - (kein Leerzeichen am Anfang oder Ende)."
|
||||||
},
|
},
|
||||||
"INFO": {
|
"INFO": {
|
||||||
|
|||||||
+1
-1
@@ -134,10 +134,10 @@
|
|||||||
"EMAIL_REQUIRED": "Email is required.",
|
"EMAIL_REQUIRED": "Email is required.",
|
||||||
"EMAIL_INVALID": "Enter a valid email address.",
|
"EMAIL_INVALID": "Enter a valid email address.",
|
||||||
"PASSWORD_TOO_SHORT": "Password must be at least 8 characters.",
|
"PASSWORD_TOO_SHORT": "Password must be at least 8 characters.",
|
||||||
|
"PASSWORD_TOO_LONG": "Password is too long: at most 72 characters, fewer with umlauts or emoji.",
|
||||||
"PASSWORD_CONFIRMATION_REQUIRED": "Please confirm your password.",
|
"PASSWORD_CONFIRMATION_REQUIRED": "Please confirm your password.",
|
||||||
"PASSWORD_MISMATCH": "Passwords do not match.",
|
"PASSWORD_MISMATCH": "Passwords do not match.",
|
||||||
"INVITE_CODE_REQUIRED": "Invite code is required.",
|
"INVITE_CODE_REQUIRED": "Invite code is required.",
|
||||||
"LANGUAGE_INVALID": "Unsupported language.",
|
|
||||||
"DISPLAY_NAME_INVALID": "3–24 characters: letters, digits, spaces, _ . - (no space at the start or end)."
|
"DISPLAY_NAME_INVALID": "3–24 characters: letters, digits, spaces, _ . - (no space at the start or end)."
|
||||||
},
|
},
|
||||||
"INFO": {
|
"INFO": {
|
||||||
|
|||||||
+3
-3
@@ -28,7 +28,7 @@ import {logout} from './utils/api.js';
|
|||||||
function App() {
|
function App() {
|
||||||
const {t} = useTranslation();
|
const {t} = useTranslation();
|
||||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||||
const {appearance, syncAppearance, resetAppearance} = usePreferences();
|
const {appearance, syncPreferences, resetAppearance} = usePreferences();
|
||||||
|
|
||||||
// Hooks for routing
|
// Hooks for routing
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -42,8 +42,8 @@ function App() {
|
|||||||
|
|
||||||
// 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) syncAppearance();
|
if (isAuthenticated) syncPreferences();
|
||||||
}, [isAuthenticated, syncAppearance]);
|
}, [isAuthenticated, syncPreferences]);
|
||||||
|
|
||||||
// Waits for Auth: a late logout answer would clear the cookies of a login that came after it.
|
// Waits for Auth: a late logout answer would clear the cookies of a login that came after it.
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
|
|||||||
@@ -71,7 +71,16 @@ export default function AppHeader({isAuthenticated, onLogout}) {
|
|||||||
toast.error(t('NAV.VIEW_AS.ERROR'));
|
toast.error(t('NAV.VIEW_AS.ERROR'));
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleLanguage = () => setLanguage(i18n.language === 'de' ? 'en' : 'de');
|
// Unsaved, the next login would switch back to Auth's language.
|
||||||
|
const toggleLanguage = async () => {
|
||||||
|
try {
|
||||||
|
const response = await setLanguage(i18n.language === 'de' ? 'en' : 'de');
|
||||||
|
if (response && !response.ok) toast.error(t('SETTINGS.ERROR_SAVE'));
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Language save error:', err);
|
||||||
|
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
|
||||||
|
}
|
||||||
|
};
|
||||||
const toggleTheme = () => setDarkMode(!isDarkMode);
|
const toggleTheme = () => setDarkMode(!isDarkMode);
|
||||||
const ThemeIcon = isDarkMode ? Sun : Moon;
|
const ThemeIcon = isDarkMode ? Sun : Moon;
|
||||||
|
|
||||||
|
|||||||
@@ -2,14 +2,18 @@
|
|||||||
import {afterEach, describe, expect, it, vi} from 'vitest';
|
import {afterEach, describe, expect, it, vi} from 'vitest';
|
||||||
import {cleanup, fireEvent, render, screen} from '@testing-library/react';
|
import {cleanup, fireEvent, render, screen} from '@testing-library/react';
|
||||||
import {MemoryRouter} from 'react-router-dom';
|
import {MemoryRouter} from 'react-router-dom';
|
||||||
|
import {toast} from 'sonner';
|
||||||
|
import {useTranslation} from 'react-i18next';
|
||||||
import AppHeader from './AppHeader.jsx';
|
import AppHeader from './AppHeader.jsx';
|
||||||
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
|
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
|
||||||
|
|
||||||
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||||
|
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
|
useTranslation().i18n.language = 'de';
|
||||||
});
|
});
|
||||||
|
|
||||||
const loggedInAs = (me) => {
|
const loggedInAs = (me) => {
|
||||||
@@ -27,6 +31,19 @@ const loggedInAs = (me) => {
|
|||||||
const openMenu = () => fireEvent.keyDown(screen.getByRole('button', {name: 'NAV.MENU'}), {key: 'Enter'});
|
const openMenu = () => fireEvent.keyDown(screen.getByRole('button', {name: 'NAV.MENU'}), {key: 'Enter'});
|
||||||
|
|
||||||
describe('AppHeader', () => {
|
describe('AppHeader', () => {
|
||||||
|
it('says so when Auth refuses the language switch', async () => {
|
||||||
|
loggedInAs({role: 'USER', activeRole: 'USER', settings: {language: 'de'}});
|
||||||
|
fetch.mockImplementation(async (url, init) => (init?.method === 'PUT'
|
||||||
|
? {ok: false, status: 500, json: async () => ({})}
|
||||||
|
: {ok: true, status: 200, json: async () => ({role: 'USER', activeRole: 'USER', settings: {language: 'de'}})}));
|
||||||
|
|
||||||
|
await screen.findByRole('button', {name: 'NAV.MENU'});
|
||||||
|
openMenu();
|
||||||
|
fireEvent.click(await screen.findByRole('menuitem', {name: /NAV.LANGUAGE/}));
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(toast.error).toHaveBeenCalledWith('SETTINGS.ERROR_SAVE'));
|
||||||
|
});
|
||||||
|
|
||||||
// jsdom applies no media queries, so the test hides the name the way the 380px breakpoint does.
|
// jsdom applies no media queries, so the test hides the name the way the 380px breakpoint does.
|
||||||
it('keeps a name on the home link when a narrow phone hides the app name', () => {
|
it('keeps a name on the home link when a narrow phone hides the app name', () => {
|
||||||
render(
|
render(
|
||||||
|
|||||||
@@ -4,11 +4,13 @@ import {
|
|||||||
applyAppearance,
|
applyAppearance,
|
||||||
cacheAppearance,
|
cacheAppearance,
|
||||||
DEFAULT_APPEARANCE,
|
DEFAULT_APPEARANCE,
|
||||||
loadAppearance,
|
loadPreferences,
|
||||||
normalizeAppearance,
|
normalizeAppearance,
|
||||||
readCachedAppearance,
|
readCachedAppearance,
|
||||||
saveAppearance,
|
saveAppearance,
|
||||||
} from '../utils/appearance.js';
|
} from '../utils/appearance.js';
|
||||||
|
import {hasSession} from '../utils/auth.js';
|
||||||
|
import {LANGUAGES, saveSettings} from '../utils/settings.js';
|
||||||
|
|
||||||
const DARK_MODE_STORAGE_KEY = 'tbp_dark_mode';
|
const DARK_MODE_STORAGE_KEY = 'tbp_dark_mode';
|
||||||
// The --background of style.css; the installed app paints its title bar in this color.
|
// The --background of style.css; the installed app paints its title bar in this color.
|
||||||
@@ -51,21 +53,29 @@ export function PreferencesProvider({children}) {
|
|||||||
cacheAppearance(appearance);
|
cacheAppearance(appearance);
|
||||||
}, [appearance]);
|
}, [appearance]);
|
||||||
|
|
||||||
const syncAppearance = useCallback(() => {
|
// Grows on every language pick, so a load that started before does not undo it.
|
||||||
|
const languagePicks = useRef(0);
|
||||||
|
|
||||||
|
const syncPreferences = useCallback(() => {
|
||||||
const started = generation.current;
|
const started = generation.current;
|
||||||
|
const picksBefore = languagePicks.current;
|
||||||
syncing.current = (async () => {
|
syncing.current = (async () => {
|
||||||
try {
|
try {
|
||||||
const loaded = await loadAppearance();
|
const loaded = await loadPreferences();
|
||||||
if (loaded && generation.current === started) {
|
if (loaded?.appearance && generation.current === started) {
|
||||||
savedAppearance.current = loaded;
|
savedAppearance.current = loaded.appearance;
|
||||||
setAppearanceState(normalizeAppearance({...loaded, ...pendingChanges.current}));
|
setAppearanceState(normalizeAppearance({...loaded.appearance, ...pendingChanges.current}));
|
||||||
|
}
|
||||||
|
if (LANGUAGES.includes(loaded?.language) && languagePicks.current === picksBefore
|
||||||
|
&& loaded.language !== i18n.language) {
|
||||||
|
i18n.changeLanguage(loaded.language);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Appearance load error:', err);
|
console.error('Preferences load error:', err);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
return syncing.current;
|
return syncing.current;
|
||||||
}, []);
|
}, [i18n]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Applies the changed fields at once, then saves them to Auth after a running load and after the saves before
|
* Applies the changed fields at once, then saves them to Auth after a running load and after the saves before
|
||||||
@@ -114,7 +124,17 @@ export function PreferencesProvider({children}) {
|
|||||||
setAppearanceState(DEFAULT_APPEARANCE);
|
setAppearanceState(DEFAULT_APPEARANCE);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const setLanguage = (lang) => i18n.changeLanguage(lang);
|
/**
|
||||||
|
* Applies the language at once and saves it to Auth while logged in.
|
||||||
|
*
|
||||||
|
* @return the fetch response, or null when logged out; rejects on a network error
|
||||||
|
*/
|
||||||
|
const setLanguage = (lang) => {
|
||||||
|
languagePicks.current += 1;
|
||||||
|
i18n.changeLanguage(lang);
|
||||||
|
if (!hasSession()) return Promise.resolve(null);
|
||||||
|
return saveSettings({language: lang});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PreferencesContext.Provider
|
<PreferencesContext.Provider
|
||||||
@@ -125,7 +145,7 @@ export function PreferencesProvider({children}) {
|
|||||||
setLanguage,
|
setLanguage,
|
||||||
appearance,
|
appearance,
|
||||||
changeAppearance,
|
changeAppearance,
|
||||||
syncAppearance,
|
syncPreferences,
|
||||||
resetAppearance,
|
resetAppearance,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -3,7 +3,11 @@ import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
|||||||
import {act, cleanup, render} from '@testing-library/react';
|
import {act, cleanup, render} from '@testing-library/react';
|
||||||
import {PreferencesProvider, usePreferences} from './PreferencesContext.jsx';
|
import {PreferencesProvider, usePreferences} from './PreferencesContext.jsx';
|
||||||
|
|
||||||
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
const i18n = vi.hoisted(() => ({language: 'de'}));
|
||||||
|
vi.mock('react-i18next', async () => {
|
||||||
|
const mock = await import('../test/i18n-mock.js');
|
||||||
|
return {...mock, useTranslation: () => ({t: mock.useTranslation().t, i18n})};
|
||||||
|
});
|
||||||
|
|
||||||
let preferences;
|
let preferences;
|
||||||
function Spy() {
|
function Spy() {
|
||||||
@@ -17,11 +21,67 @@ beforeEach(() => {
|
|||||||
document.head.innerHTML = '<meta name="theme-color" content="#09090b">';
|
document.head.innerHTML = '<meta name="theme-color" content="#09090b">';
|
||||||
});
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
i18n.language = 'de';
|
||||||
|
i18n.changeLanguage = vi.fn((lng) => { i18n.language = lng; });
|
||||||
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const answerMe = (settings) => {
|
||||||
|
globalThis.fetch = vi.fn(async (url, init) => (init?.method === 'PUT'
|
||||||
|
? {ok: true, status: 204}
|
||||||
|
: {ok: true, status: 200, json: async () => ({displayName: 'Lobo', settings})}));
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('PreferencesProvider language', () => {
|
||||||
|
it('takes the language saved in Auth', async () => {
|
||||||
|
answerMe({language: 'en', useDarkTheme: true, publicProfile: false});
|
||||||
|
render(<PreferencesProvider><Spy/></PreferencesProvider>);
|
||||||
|
|
||||||
|
await act(() => preferences.syncPreferences());
|
||||||
|
|
||||||
|
expect(i18n.changeLanguage).toHaveBeenCalledWith('en');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps its own language while Auth has none', async () => {
|
||||||
|
answerMe({language: '', useDarkTheme: true, publicProfile: false});
|
||||||
|
render(<PreferencesProvider><Spy/></PreferencesProvider>);
|
||||||
|
|
||||||
|
await act(() => preferences.syncPreferences());
|
||||||
|
|
||||||
|
expect(i18n.changeLanguage).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a language picked while Auth was still answering', async () => {
|
||||||
|
answerMe({language: 'de', useDarkTheme: true, publicProfile: false});
|
||||||
|
render(<PreferencesProvider><Spy/></PreferencesProvider>);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
const sync = preferences.syncPreferences();
|
||||||
|
preferences.setLanguage('en');
|
||||||
|
await sync;
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(i18n.changeLanguage.mock.calls).toEqual([['en']]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('saves a picked language to Auth with the other settings unchanged', async () => {
|
||||||
|
localStorage.setItem('tbp_session', '1');
|
||||||
|
answerMe({language: 'de', useDarkTheme: true, publicProfile: true});
|
||||||
|
render(<PreferencesProvider><Spy/></PreferencesProvider>);
|
||||||
|
|
||||||
|
await act(() => preferences.setLanguage('en'));
|
||||||
|
|
||||||
|
const put = fetch.mock.calls.find(([, init]) => init?.method === 'PUT');
|
||||||
|
expect(put[0]).toMatch(/\/api\/users\/settings$/);
|
||||||
|
expect(JSON.parse(put[1].body)).toEqual({useDarkTheme: true, language: 'en', displayName: 'Lobo', publicProfile: true});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('PreferencesProvider theme color', () => {
|
describe('PreferencesProvider theme color', () => {
|
||||||
it('gives the installed app a light title bar in light mode and a dark one again in dark mode', () => {
|
it('gives the installed app a light title bar in light mode and a dark one again in dark mode', () => {
|
||||||
localStorage.setItem('tbp_dark_mode', 'false');
|
localStorage.setItem('tbp_dark_mode', 'false');
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ describe('useSaveAppearance', () => {
|
|||||||
let sync;
|
let sync;
|
||||||
let save;
|
let save;
|
||||||
act(() => {
|
act(() => {
|
||||||
sync = result.current.preferences.syncAppearance();
|
sync = result.current.preferences.syncPreferences();
|
||||||
});
|
});
|
||||||
act(() => {
|
act(() => {
|
||||||
save = result.current.save({corners: 'round'});
|
save = result.current.save({corners: 'round'});
|
||||||
@@ -149,7 +149,7 @@ describe('useSaveAppearance', () => {
|
|||||||
|
|
||||||
let sync;
|
let sync;
|
||||||
act(() => {
|
act(() => {
|
||||||
sync = result.current.preferences.syncAppearance();
|
sync = result.current.preferences.syncPreferences();
|
||||||
});
|
});
|
||||||
act(() => result.current.preferences.resetAppearance());
|
act(() => result.current.preferences.resetAppearance());
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
@@ -209,7 +209,7 @@ describe('useSaveAppearance', () => {
|
|||||||
const {result} = renderSave();
|
const {result} = renderSave();
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.preferences.syncAppearance();
|
result.current.preferences.syncPreferences();
|
||||||
});
|
});
|
||||||
let save;
|
let save;
|
||||||
act(() => {
|
act(() => {
|
||||||
|
|||||||
+4
-1
@@ -1,12 +1,13 @@
|
|||||||
import i18n from 'i18next';
|
import i18n from 'i18next';
|
||||||
import {initReactI18next} from 'react-i18next';
|
import {initReactI18next} from 'react-i18next';
|
||||||
import HttpBackend from 'i18next-http-backend';
|
import HttpBackend from 'i18next-http-backend';
|
||||||
|
import {cacheLanguage, readCachedLanguage} from './utils/settings.js';
|
||||||
|
|
||||||
|
|
||||||
i18n.use(HttpBackend)
|
i18n.use(HttpBackend)
|
||||||
.use(initReactI18next)
|
.use(initReactI18next)
|
||||||
.init({
|
.init({
|
||||||
lng: 'de',
|
lng: readCachedLanguage() ?? 'de',
|
||||||
fallbackLng: 'en',
|
fallbackLng: 'en',
|
||||||
backend: {
|
backend: {
|
||||||
// A browser kept an old file without its Cache-Control and showed raw keys; a new URL per version avoids that.
|
// A browser kept an old file without its Cache-Control and showed raw keys; a new URL per version avoids that.
|
||||||
@@ -17,4 +18,6 @@ i18n.use(HttpBackend)
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
i18n.on('languageChanged', cacheLanguage);
|
||||||
|
|
||||||
export default i18n;
|
export default i18n;
|
||||||
@@ -213,18 +213,18 @@ const Dashboard = () => {
|
|||||||
} else if (profileResponse.status === 401) {
|
} else if (profileResponse.status === 401) {
|
||||||
expireSession();
|
expireSession();
|
||||||
} else {
|
} else {
|
||||||
setError(t('INFO.ERROR.LOAD_PROFILE'));
|
setError('INFO.ERROR.LOAD_PROFILE');
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Dashboard fetch error:', err);
|
console.error('Dashboard fetch error:', err);
|
||||||
setError(t('INFO.ERROR.NETWORK_FAILURE'));
|
setError('INFO.ERROR.NETWORK_FAILURE');
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchUserData();
|
fetchUserData();
|
||||||
}, [navigate, expireSession, t, fetchSteamCount, fetchTopGames, fetchPlaying, fetchSummary, fetchActivity, fetchAchievements]);
|
}, [navigate, expireSession, fetchSteamCount, fetchTopGames, fetchPlaying, fetchSummary, fetchActivity, fetchAchievements]);
|
||||||
|
|
||||||
const hasSteamId = !!userData?.steamId;
|
const hasSteamId = !!userData?.steamId;
|
||||||
const showLibrary = hasSteamId || steamData.count > 0;
|
const showLibrary = hasSteamId || steamData.count > 0;
|
||||||
@@ -248,7 +248,7 @@ const Dashboard = () => {
|
|||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="py-24 text-center text-destructive">
|
<div className="py-24 text-center text-destructive">
|
||||||
{error}
|
{t(error)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,6 +89,14 @@ export default function GameDetail() {
|
|||||||
shownLevel.current = {appid: data.appid, level: celebrationLevel(data)};
|
shownLevel.current = {appid: data.appid, level: celebrationLevel(data)};
|
||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
|
// Another game starts empty, already in this render. A reload of the same game keeps the old data, so the edit
|
||||||
|
// cards keep what is typed.
|
||||||
|
const [dataAppid, setDataAppid] = useState(appid);
|
||||||
|
if (dataAppid !== appid) {
|
||||||
|
setDataAppid(appid);
|
||||||
|
setData(null);
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
if (!hasSession()) {
|
if (!hasSession()) {
|
||||||
@@ -111,11 +119,11 @@ export default function GameDetail() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (response.status === 404) {
|
if (response.status === 404) {
|
||||||
setError(t('GAME_DETAIL.NOT_FOUND'));
|
setError('GAME_DETAIL.NOT_FOUND');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
setError(t('GAME_DETAIL.ERROR_LOAD'));
|
setError('GAME_DETAIL.ERROR_LOAD');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const json = await response.json();
|
const json = await response.json();
|
||||||
@@ -123,7 +131,7 @@ export default function GameDetail() {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
console.error('GameDetail load error:', err);
|
console.error('GameDetail load error:', err);
|
||||||
setError(t('INFO.ERROR.NETWORK_FAILURE'));
|
setError('INFO.ERROR.NETWORK_FAILURE');
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (!cancelled) setIsLoading(false);
|
if (!cancelled) setIsLoading(false);
|
||||||
@@ -132,7 +140,7 @@ export default function GameDetail() {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [appid, navigate, expireSession, t, reloads]);
|
}, [appid, navigate, expireSession, reloads]);
|
||||||
|
|
||||||
const sortedAchievements = useMemo(() => {
|
const sortedAchievements = useMemo(() => {
|
||||||
if (!data?.achievements) return [];
|
if (!data?.achievements) return [];
|
||||||
@@ -188,7 +196,7 @@ export default function GameDetail() {
|
|||||||
<ArrowLeft/>{t('GAME_DETAIL.BACK')}
|
<ArrowLeft/>{t('GAME_DETAIL.BACK')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{isLoading && (
|
{isLoading && !data && (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<Skeleton className="h-52 rounded-xl"/>
|
<Skeleton className="h-52 rounded-xl"/>
|
||||||
<Skeleton className="h-40 rounded-xl"/>
|
<Skeleton className="h-40 rounded-xl"/>
|
||||||
@@ -197,12 +205,12 @@ export default function GameDetail() {
|
|||||||
|
|
||||||
{error && !isLoading && (
|
{error && !isLoading && (
|
||||||
<div className="py-12 text-center text-destructive space-y-4">
|
<div className="py-12 text-center text-destructive space-y-4">
|
||||||
<p>{error}</p>
|
<p>{t(error)}</p>
|
||||||
<Button asChild variant="outline"><Link to="/library">{t('GAME_DETAIL.BACK_TO_LIBRARY')}</Link></Button>
|
<Button asChild variant="outline"><Link to="/library">{t('GAME_DETAIL.BACK_TO_LIBRARY')}</Link></Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isLoading && !error && data && (
|
{!error && data && (
|
||||||
<>
|
<>
|
||||||
{data.hidden && (
|
{data.hidden && (
|
||||||
<div role="status" className="flex items-center gap-2 rounded-lg border bg-muted/50 px-4 py-3 text-sm text-muted-foreground">
|
<div role="status" className="flex items-center gap-2 rounded-lg border bg-muted/50 px-4 py-3 text-sm text-muted-foreground">
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import {afterEach, beforeEach, expect, it, vi} from 'vitest';
|
||||||
|
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
|
||||||
|
import {MemoryRouter, Route, Routes} from 'react-router-dom';
|
||||||
|
import GameDetail from './GameDetail.jsx';
|
||||||
|
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
|
||||||
|
|
||||||
|
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||||
|
// Stands in for a saved correction, which loads the game again.
|
||||||
|
vi.mock('../components/GameCorrectionDialog.jsx', () => ({
|
||||||
|
default: ({onChanged}) => <button type="button" onClick={onChanged}>correction saved</button>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const game = {
|
||||||
|
appid: 220, name: 'Half-Life 2', playtimeMinutes: 100, playtime2Weeks: 90, status: 'BACKLOG',
|
||||||
|
rating: null, notes: null, priority: null, hasAchievements: false, statsVisible: false, achievements: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const gameLoads = () => fetch.mock.calls.filter(([url]) => url.endsWith('/api/library/games/220')).length;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.setItem('tbp_session', '1');
|
||||||
|
globalThis.fetch = vi.fn(async (url) => ({ok: true, status: 200,
|
||||||
|
json: async () => (url.includes('/api/users/me') ? {role: 'AUDITOR', activeRole: 'AUDITOR'} : game)}));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps unsaved notes while the game loads again after a correction', async () => {
|
||||||
|
render(
|
||||||
|
<MemoryRouter initialEntries={['/library/220']}><PreferencesProvider>
|
||||||
|
<Routes><Route path="/library/:appid" element={<GameDetail/>}/></Routes>
|
||||||
|
</PreferencesProvider></MemoryRouter>,
|
||||||
|
);
|
||||||
|
fireEvent.change(await screen.findByLabelText('BACKLOG.NOTES_LABEL'), {target: {value: 'my draft'}});
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByRole('button', {name: 'correction saved'}));
|
||||||
|
|
||||||
|
await waitFor(() => expect(gameLoads()).toBe(2));
|
||||||
|
await waitFor(() => expect(screen.getByLabelText('BACKLOG.NOTES_LABEL').value).toBe('my draft'));
|
||||||
|
});
|
||||||
@@ -89,7 +89,7 @@ export default function GameLibrary() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
setError(t('LIBRARY.ERROR_LOAD'));
|
setError('LIBRARY.ERROR_LOAD');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,11 +97,11 @@ export default function GameLibrary() {
|
|||||||
setData(json);
|
setData(json);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Library load error:', err);
|
console.error('Library load error:', err);
|
||||||
setError(t('INFO.ERROR.NETWORK_FAILURE'));
|
setError('INFO.ERROR.NETWORK_FAILURE');
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}, [navigate, expireSession, page, size, sort, listStatus, q, listShortOnly, hiddenOnly, t]);
|
}, [navigate, expireSession, page, size, sort, listStatus, q, listShortOnly, hiddenOnly]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
load();
|
load();
|
||||||
@@ -287,7 +287,7 @@ export default function GameLibrary() {
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error && <div className="py-12 text-center text-destructive">{error}</div>}
|
{error && <div className="py-12 text-center text-destructive">{t(error)}</div>}
|
||||||
|
|
||||||
{ready && data.content.length === 0 && (
|
{ready && data.content.length === 0 && (
|
||||||
<Card className="items-center py-16 text-center text-muted-foreground">
|
<Card className="items-center py-16 text-center text-muted-foreground">
|
||||||
|
|||||||
@@ -51,11 +51,11 @@ export default function PublicProfilePage() {
|
|||||||
`${import.meta.env.VITE_API_URL}/api/public/profiles/${encodeURIComponent(name)}`);
|
`${import.meta.env.VITE_API_URL}/api/public/profiles/${encodeURIComponent(name)}`);
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
if (profileResponse.status === 404) {
|
if (profileResponse.status === 404) {
|
||||||
setError(t('PROFILE.NOT_FOUND'));
|
setError('PROFILE.NOT_FOUND');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!profileResponse.ok) {
|
if (!profileResponse.ok) {
|
||||||
setError(t('PROFILE.ERROR_LOAD'));
|
setError('PROFILE.ERROR_LOAD');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const found = await profileResponse.json();
|
const found = await profileResponse.json();
|
||||||
@@ -64,7 +64,7 @@ export default function PublicProfilePage() {
|
|||||||
});
|
});
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
if (!libraryResponse.ok) {
|
if (!libraryResponse.ok) {
|
||||||
setError(t('PROFILE.ERROR_LOAD'));
|
setError('PROFILE.ERROR_LOAD');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const data = await libraryResponse.json();
|
const data = await libraryResponse.json();
|
||||||
@@ -74,20 +74,20 @@ export default function PublicProfilePage() {
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Public profile load error:', err);
|
console.error('Public profile load error:', err);
|
||||||
if (!cancelled) setError(t('INFO.ERROR.NETWORK_FAILURE'));
|
if (!cancelled) setError('INFO.ERROR.NETWORK_FAILURE');
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [name, t, reloads]);
|
}, [name, reloads]);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<PageContainer className="p-4 md:p-8">
|
<PageContainer className="p-4 md:p-8">
|
||||||
<Card className="items-center py-16 text-center text-muted-foreground">
|
<Card className="items-center py-16 text-center text-muted-foreground">
|
||||||
<UserRoundX className="size-10"/>
|
<UserRoundX className="size-10"/>
|
||||||
<p className="max-w-sm text-sm">{error}</p>
|
<p className="max-w-sm text-sm">{t(error)}</p>
|
||||||
<Link to="/" className="text-sm font-medium text-primary hover:underline">{t('PROFILE.TO_START')}</Link>
|
<Link to="/" className="text-sm font-medium text-primary hover:underline">{t('PROFILE.TO_START')}</Link>
|
||||||
</Card>
|
</Card>
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
|
|||||||
+15
-38
@@ -31,6 +31,7 @@ import {coverUrl} from '../utils/games.js';
|
|||||||
import {apiFetch, refreshSession} from '../utils/api.js';
|
import {apiFetch, refreshSession} from '../utils/api.js';
|
||||||
import {hasSession, markSession} from '../utils/auth.js';
|
import {hasSession, markSession} from '../utils/auth.js';
|
||||||
import {FAVORITE_PLATFORMS, saveFavoritePlatform} from '../utils/platforms.js';
|
import {FAVORITE_PLATFORMS, saveFavoritePlatform} from '../utils/platforms.js';
|
||||||
|
import {LANGUAGES, saveSettings} from '../utils/settings.js';
|
||||||
|
|
||||||
const BACKEND_ERROR_KEY = {
|
const BACKEND_ERROR_KEY = {
|
||||||
DISPLAY_NAME_ALREADY_IN_USE: 'SETTINGS.ERROR_DISPLAY_NAME_IN_USE',
|
DISPLAY_NAME_ALREADY_IN_USE: 'SETTINGS.ERROR_DISPLAY_NAME_IN_USE',
|
||||||
@@ -44,7 +45,6 @@ const STEAM_LINK_TOASTS = {
|
|||||||
failed: {show: toast.error, key: 'SETTINGS.STEAM_LINK.FAILED'},
|
failed: {show: toast.error, key: 'SETTINGS.STEAM_LINK.FAILED'},
|
||||||
};
|
};
|
||||||
|
|
||||||
const SUPPORTED_LANGUAGES = ['en', 'de'];
|
|
||||||
// Radix Select has no empty value.
|
// Radix Select has no empty value.
|
||||||
const NO_FAVORITE = 'NONE';
|
const NO_FAVORITE = 'NONE';
|
||||||
|
|
||||||
@@ -81,7 +81,7 @@ export default function SettingsPage() {
|
|||||||
formState: {errors, isSubmitting},
|
formState: {errors, isSubmitting},
|
||||||
} = useForm({
|
} = useForm({
|
||||||
resolver: zodResolver(settingsSchema),
|
resolver: zodResolver(settingsSchema),
|
||||||
defaultValues: {useDarkTheme: true, language: 'en', displayName: ''},
|
defaultValues: {displayName: ''},
|
||||||
});
|
});
|
||||||
|
|
||||||
// A language change gives a new `t` and runs the effect again; the toast shows once.
|
// A language change gives a new `t` and runs the effect again; the toast shows once.
|
||||||
@@ -123,42 +123,32 @@ export default function SettingsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
setLoadError(t('INFO.ERROR.LOAD_PROFILE'));
|
setLoadError('INFO.ERROR.LOAD_PROFILE');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
setProfile(data);
|
setProfile(data);
|
||||||
reset({
|
reset({displayName: data.displayName ?? ''});
|
||||||
useDarkTheme: !!data.settings?.useDarkTheme,
|
|
||||||
language: data.settings?.language || 'en',
|
|
||||||
displayName: data.displayName ?? '',
|
|
||||||
});
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Settings load error:', err);
|
console.error('Settings load error:', err);
|
||||||
setLoadError(t('INFO.ERROR.NETWORK_FAILURE'));
|
setLoadError('INFO.ERROR.NETWORK_FAILURE');
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
load();
|
load();
|
||||||
}, [navigate, expireSession, reset, t, steamOutcome]);
|
}, [navigate, expireSession, reset, steamOutcome]);
|
||||||
|
|
||||||
const persistSettings = async (payload) => {
|
const persistSettings = async (send) => {
|
||||||
if (!hasSession()) {
|
if (!hasSession()) {
|
||||||
navigate('/login');
|
navigate('/login');
|
||||||
return {ok: false};
|
return {ok: false};
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await apiFetch(`${import.meta.env.VITE_API_URL}/api/users/settings`, {
|
const response = await send();
|
||||||
method: 'PUT',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
expireSession();
|
expireSession();
|
||||||
@@ -181,23 +171,14 @@ export default function SettingsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// The backend replaces all settings, so every save sends the saved values of the fields it does not change.
|
|
||||||
const savedSettings = () => ({
|
|
||||||
useDarkTheme: isDarkMode,
|
|
||||||
language: i18n.language,
|
|
||||||
displayName: profile?.displayName ?? null,
|
|
||||||
publicProfile: !!profile?.settings?.publicProfile,
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleDarkThemeToggle = async () => {
|
const handleDarkThemeToggle = async () => {
|
||||||
const next = !isDarkMode;
|
const next = !isDarkMode;
|
||||||
setDarkMode(next); // apply on the fly
|
setDarkMode(next); // apply on the fly
|
||||||
await persistSettings({...savedSettings(), useDarkTheme: next});
|
await persistSettings(() => saveSettings({useDarkTheme: next}));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLanguageChange = async (next) => {
|
const handleLanguageChange = async (next) => {
|
||||||
setLanguage(next); // apply on the fly
|
await persistSettings(() => setLanguage(next));
|
||||||
await persistSettings({...savedSettings(), language: next});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFavoritePlatformChange = async (value) => {
|
const handleFavoritePlatformChange = async (value) => {
|
||||||
@@ -210,7 +191,7 @@ export default function SettingsPage() {
|
|||||||
|
|
||||||
const handlePublicProfileToggle = async () => {
|
const handlePublicProfileToggle = async () => {
|
||||||
const next = !profile?.settings?.publicProfile;
|
const next = !profile?.settings?.publicProfile;
|
||||||
const result = await persistSettings({...savedSettings(), publicProfile: next});
|
const result = await persistSettings(() => saveSettings({publicProfile: next}));
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
setProfile((p) => ({...p, settings: {...p.settings, publicProfile: next}}));
|
setProfile((p) => ({...p, settings: {...p.settings, publicProfile: next}}));
|
||||||
}
|
}
|
||||||
@@ -237,12 +218,8 @@ export default function SettingsPage() {
|
|||||||
|
|
||||||
const onSubmitProfile = async (values) => {
|
const onSubmitProfile = async (values) => {
|
||||||
// Without a name the profile has no link, so removing the name also makes it private.
|
// Without a name the profile has no link, so removing the name also makes it private.
|
||||||
const publicProfile = savedSettings().publicProfile && !!values.displayName;
|
const publicProfile = !!profile?.settings?.publicProfile && !!values.displayName;
|
||||||
const result = await persistSettings({
|
const result = await persistSettings(() => saveSettings({displayName: values.displayName, publicProfile}));
|
||||||
...savedSettings(),
|
|
||||||
displayName: values.displayName,
|
|
||||||
publicProfile,
|
|
||||||
});
|
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
setProfile((p) => (p ? {
|
setProfile((p) => (p ? {
|
||||||
...p, displayName: values.displayName, settings: {...p.settings, publicProfile},
|
...p, displayName: values.displayName, settings: {...p.settings, publicProfile},
|
||||||
@@ -262,7 +239,7 @@ export default function SettingsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (loadError) {
|
if (loadError) {
|
||||||
return <div className="py-24 text-center text-destructive">{loadError}</div>;
|
return <div className="py-24 text-center text-destructive">{t(loadError)}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const publicProfile = !!profile?.settings?.publicProfile;
|
const publicProfile = !!profile?.settings?.publicProfile;
|
||||||
@@ -313,7 +290,7 @@ export default function SettingsPage() {
|
|||||||
<SelectValue/>
|
<SelectValue/>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{SUPPORTED_LANGUAGES.map((lang) => (
|
{LANGUAGES.map((lang) => (
|
||||||
<SelectItem key={lang} value={lang}>
|
<SelectItem key={lang} value={lang}>
|
||||||
{t(`SETTINGS.LANGUAGE_OPTIONS.${lang.toUpperCase()}`)}
|
{t(`SETTINGS.LANGUAGE_OPTIONS.${lang.toUpperCase()}`)}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||||
import {cleanup, fireEvent, render, screen, waitFor, within} from '@testing-library/react';
|
import {act, cleanup, fireEvent, render, screen, waitFor, within} from '@testing-library/react';
|
||||||
import {MemoryRouter} from 'react-router-dom';
|
import {MemoryRouter} from 'react-router-dom';
|
||||||
import SettingsPage from './SettingsPage.jsx';
|
import SettingsPage from './SettingsPage.jsx';
|
||||||
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
|
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
|
||||||
@@ -29,6 +29,24 @@ function renderPage(me) {
|
|||||||
render(<MemoryRouter><PreferencesProvider><SettingsPage/></PreferencesProvider></MemoryRouter>);
|
render(<MemoryRouter><PreferencesProvider><SettingsPage/></PreferencesProvider></MemoryRouter>);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auth with PUT /api/users/settings answered one by one through answer(); GET /api/users/me shows what was saved.
|
||||||
|
*/
|
||||||
|
function fakeAuth(me) {
|
||||||
|
const saved = structuredClone(me);
|
||||||
|
const open = [];
|
||||||
|
globalThis.fetch = vi.fn((url, init) => {
|
||||||
|
if (init?.method !== 'PUT') return Promise.resolve({ok: true, status: 200, json: async () => structuredClone(saved)});
|
||||||
|
return new Promise((resolve) => open.push(() => {
|
||||||
|
const body = JSON.parse(init.body);
|
||||||
|
saved.displayName = body.displayName;
|
||||||
|
Object.assign(saved.settings, {useDarkTheme: body.useDarkTheme, language: body.language, publicProfile: body.publicProfile});
|
||||||
|
resolve({ok: true, status: 204, json: async () => { throw new SyntaxError('no body'); }});
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
return {answer: () => open.shift()()};
|
||||||
|
}
|
||||||
|
|
||||||
describe('SettingsPage public profile', () => {
|
describe('SettingsPage public profile', () => {
|
||||||
beforeEach(() => localStorage.setItem('tbp_session', '1'));
|
beforeEach(() => localStorage.setItem('tbp_session', '1'));
|
||||||
|
|
||||||
@@ -46,6 +64,20 @@ describe('SettingsPage public profile', () => {
|
|||||||
expect(puts()[0].publicProfile).toBe(true);
|
expect(puts()[0].publicProfile).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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>);
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByRole('switch', {name: 'SETTINGS.PUBLIC_PROFILE'}));
|
||||||
|
await waitFor(() => expect(puts()).toHaveLength(1));
|
||||||
|
fireEvent.click(screen.getByRole('switch', {name: 'SETTINGS.DARK_THEME'}));
|
||||||
|
await act(async () => auth.answer());
|
||||||
|
|
||||||
|
await waitFor(() => expect(puts()).toHaveLength(2));
|
||||||
|
expect(puts()[1]).toMatchObject({publicProfile: false, useDarkTheme: false, displayName: 'Lobo'});
|
||||||
|
await act(async () => auth.answer());
|
||||||
|
});
|
||||||
|
|
||||||
it('turns the public profile off and shows the link while it is on', async () => {
|
it('turns the public profile off and shows the link while it is on', async () => {
|
||||||
renderPage(profile());
|
renderPage(profile());
|
||||||
|
|
||||||
|
|||||||
@@ -40,20 +40,20 @@ export default function WishlistPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
setError(t('WISHLIST.ERROR_LOAD'));
|
setError('WISHLIST.ERROR_LOAD');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const json = await response.json();
|
const json = await response.json();
|
||||||
if (!cancelled) setGames(json.games ?? []);
|
if (!cancelled) setGames(json.games ?? []);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Wishlist load error:', err);
|
console.error('Wishlist load error:', err);
|
||||||
if (!cancelled) setError(t('INFO.ERROR.NETWORK_FAILURE'));
|
if (!cancelled) setError('INFO.ERROR.NETWORK_FAILURE');
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [navigate, expireSession, t]);
|
}, [navigate, expireSession]);
|
||||||
|
|
||||||
const shown = (games ?? []).filter((g) => !onlyOnSale || g.discountPct > 0);
|
const shown = (games ?? []).filter((g) => !onlyOnSale || g.discountPct > 0);
|
||||||
const onSaleCount = (games ?? []).filter((g) => g.discountPct > 0).length;
|
const onSaleCount = (games ?? []).filter((g) => g.discountPct > 0).length;
|
||||||
@@ -91,7 +91,7 @@ export default function WishlistPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error && <div className="py-12 text-center text-destructive">{error}</div>}
|
{error && <div className="py-12 text-center text-destructive">{t(error)}</div>}
|
||||||
|
|
||||||
{games !== null && shown.length === 0 && (
|
{games !== null && shown.length === 0 && (
|
||||||
<Card className="items-center py-16 text-center text-muted-foreground">
|
<Card className="items-center py-16 text-center text-muted-foreground">
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||||
|
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 Dashboard from './Dashboard.jsx';
|
||||||
|
import GameDetail from './GameDetail.jsx';
|
||||||
|
import GameLibrary from './GameLibrary.jsx';
|
||||||
|
import PublicProfilePage from './PublicProfilePage.jsx';
|
||||||
|
import SettingsPage from './SettingsPage.jsx';
|
||||||
|
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'],
|
||||||
|
];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.setItem('tbp_session', '1');
|
||||||
|
switchLanguage('de');
|
||||||
|
globalThis.fetch = vi.fn(async () => ({ok: false, status: 500, json: async () => ({})}));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
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) => {
|
||||||
|
const tree = () => (
|
||||||
|
<MemoryRouter initialEntries={[url]}>
|
||||||
|
<PreferencesProvider><Routes><Route path={path} element={page}/></Routes></PreferencesProvider>
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
const {rerender} = render(tree());
|
||||||
|
await screen.findByText(`de:${errorKey}`);
|
||||||
|
const requests = fetch.mock.calls.length;
|
||||||
|
|
||||||
|
switchLanguage('en');
|
||||||
|
rerender(tree());
|
||||||
|
|
||||||
|
expect(await screen.findByText(`en:${errorKey}`)).toBeTruthy();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
expect(fetch.mock.calls.length).toBe(requests);
|
||||||
|
});
|
||||||
|
});
|
||||||
+11
-2
@@ -1,6 +1,15 @@
|
|||||||
// Module mock for react-i18next: t() returns the key (plus ":<count>" for plurals), so tests do not depend on the texts.
|
// Module mock for react-i18next: t() returns the key (plus ":<count>" for plurals), so tests do not depend on the texts.
|
||||||
// Use in a test file: vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
// Use in a test file: vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||||
const t = (key, options) => (options?.count === undefined ? key : `${key}:${options.count}`);
|
const translate = (key, options) => (options?.count === undefined ? key : `${key}:${options.count}`);
|
||||||
const i18n = {language: 'de'};
|
let t = translate;
|
||||||
|
const i18n = {language: 'de', changeLanguage: (language) => { i18n.language = language; }};
|
||||||
|
|
||||||
export const useTranslation = () => ({t, i18n});
|
export const useTranslation = () => ({t, i18n});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A new `t` that puts `language:` before each key, like react-i18next gives a new `t` on a language change. Rerender
|
||||||
|
* to see it.
|
||||||
|
*/
|
||||||
|
export const switchLanguage = (language) => {
|
||||||
|
t = (key, options) => `${language}:${translate(key, options)}`;
|
||||||
|
};
|
||||||
+10
-8
@@ -1,4 +1,5 @@
|
|||||||
import {apiFetch} from './api.js';
|
import {apiFetch} from './api.js';
|
||||||
|
import {fetchMe, REQUEST_TIMEOUT_MS} from './settings.js';
|
||||||
|
|
||||||
// Auth accepts only these names (AppearanceRequest); style.css has a preset for each.
|
// Auth accepts only these names (AppearanceRequest); style.css has a preset for each.
|
||||||
export const ACCENTS = ['violet', 'blue', 'green', 'orange', 'pink', 'red'];
|
export const ACCENTS = ['violet', 'blue', 'green', 'orange', 'pink', 'red'];
|
||||||
@@ -12,7 +13,6 @@ export const DEFAULT_APPEARANCE = {
|
|||||||
export const appid = (value) => (Number.isInteger(value) && value > 0 ? value : null);
|
export const appid = (value) => (Number.isInteger(value) && value > 0 ? value : null);
|
||||||
|
|
||||||
const STORAGE_KEY = 'tbp_appearance';
|
const STORAGE_KEY = 'tbp_appearance';
|
||||||
const REQUEST_TIMEOUT_MS = 10000;
|
|
||||||
|
|
||||||
export const normalizeAppearance = (value) => ({
|
export const normalizeAppearance = (value) => ({
|
||||||
accent: ACCENTS.includes(value?.accent) ? value.accent : DEFAULT_APPEARANCE.accent,
|
accent: ACCENTS.includes(value?.accent) ? value.accent : DEFAULT_APPEARANCE.accent,
|
||||||
@@ -60,14 +60,16 @@ export const saveAppearance = (appearance) =>
|
|||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/users/me on Auth, only for its appearance. Null when there is no answer to use.
|
* GET /api/users/me on Auth, for its appearance (null when none is saved) and its language ('' when none is chosen).
|
||||||
|
* Null when there is no answer to use.
|
||||||
*/
|
*/
|
||||||
export const loadAppearance = async () => {
|
export const loadPreferences = async () => {
|
||||||
const response = await apiFetch(`${import.meta.env.VITE_API_URL}/api/users/me?v=1`, {
|
// Saves wait for this load, so it has a timeout.
|
||||||
// Saves wait for this load.
|
const response = await fetchMe();
|
||||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
||||||
});
|
|
||||||
if (!response.ok) return null;
|
if (!response.ok) return null;
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
return data.settings?.appearance ? normalizeAppearance(data.settings.appearance) : null;
|
return {
|
||||||
|
appearance: data.settings?.appearance ? normalizeAppearance(data.settings.appearance) : null,
|
||||||
|
language: data.settings?.language ?? '',
|
||||||
|
};
|
||||||
};
|
};
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import {apiFetch} from './api.js';
|
||||||
|
|
||||||
|
export const LANGUAGES = ['en', 'de'];
|
||||||
|
|
||||||
|
const LANGUAGE_STORAGE_KEY = 'tbp_language';
|
||||||
|
export const REQUEST_TIMEOUT_MS = 10000;
|
||||||
|
|
||||||
|
export const readCachedLanguage = () => {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(LANGUAGE_STORAGE_KEY);
|
||||||
|
return LANGUAGES.includes(stored) ? stored : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const cacheLanguage = (language) => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(LANGUAGE_STORAGE_KEY, language);
|
||||||
|
} catch {
|
||||||
|
// Private mode or a full storage: the language is only not cached.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/users/me on Auth. The caller handles the response status.
|
||||||
|
*/
|
||||||
|
export const fetchMe = () => apiFetch(`${import.meta.env.VITE_API_URL}/api/users/me?v=1`, {
|
||||||
|
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||||
|
});
|
||||||
|
|
||||||
|
let queue = Promise.resolve();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/users/settings on Auth with `changes`. That endpoint replaces all four fields, so each save first loads
|
||||||
|
* the saved ones; saves run one after another, from every page. The caller handles the response status.
|
||||||
|
*/
|
||||||
|
export const saveSettings = (changes) => {
|
||||||
|
const save = async () => {
|
||||||
|
const me = await fetchMe();
|
||||||
|
if (!me.ok) return me;
|
||||||
|
const data = await me.json();
|
||||||
|
return apiFetch(`${import.meta.env.VITE_API_URL}/api/users/settings`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({
|
||||||
|
useDarkTheme: !!data.settings?.useDarkTheme,
|
||||||
|
language: data.settings?.language ?? '',
|
||||||
|
displayName: data.displayName ?? null,
|
||||||
|
publicProfile: !!data.settings?.publicProfile,
|
||||||
|
...changes,
|
||||||
|
}),
|
||||||
|
// A hung request must not hold back the next saves.
|
||||||
|
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const result = queue.then(save, save);
|
||||||
|
queue = result.catch(() => {});
|
||||||
|
return result;
|
||||||
|
};
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import {afterEach, describe, expect, it} from 'vitest';
|
||||||
|
import {cacheLanguage, readCachedLanguage} from './settings.js';
|
||||||
|
|
||||||
|
afterEach(() => localStorage.clear());
|
||||||
|
|
||||||
|
describe('language cache', () => {
|
||||||
|
it('gives back the cached language, and nothing for an unknown one', () => {
|
||||||
|
expect(readCachedLanguage()).toBeNull();
|
||||||
|
cacheLanguage('en');
|
||||||
|
expect(readCachedLanguage()).toBe('en');
|
||||||
|
localStorage.setItem('tbp_language', 'fr');
|
||||||
|
expect(readCachedLanguage()).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,9 +7,11 @@ const emailField = z
|
|||||||
.min(1, 'VALIDATION.EMAIL_REQUIRED')
|
.min(1, 'VALIDATION.EMAIL_REQUIRED')
|
||||||
.email('VALIDATION.EMAIL_INVALID');
|
.email('VALIDATION.EMAIL_INVALID');
|
||||||
|
|
||||||
|
// Auth hashes with BCrypt, which takes at most 72 bytes.
|
||||||
const passwordField = z
|
const passwordField = z
|
||||||
.string()
|
.string()
|
||||||
.min(8, 'VALIDATION.PASSWORD_TOO_SHORT');
|
.min(8, 'VALIDATION.PASSWORD_TOO_SHORT')
|
||||||
|
.refine((value) => new TextEncoder().encode(value).length <= 72, 'VALIDATION.PASSWORD_TOO_LONG');
|
||||||
|
|
||||||
const inviteCodeField = z
|
const inviteCodeField = z
|
||||||
.string()
|
.string()
|
||||||
@@ -40,7 +42,5 @@ export const registrationSchema = z
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const settingsSchema = z.object({
|
export const settingsSchema = z.object({
|
||||||
useDarkTheme: z.boolean(),
|
|
||||||
language: z.enum(['en', 'de'], {message: 'VALIDATION.LANGUAGE_INVALID'}),
|
|
||||||
displayName: displayNameField,
|
displayName: displayNameField,
|
||||||
});
|
});
|
||||||
@@ -29,3 +29,12 @@ describe('displayName validation', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
describe('registration password', () => {
|
||||||
|
it('rejects a password over 72 UTF-8 bytes, as Auth does', () => {
|
||||||
|
const password = 'é'.repeat(37);
|
||||||
|
expect(registrationSchema.safeParse({
|
||||||
|
email: 'a@b.de', password, passwordConfirmation: password, inviteCode: 'code',
|
||||||
|
}).success).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user