diff --git a/README.md b/README.md index f224c0c..14f198b 100644 --- a/README.md +++ b/README.md @@ -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. - **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. +- **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 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`. @@ -130,6 +132,10 @@ flowchart LR - `src/utils/brokenImages.js` reports pictures that fail to load (`GameCover`, achievement icons on the game detail page) to Core. - `useLibrarySync` polls Core's `/api/library/sync-status` while a Steam sync runs, then loads the data again. +- `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 diff --git a/public/i18n/de.json b/public/i18n/de.json index b51326d..af20826 100644 --- a/public/i18n/de.json +++ b/public/i18n/de.json @@ -134,10 +134,10 @@ "EMAIL_REQUIRED": "Email ist erforderlich.", "EMAIL_INVALID": "Gib eine gültige Email-Adresse ein.", "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_MISMATCH": "Die Passwörter stimmen nicht überein.", "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)." }, "INFO": { diff --git a/public/i18n/en.json b/public/i18n/en.json index 7583d9a..d584e90 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -134,10 +134,10 @@ "EMAIL_REQUIRED": "Email is required.", "EMAIL_INVALID": "Enter a valid email address.", "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_MISMATCH": "Passwords do not match.", "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)." }, "INFO": { diff --git a/src/App.jsx b/src/App.jsx index 7fd0b1b..5b48410 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -28,7 +28,7 @@ import {logout} from './utils/api.js'; function App() { const {t} = useTranslation(); const [isAuthenticated, setIsAuthenticated] = useState(false); - const {appearance, syncAppearance, resetAppearance} = usePreferences(); + const {appearance, syncPreferences, resetAppearance} = usePreferences(); // Hooks for routing const navigate = useNavigate(); @@ -42,8 +42,8 @@ function App() { // On login and on page load; the cached look shows until Auth answers. useEffect(() => { - if (isAuthenticated) syncAppearance(); - }, [isAuthenticated, syncAppearance]); + if (isAuthenticated) syncPreferences(); + }, [isAuthenticated, syncPreferences]); // Waits for Auth: a late logout answer would clear the cookies of a login that came after it. const handleLogout = async () => { diff --git a/src/components/AppHeader.jsx b/src/components/AppHeader.jsx index a44d602..f2408b1 100644 --- a/src/components/AppHeader.jsx +++ b/src/components/AppHeader.jsx @@ -71,7 +71,16 @@ export default function AppHeader({isAuthenticated, onLogout}) { 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 ThemeIcon = isDarkMode ? Sun : Moon; diff --git a/src/components/AppHeader.test.jsx b/src/components/AppHeader.test.jsx index 8395920..353b6f7 100644 --- a/src/components/AppHeader.test.jsx +++ b/src/components/AppHeader.test.jsx @@ -2,14 +2,18 @@ import {afterEach, describe, expect, it, vi} from 'vitest'; import {cleanup, fireEvent, render, screen} from '@testing-library/react'; import {MemoryRouter} from 'react-router-dom'; +import {toast} from 'sonner'; +import {useTranslation} from 'react-i18next'; import AppHeader from './AppHeader.jsx'; import {PreferencesProvider} from '../context/PreferencesContext.jsx'; vi.mock('react-i18next', () => import('../test/i18n-mock.js')); +vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}})); afterEach(() => { cleanup(); localStorage.clear(); + useTranslation().i18n.language = 'de'; }); const loggedInAs = (me) => { @@ -27,6 +31,19 @@ const loggedInAs = (me) => { const openMenu = () => fireEvent.keyDown(screen.getByRole('button', {name: 'NAV.MENU'}), {key: 'Enter'}); describe('AppHeader', () => { + it('says so when Auth refuses the language switch', async () => { + loggedInAs({role: 'USER', activeRole: 'USER', settings: {language: 'de'}}); + fetch.mockImplementation(async (url, init) => (init?.method === 'PUT' + ? {ok: false, status: 500, json: async () => ({})} + : {ok: true, status: 200, json: async () => ({role: 'USER', activeRole: 'USER', settings: {language: 'de'}})})); + + 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. it('keeps a name on the home link when a narrow phone hides the app name', () => { render( diff --git a/src/context/PreferencesContext.jsx b/src/context/PreferencesContext.jsx index 3e10a2a..c52131d 100644 --- a/src/context/PreferencesContext.jsx +++ b/src/context/PreferencesContext.jsx @@ -4,11 +4,13 @@ import { applyAppearance, cacheAppearance, DEFAULT_APPEARANCE, - loadAppearance, + loadPreferences, normalizeAppearance, readCachedAppearance, saveAppearance, } 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'; // 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); }, [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 picksBefore = languagePicks.current; syncing.current = (async () => { try { - const loaded = await loadAppearance(); - if (loaded && generation.current === started) { - savedAppearance.current = loaded; - setAppearanceState(normalizeAppearance({...loaded, ...pendingChanges.current})); + const loaded = await loadPreferences(); + if (loaded?.appearance && generation.current === started) { + savedAppearance.current = loaded.appearance; + setAppearanceState(normalizeAppearance({...loaded.appearance, ...pendingChanges.current})); + } + if (LANGUAGES.includes(loaded?.language) && languagePicks.current === picksBefore + && loaded.language !== i18n.language) { + i18n.changeLanguage(loaded.language); } } catch (err) { - console.error('Appearance load error:', err); + console.error('Preferences load error:', err); } })(); return syncing.current; - }, []); + }, [i18n]); /** * 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); }, []); - 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 ( diff --git a/src/context/PreferencesContext.test.jsx b/src/context/PreferencesContext.test.jsx index f4e910b..aaacca3 100644 --- a/src/context/PreferencesContext.test.jsx +++ b/src/context/PreferencesContext.test.jsx @@ -3,7 +3,11 @@ import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; import {act, cleanup, render} from '@testing-library/react'; 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; function Spy() { @@ -17,11 +21,67 @@ beforeEach(() => { document.head.innerHTML = ''; }); +beforeEach(() => { + i18n.language = 'de'; + i18n.changeLanguage = vi.fn((lng) => { i18n.language = lng; }); +}); + afterEach(() => { cleanup(); 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(); + + 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(); + + 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(); + + 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(); + + 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', () => { 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'); diff --git a/src/hooks/useSaveAppearance.test.jsx b/src/hooks/useSaveAppearance.test.jsx index e14a61c..840cd97 100644 --- a/src/hooks/useSaveAppearance.test.jsx +++ b/src/hooks/useSaveAppearance.test.jsx @@ -121,7 +121,7 @@ describe('useSaveAppearance', () => { let sync; let save; act(() => { - sync = result.current.preferences.syncAppearance(); + sync = result.current.preferences.syncPreferences(); }); act(() => { save = result.current.save({corners: 'round'}); @@ -149,7 +149,7 @@ describe('useSaveAppearance', () => { let sync; act(() => { - sync = result.current.preferences.syncAppearance(); + sync = result.current.preferences.syncPreferences(); }); act(() => result.current.preferences.resetAppearance()); await act(async () => { @@ -209,7 +209,7 @@ describe('useSaveAppearance', () => { const {result} = renderSave(); act(() => { - result.current.preferences.syncAppearance(); + result.current.preferences.syncPreferences(); }); let save; act(() => { diff --git a/src/i18n.js b/src/i18n.js index d0ca901..fd30c28 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -1,12 +1,13 @@ import i18n from 'i18next'; import {initReactI18next} from 'react-i18next'; import HttpBackend from 'i18next-http-backend'; +import {cacheLanguage, readCachedLanguage} from './utils/settings.js'; i18n.use(HttpBackend) .use(initReactI18next) .init({ - lng: 'de', + lng: readCachedLanguage() ?? 'de', fallbackLng: 'en', backend: { // 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; \ No newline at end of file diff --git a/src/pages/Dashboard.jsx b/src/pages/Dashboard.jsx index c84f9d9..c4da6d5 100644 --- a/src/pages/Dashboard.jsx +++ b/src/pages/Dashboard.jsx @@ -213,18 +213,18 @@ const Dashboard = () => { } else if (profileResponse.status === 401) { expireSession(); } else { - setError(t('INFO.ERROR.LOAD_PROFILE')); + setError('INFO.ERROR.LOAD_PROFILE'); } } catch (err) { console.error('Dashboard fetch error:', err); - setError(t('INFO.ERROR.NETWORK_FAILURE')); + setError('INFO.ERROR.NETWORK_FAILURE'); } finally { setIsLoading(false); } }; fetchUserData(); - }, [navigate, expireSession, t, fetchSteamCount, fetchTopGames, fetchPlaying, fetchSummary, fetchActivity, fetchAchievements]); + }, [navigate, expireSession, fetchSteamCount, fetchTopGames, fetchPlaying, fetchSummary, fetchActivity, fetchAchievements]); const hasSteamId = !!userData?.steamId; const showLibrary = hasSteamId || steamData.count > 0; @@ -248,7 +248,7 @@ const Dashboard = () => { if (error) { return (
- {error} + {t(error)}
); } diff --git a/src/pages/GameDetail.jsx b/src/pages/GameDetail.jsx index 4931b27..ed291dc 100644 --- a/src/pages/GameDetail.jsx +++ b/src/pages/GameDetail.jsx @@ -89,6 +89,14 @@ export default function GameDetail() { shownLevel.current = {appid: data.appid, level: celebrationLevel(data)}; }, [data]); + // Another game starts empty, already in this render. A reload of the same game keeps the old data, so the edit + // cards keep what is typed. + const [dataAppid, setDataAppid] = useState(appid); + if (dataAppid !== appid) { + setDataAppid(appid); + setData(null); + } + useEffect(() => { let cancelled = false; if (!hasSession()) { @@ -111,11 +119,11 @@ export default function GameDetail() { return; } if (response.status === 404) { - setError(t('GAME_DETAIL.NOT_FOUND')); + setError('GAME_DETAIL.NOT_FOUND'); return; } if (!response.ok) { - setError(t('GAME_DETAIL.ERROR_LOAD')); + setError('GAME_DETAIL.ERROR_LOAD'); return; } const json = await response.json(); @@ -123,7 +131,7 @@ export default function GameDetail() { } catch (err) { if (!cancelled) { console.error('GameDetail load error:', err); - setError(t('INFO.ERROR.NETWORK_FAILURE')); + setError('INFO.ERROR.NETWORK_FAILURE'); } } finally { if (!cancelled) setIsLoading(false); @@ -132,7 +140,7 @@ export default function GameDetail() { return () => { cancelled = true; }; - }, [appid, navigate, expireSession, t, reloads]); + }, [appid, navigate, expireSession, reloads]); const sortedAchievements = useMemo(() => { if (!data?.achievements) return []; @@ -188,7 +196,7 @@ export default function GameDetail() { {t('GAME_DETAIL.BACK')} - {isLoading && ( + {isLoading && !data && (
@@ -197,12 +205,12 @@ export default function GameDetail() { {error && !isLoading && (
-

{error}

+

{t(error)}

)} - {!isLoading && !error && data && ( + {!error && data && ( <> {data.hidden && (
diff --git a/src/pages/GameDetail.reload.test.jsx b/src/pages/GameDetail.reload.test.jsx new file mode 100644 index 0000000..1c67308 --- /dev/null +++ b/src/pages/GameDetail.reload.test.jsx @@ -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}) => , +})); + +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( + + }/> + , + ); + 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')); +}); diff --git a/src/pages/GameLibrary.jsx b/src/pages/GameLibrary.jsx index 23563ee..5e1380b 100644 --- a/src/pages/GameLibrary.jsx +++ b/src/pages/GameLibrary.jsx @@ -89,7 +89,7 @@ export default function GameLibrary() { } if (!response.ok) { - setError(t('LIBRARY.ERROR_LOAD')); + setError('LIBRARY.ERROR_LOAD'); return; } @@ -97,11 +97,11 @@ export default function GameLibrary() { setData(json); } catch (err) { console.error('Library load error:', err); - setError(t('INFO.ERROR.NETWORK_FAILURE')); + setError('INFO.ERROR.NETWORK_FAILURE'); } finally { setIsLoading(false); } - }, [navigate, expireSession, page, size, sort, listStatus, q, listShortOnly, hiddenOnly, t]); + }, [navigate, expireSession, page, size, sort, listStatus, q, listShortOnly, hiddenOnly]); useEffect(() => { load(); @@ -287,7 +287,7 @@ export default function GameLibrary() { )} - {error &&
{error}
} + {error &&
{t(error)}
} {ready && data.content.length === 0 && ( diff --git a/src/pages/PublicProfilePage.jsx b/src/pages/PublicProfilePage.jsx index 25b4686..225554b 100644 --- a/src/pages/PublicProfilePage.jsx +++ b/src/pages/PublicProfilePage.jsx @@ -51,11 +51,11 @@ export default function PublicProfilePage() { `${import.meta.env.VITE_API_URL}/api/public/profiles/${encodeURIComponent(name)}`); if (cancelled) return; if (profileResponse.status === 404) { - setError(t('PROFILE.NOT_FOUND')); + setError('PROFILE.NOT_FOUND'); return; } if (!profileResponse.ok) { - setError(t('PROFILE.ERROR_LOAD')); + setError('PROFILE.ERROR_LOAD'); return; } const found = await profileResponse.json(); @@ -64,7 +64,7 @@ export default function PublicProfilePage() { }); if (cancelled) return; if (!libraryResponse.ok) { - setError(t('PROFILE.ERROR_LOAD')); + setError('PROFILE.ERROR_LOAD'); return; } const data = await libraryResponse.json(); @@ -74,20 +74,20 @@ export default function PublicProfilePage() { } } catch (err) { console.error('Public profile load error:', err); - if (!cancelled) setError(t('INFO.ERROR.NETWORK_FAILURE')); + if (!cancelled) setError('INFO.ERROR.NETWORK_FAILURE'); } })(); return () => { cancelled = true; }; - }, [name, t, reloads]); + }, [name, reloads]); if (error) { return ( -

{error}

+

{t(error)}

{t('PROFILE.TO_START')}
diff --git a/src/pages/SettingsPage.jsx b/src/pages/SettingsPage.jsx index faa1458..bcabf72 100644 --- a/src/pages/SettingsPage.jsx +++ b/src/pages/SettingsPage.jsx @@ -31,6 +31,7 @@ import {coverUrl} from '../utils/games.js'; import {apiFetch, refreshSession} from '../utils/api.js'; import {hasSession, markSession} from '../utils/auth.js'; import {FAVORITE_PLATFORMS, saveFavoritePlatform} from '../utils/platforms.js'; +import {LANGUAGES, saveSettings} from '../utils/settings.js'; const BACKEND_ERROR_KEY = { 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'}, }; -const SUPPORTED_LANGUAGES = ['en', 'de']; // Radix Select has no empty value. const NO_FAVORITE = 'NONE'; @@ -81,7 +81,7 @@ export default function SettingsPage() { formState: {errors, isSubmitting}, } = useForm({ 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. @@ -123,42 +123,32 @@ export default function SettingsPage() { } if (!response.ok) { - setLoadError(t('INFO.ERROR.LOAD_PROFILE')); + setLoadError('INFO.ERROR.LOAD_PROFILE'); return; } const data = await response.json(); setProfile(data); - reset({ - useDarkTheme: !!data.settings?.useDarkTheme, - language: data.settings?.language || 'en', - displayName: data.displayName ?? '', - }); + reset({displayName: data.displayName ?? ''}); } catch (err) { console.error('Settings load error:', err); - setLoadError(t('INFO.ERROR.NETWORK_FAILURE')); + setLoadError('INFO.ERROR.NETWORK_FAILURE'); } finally { setIsLoading(false); } }; load(); - }, [navigate, expireSession, reset, t, steamOutcome]); + }, [navigate, expireSession, reset, steamOutcome]); - const persistSettings = async (payload) => { + const persistSettings = async (send) => { if (!hasSession()) { navigate('/login'); return {ok: false}; } try { - const response = await apiFetch(`${import.meta.env.VITE_API_URL}/api/users/settings`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(payload), - }); + const response = await send(); if (response.status === 401) { 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 next = !isDarkMode; setDarkMode(next); // apply on the fly - await persistSettings({...savedSettings(), useDarkTheme: next}); + await persistSettings(() => saveSettings({useDarkTheme: next})); }; const handleLanguageChange = async (next) => { - setLanguage(next); // apply on the fly - await persistSettings({...savedSettings(), language: next}); + await persistSettings(() => setLanguage(next)); }; const handleFavoritePlatformChange = async (value) => { @@ -210,7 +191,7 @@ export default function SettingsPage() { const handlePublicProfileToggle = async () => { const next = !profile?.settings?.publicProfile; - const result = await persistSettings({...savedSettings(), publicProfile: next}); + const result = await persistSettings(() => saveSettings({publicProfile: next})); if (result.ok) { setProfile((p) => ({...p, settings: {...p.settings, publicProfile: next}})); } @@ -237,12 +218,8 @@ export default function SettingsPage() { const onSubmitProfile = async (values) => { // Without a name the profile has no link, so removing the name also makes it private. - const publicProfile = savedSettings().publicProfile && !!values.displayName; - const result = await persistSettings({ - ...savedSettings(), - displayName: values.displayName, - publicProfile, - }); + 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}, @@ -262,7 +239,7 @@ export default function SettingsPage() { } if (loadError) { - return
{loadError}
; + return
{t(loadError)}
; } const publicProfile = !!profile?.settings?.publicProfile; @@ -313,7 +290,7 @@ export default function SettingsPage() { - {SUPPORTED_LANGUAGES.map((lang) => ( + {LANGUAGES.map((lang) => ( {t(`SETTINGS.LANGUAGE_OPTIONS.${lang.toUpperCase()}`)} diff --git a/src/pages/SettingsPage.test.jsx b/src/pages/SettingsPage.test.jsx index 538f507..78bb800 100644 --- a/src/pages/SettingsPage.test.jsx +++ b/src/pages/SettingsPage.test.jsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom 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 SettingsPage from './SettingsPage.jsx'; import {PreferencesProvider} from '../context/PreferencesContext.jsx'; @@ -29,6 +29,24 @@ function renderPage(me) { render(); } +/** + * 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', () => { beforeEach(() => localStorage.setItem('tbp_session', '1')); @@ -46,6 +64,20 @@ describe('SettingsPage public profile', () => { 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(); + + 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 () => { renderPage(profile()); diff --git a/src/pages/WishlistPage.jsx b/src/pages/WishlistPage.jsx index eb7c019..8d8f508 100644 --- a/src/pages/WishlistPage.jsx +++ b/src/pages/WishlistPage.jsx @@ -40,20 +40,20 @@ export default function WishlistPage() { return; } if (!response.ok) { - setError(t('WISHLIST.ERROR_LOAD')); + setError('WISHLIST.ERROR_LOAD'); return; } const json = await response.json(); if (!cancelled) setGames(json.games ?? []); } catch (err) { console.error('Wishlist load error:', err); - if (!cancelled) setError(t('INFO.ERROR.NETWORK_FAILURE')); + if (!cancelled) setError('INFO.ERROR.NETWORK_FAILURE'); } })(); return () => { cancelled = true; }; - }, [navigate, expireSession, t]); + }, [navigate, expireSession]); const shown = (games ?? []).filter((g) => !onlyOnSale || g.discountPct > 0); const onSaleCount = (games ?? []).filter((g) => g.discountPct > 0).length; @@ -91,7 +91,7 @@ export default function WishlistPage() {
)} - {error &&
{error}
} + {error &&
{t(error)}
} {games !== null && shown.length === 0 && ( diff --git a/src/pages/languageSwitch.test.jsx b/src/pages/languageSwitch.test.jsx new file mode 100644 index 0000000..1774803 --- /dev/null +++ b/src/pages/languageSwitch.test.jsx @@ -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', '/', '/', , 'INFO.ERROR.LOAD_PROFILE'], + ['GameDetail', '/games/:appid', '/games/220', , 'GAME_DETAIL.ERROR_LOAD'], + ['GameLibrary', '/library', '/library', , 'LIBRARY.ERROR_LOAD'], + ['PublicProfilePage', '/u/:name', '/u/Lobo', , 'PROFILE.ERROR_LOAD'], + ['SettingsPage', '/settings', '/settings', , 'INFO.ERROR.LOAD_PROFILE'], + ['WishlistPage', '/wishlist', '/wishlist', , '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 = () => ( + + + + ); + 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); + }); +}); diff --git a/src/test/i18n-mock.js b/src/test/i18n-mock.js index e8ecb2e..d27527f 100644 --- a/src/test/i18n-mock.js +++ b/src/test/i18n-mock.js @@ -1,6 +1,15 @@ // Module mock for react-i18next: t() returns the key (plus ":" for plurals), so tests do not depend on the texts. // 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 i18n = {language: 'de'}; +const translate = (key, options) => (options?.count === undefined ? key : `${key}:${options.count}`); +let t = translate; +const i18n = {language: 'de', changeLanguage: (language) => { i18n.language = language; }}; 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)}`; +}; diff --git a/src/utils/appearance.js b/src/utils/appearance.js index f64ff7b..5802da4 100644 --- a/src/utils/appearance.js +++ b/src/utils/appearance.js @@ -1,4 +1,5 @@ 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. 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); const STORAGE_KEY = 'tbp_appearance'; -const REQUEST_TIMEOUT_MS = 10000; export const normalizeAppearance = (value) => ({ 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 () => { - const response = await apiFetch(`${import.meta.env.VITE_API_URL}/api/users/me?v=1`, { - // Saves wait for this load. - signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), - }); +export const loadPreferences = async () => { + // Saves wait for this load, so it has a timeout. + const response = await fetchMe(); if (!response.ok) return null; 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 ?? '', + }; }; diff --git a/src/utils/settings.js b/src/utils/settings.js new file mode 100644 index 0000000..c1c5365 --- /dev/null +++ b/src/utils/settings.js @@ -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; +}; diff --git a/src/utils/settings.test.js b/src/utils/settings.test.js new file mode 100644 index 0000000..69b275e --- /dev/null +++ b/src/utils/settings.test.js @@ -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(); + }); +}); diff --git a/src/utils/validation.js b/src/utils/validation.js index dfead2d..dbb000f 100644 --- a/src/utils/validation.js +++ b/src/utils/validation.js @@ -7,9 +7,11 @@ const emailField = z .min(1, 'VALIDATION.EMAIL_REQUIRED') .email('VALIDATION.EMAIL_INVALID'); +// Auth hashes with BCrypt, which takes at most 72 bytes. const passwordField = z .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 .string() @@ -40,7 +42,5 @@ export const registrationSchema = z }); export const settingsSchema = z.object({ - useDarkTheme: z.boolean(), - language: z.enum(['en', 'de'], {message: 'VALIDATION.LANGUAGE_INVALID'}), displayName: displayNameField, }); diff --git a/src/utils/validation.test.js b/src/utils/validation.test.js index 5737093..74fe443 100644 --- a/src/utils/validation.test.js +++ b/src/utils/validation.test.js @@ -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); + }); +});