feat(appearance): accent color, corners, wide layout and game art background

This commit is contained in:
lobo committed 2026-09-24 15:06:16 +02:00
1 parent 91d5672421
commit 2c360c9421
28 files changed
+930 -42

No files matched your search

+25 -2
View File
@@ -64,7 +64,28 @@
"PUBLIC_PROFILE_HINT": "Jeder mit dem Link sieht deine Statistik, was du gerade spielst und deine durchgespielten Spiele mit Bewertung. Notizen, E-Mail und Steam-ID bleiben privat.", "PUBLIC_PROFILE_HINT": "Jeder mit dem Link sieht deine Statistik, was du gerade spielst und deine durchgespielten Spiele mit Bewertung. Notizen, E-Mail und Steam-ID bleiben privat.",
"PUBLIC_PROFILE_NEEDS_NAME": "Lege zuerst einen Anzeigenamen fest; er wird Teil des Links.", "PUBLIC_PROFILE_NEEDS_NAME": "Lege zuerst einen Anzeigenamen fest; er wird Teil des Links.",
"PUBLIC_PROFILE_COPY": "Link kopieren", "PUBLIC_PROFILE_COPY": "Link kopieren",
"PUBLIC_PROFILE_COPIED": "Link kopiert" "PUBLIC_PROFILE_COPIED": "Link kopiert",
"APPEARANCE": "Aussehen",
"ACCENT": "Akzentfarbe",
"ACCENT_OPTIONS": {
"VIOLET": "Violett",
"BLUE": "Blau",
"GREEN": "Grün",
"ORANGE": "Orange",
"PINK": "Pink",
"RED": "Rot"
},
"CORNERS": "Ecken",
"CORNER_OPTIONS": {
"SHARP": "Eckig",
"DEFAULT": "Normal",
"ROUND": "Rund"
},
"WIDE_LAYOUT": "Breites Layout",
"WIDE_LAYOUT_HINT": "Nutzt mehr Platz auf breiten Bildschirmen.",
"BACKGROUND": "Hintergrund",
"BACKGROUND_HINT": "Öffne ein Spiel in deiner Bibliothek und wähle „Als Hintergrund“.",
"BACKGROUND_REMOVE": "Hintergrund entfernen"
}, },
"VALIDATION": { "VALIDATION": {
"EMAIL_REQUIRED": "Email ist erforderlich.", "EMAIL_REQUIRED": "Email ist erforderlich.",
@@ -158,7 +179,9 @@
"TTB": "Spieldauer laut IGDB", "TTB": "Spieldauer laut IGDB",
"TTB_HASTILY": "Story", "TTB_HASTILY": "Story",
"TTB_NORMALLY": "+ Extras", "TTB_NORMALLY": "+ Extras",
"TTB_COMPLETELY": "100 %" "TTB_COMPLETELY": "100 %",
"SET_BACKGROUND": "Als Hintergrund",
"REMOVE_BACKGROUND": "Hintergrund entfernen"
}, },
"LANDING": { "LANDING": {
"STATS_TITLE": "Community-Statistiken", "STATS_TITLE": "Community-Statistiken",
+25 -2
View File
@@ -64,7 +64,28 @@
"PUBLIC_PROFILE_HINT": "Anyone with the link sees your stats, what you play right now and your finished games with ratings. Notes, email and Steam ID stay private.", "PUBLIC_PROFILE_HINT": "Anyone with the link sees your stats, what you play right now and your finished games with ratings. Notes, email and Steam ID stay private.",
"PUBLIC_PROFILE_NEEDS_NAME": "Set a display name first; it becomes part of the link.", "PUBLIC_PROFILE_NEEDS_NAME": "Set a display name first; it becomes part of the link.",
"PUBLIC_PROFILE_COPY": "Copy link", "PUBLIC_PROFILE_COPY": "Copy link",
"PUBLIC_PROFILE_COPIED": "Link copied" "PUBLIC_PROFILE_COPIED": "Link copied",
"APPEARANCE": "Appearance",
"ACCENT": "Accent color",
"ACCENT_OPTIONS": {
"VIOLET": "Violet",
"BLUE": "Blue",
"GREEN": "Green",
"ORANGE": "Orange",
"PINK": "Pink",
"RED": "Red"
},
"CORNERS": "Corners",
"CORNER_OPTIONS": {
"SHARP": "Sharp",
"DEFAULT": "Normal",
"ROUND": "Round"
},
"WIDE_LAYOUT": "Wide layout",
"WIDE_LAYOUT_HINT": "Uses more of a wide screen.",
"BACKGROUND": "Background",
"BACKGROUND_HINT": "Open a game in your library and choose \"Use as background\".",
"BACKGROUND_REMOVE": "Remove background"
}, },
"VALIDATION": { "VALIDATION": {
"EMAIL_REQUIRED": "Email is required.", "EMAIL_REQUIRED": "Email is required.",
@@ -158,7 +179,9 @@
"TTB": "Time to beat (IGDB)", "TTB": "Time to beat (IGDB)",
"TTB_HASTILY": "Story", "TTB_HASTILY": "Story",
"TTB_NORMALLY": "+ Extras", "TTB_NORMALLY": "+ Extras",
"TTB_COMPLETELY": "100%" "TTB_COMPLETELY": "100%",
"SET_BACKGROUND": "Use as background",
"REMOVE_BACKGROUND": "Remove background"
}, },
"LANDING": { "LANDING": {
"STATS_TITLE": "Community stats", "STATS_TITLE": "Community stats",
+12 -1
View File
@@ -15,10 +15,13 @@ import PublicProfilePage from './pages/PublicProfilePage.jsx';
import AppHeader from './components/AppHeader.jsx'; import AppHeader from './components/AppHeader.jsx';
import {Toaster} from '@/components/ui/sonner'; import {Toaster} from '@/components/ui/sonner';
import {TooltipProvider} from '@/components/ui/tooltip'; import {TooltipProvider} from '@/components/ui/tooltip';
import PageBackground from './components/PageBackground.jsx';
import {usePreferences} from './context/PreferencesContext.jsx';
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();
// Hooks for routing // Hooks for routing
const navigate = useNavigate(); const navigate = useNavigate();
@@ -30,15 +33,23 @@ function App() {
setIsAuthenticated(!!localStorage.getItem('tbp_jwt')); setIsAuthenticated(!!localStorage.getItem('tbp_jwt'));
}, [location]); }, [location]);
// On login and on page load; the cached look shows until Auth answers.
useEffect(() => {
if (isAuthenticated) syncAppearance();
}, [isAuthenticated, syncAppearance]);
const handleLogout = () => { const handleLogout = () => {
localStorage.removeItem('tbp_jwt'); localStorage.removeItem('tbp_jwt');
resetAppearance();
setIsAuthenticated(false); setIsAuthenticated(false);
navigate('/login'); navigate('/login');
}; };
return ( return (
<TooltipProvider> <TooltipProvider>
<div className="min-h-screen flex flex-col bg-background text-foreground font-sans"> {/* isolate: the background layer sits behind the content but in front of this div's own background. */}
<div className="isolate min-h-screen flex flex-col bg-background text-foreground font-sans">
{isAuthenticated && <PageBackground key={appearance.backgroundAppid} appid={appearance.backgroundAppid}/>}
<AppHeader isAuthenticated={isAuthenticated} onLogout={handleLogout}/> <AppHeader isAuthenticated={isAuthenticated} onLogout={handleLogout}/>
<main className="grow flex flex-col"> <main className="grow flex flex-col">
+3 -2
View File
@@ -12,6 +12,7 @@ import {
} from '@/components/ui/dropdown-menu'; } from '@/components/ui/dropdown-menu';
import {usePreferences} from '@/context/PreferencesContext.jsx'; import {usePreferences} from '@/context/PreferencesContext.jsx';
import {cn} from '@/lib/utils'; import {cn} from '@/lib/utils';
import PageContainer from './PageContainer.jsx';
const NAV_ITEMS = [ const NAV_ITEMS = [
{to: '/dashboard', labelKey: 'NAV.DASHBOARD', Icon: LayoutDashboard}, {to: '/dashboard', labelKey: 'NAV.DASHBOARD', Icon: LayoutDashboard},
@@ -37,7 +38,7 @@ export default function AppHeader({isAuthenticated, onLogout}) {
return ( return (
<header className="sticky top-0 z-50 border-b bg-background/80 backdrop-blur supports-[backdrop-filter]:bg-background/60"> <header className="sticky top-0 z-50 border-b bg-background/80 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="mx-auto flex h-16 max-w-5xl items-center gap-2 px-4 md:px-8"> <PageContainer className="flex h-16 items-center gap-2 px-4 md:px-8">
<Link to={isAuthenticated ? '/dashboard' : '/'} className="mr-2 flex items-center gap-2 font-bold tracking-tight whitespace-nowrap"> <Link to={isAuthenticated ? '/dashboard' : '/'} className="mr-2 flex items-center gap-2 font-bold tracking-tight whitespace-nowrap">
<span className="flex size-8 items-center justify-center rounded-lg bg-primary text-primary-foreground"> <span className="flex size-8 items-center justify-center rounded-lg bg-primary text-primary-foreground">
<Gamepad2 className="size-5"/> <Gamepad2 className="size-5"/>
@@ -95,7 +96,7 @@ export default function AppHeader({isAuthenticated, onLogout}) {
</> </>
)} )}
</div> </div>
</div> </PageContainer>
</header> </header>
); );
} }
+21
View File
@@ -0,0 +1,21 @@
import {useState} from 'react';
import {coverUrl, heroUrl} from '../utils/games.js';
/**
* Game art behind the whole page, blurred and dimmed so text on it stays readable. Falls back to the store
* header for apps without library art. Give it `key={appid}`, so a new game starts with its library art again.
*/
export default function PageBackground({appid}) {
const [failures, setFailures] = useState(0);
if (!appid || failures > 1) return null;
return (
<div aria-hidden="true" className="pointer-events-none fixed inset-0 -z-10 overflow-hidden">
<img src={failures === 0 ? heroUrl(appid) : coverUrl(appid)} alt=""
onError={() => setFailures((count) => count + 1)}
className="size-full scale-110 object-cover blur-md"/>
<div className="absolute inset-0 bg-background/80"/>
</div>
);
}
+26
View File
@@ -0,0 +1,26 @@
// @vitest-environment jsdom
import {afterEach, describe, expect, it} from 'vitest';
import {cleanup, fireEvent, render} from '@testing-library/react';
import PageBackground from './PageBackground.jsx';
afterEach(cleanup);
describe('PageBackground', () => {
it('shows nothing without a game', () => {
const {container} = render(<PageBackground appid={null}/>);
expect(container.innerHTML).toBe('');
});
it('shows the library art, then the store header, then nothing', () => {
const {container} = render(<PageBackground appid={620}/>);
const img = () => container.querySelector('img');
expect(img().getAttribute('src')).toContain('/620/library_hero.jpg');
fireEvent.error(img());
expect(img().getAttribute('src')).toContain('/620/header.jpg');
fireEvent.error(img());
expect(container.innerHTML).toBe('');
});
});
+14
View File
@@ -0,0 +1,14 @@
import {cn} from '@/lib/utils';
import {usePreferences} from '../context/PreferencesContext.jsx';
/**
* The centered page column. Its width follows the wide layout setting.
*/
export default function PageContainer({className, children}) {
const {appearance} = usePreferences();
return (
<div className={cn('w-full mx-auto', appearance.wideLayout ? 'max-w-7xl' : 'max-w-5xl', className)}>
{children}
</div>
);
}
+31
View File
@@ -0,0 +1,31 @@
// @vitest-environment jsdom
import {afterEach, describe, expect, it, vi} from 'vitest';
import {cleanup, render} from '@testing-library/react';
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
import PageContainer from './PageContainer.jsx';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
afterEach(() => {
cleanup();
localStorage.clear();
});
const renderContainer = () => render(
<PreferencesProvider><PageContainer className="p-4">content</PageContainer></PreferencesProvider>,
).container.firstChild;
describe('PageContainer', () => {
it('is the normal column by default', () => {
expect(renderContainer().className).toContain('max-w-5xl');
});
it('is wider with the wide layout', () => {
localStorage.setItem('tbp_appearance', JSON.stringify({wideLayout: true}));
const container = renderContainer();
expect(container.className).toContain('max-w-7xl');
expect(container.className).toContain('p-4');
});
});
+102 -1
View File
@@ -1,5 +1,14 @@
import {createContext, useContext, useEffect, useState} from 'react'; import {createContext, useCallback, useContext, useEffect, useLayoutEffect, useRef, useState} from 'react';
import {useTranslation} from 'react-i18next'; import {useTranslation} from 'react-i18next';
import {
applyAppearance,
cacheAppearance,
DEFAULT_APPEARANCE,
loadAppearance,
normalizeAppearance,
readCachedAppearance,
saveAppearance,
} from '../utils/appearance.js';
const DARK_MODE_STORAGE_KEY = 'tbp_dark_mode'; const DARK_MODE_STORAGE_KEY = 'tbp_dark_mode';
@@ -22,6 +31,85 @@ export function PreferencesProvider({children}) {
localStorage.setItem(DARK_MODE_STORAGE_KEY, String(isDarkMode)); localStorage.setItem(DARK_MODE_STORAGE_KEY, String(isDarkMode));
}, [isDarkMode]); }, [isDarkMode]);
const [appearance, setAppearanceState] = useState(readCachedAppearance);
// What Auth has, as far as this tab knows. A save sends it plus the pending changes, never a stale full look.
const savedAppearance = useRef(appearance);
// Changed here but not yet saved; a load from Auth keeps them.
const pendingChanges = useRef({});
const syncing = useRef(Promise.resolve());
const saving = useRef(Promise.resolve());
// Grows on reset, so a load or save that started for the user before does not come back.
const generation = useRef(0);
// Layout effect: the cached look is set before the first paint.
useLayoutEffect(() => {
applyAppearance(appearance);
cacheAppearance(appearance);
}, [appearance]);
const syncAppearance = useCallback(() => {
const started = generation.current;
syncing.current = (async () => {
try {
const loaded = await loadAppearance();
if (loaded && generation.current === started) {
savedAppearance.current = loaded;
setAppearanceState(normalizeAppearance({...loaded, ...pendingChanges.current}));
}
} catch (err) {
console.error('Appearance load error:', err);
}
})();
return syncing.current;
}, []);
/**
* Applies the changed fields at once, then saves them to Auth after a running load and after the saves before
* it, one at a time, so answers cannot arrive out of order. On a failed save it puts back the fields that still
* hold this change, so a later change is kept.
*
* @return the fetch response, or null when a reset came first and nothing was sent; throws on a network error
*/
const changeAppearance = useCallback((changes) => {
const started = generation.current;
pendingChanges.current = {...pendingChanges.current, ...changes};
setAppearanceState((current) => normalizeAppearance({...current, ...changes}));
const save = async () => {
await syncing.current;
if (generation.current !== started) return null;
const body = normalizeAppearance({...savedAppearance.current, ...pendingChanges.current});
let response = null;
try {
response = await saveAppearance(body);
return response;
} finally {
if (generation.current === started) {
const ok = !!response?.ok;
if (ok) savedAppearance.current = body;
const own = Object.keys(changes).filter((field) => pendingChanges.current[field] === changes[field]);
own.forEach((field) => delete pendingChanges.current[field]);
if (!ok && own.length) {
setAppearanceState((current) => normalizeAppearance({
...current, ...Object.fromEntries(own.map((field) => [field, savedAppearance.current[field]])),
}));
}
}
}
};
const result = saving.current.then(save, save);
saving.current = result.catch(() => {});
return result;
}, []);
const resetAppearance = useCallback(() => {
generation.current += 1;
pendingChanges.current = {};
savedAppearance.current = DEFAULT_APPEARANCE;
setAppearanceState(DEFAULT_APPEARANCE);
}, []);
const setLanguage = (lang) => i18n.changeLanguage(lang); const setLanguage = (lang) => i18n.changeLanguage(lang);
return ( return (
@@ -31,6 +119,10 @@ export function PreferencesProvider({children}) {
setDarkMode: setIsDarkMode, setDarkMode: setIsDarkMode,
language: i18n.language, language: i18n.language,
setLanguage, setLanguage,
appearance,
changeAppearance,
syncAppearance,
resetAppearance,
}} }}
> >
{children} {children}
@@ -45,3 +137,12 @@ export function usePreferences() {
} }
return ctx; return ctx;
} }
/**
* resetAppearance, or a no-op outside a PreferencesProvider, so hooks used in bare component tests keep working.
*/
export function useResetAppearance() {
return useContext(PreferencesContext)?.resetAppearance ?? noop;
}
const noop = () => {};
+35
View File
@@ -0,0 +1,35 @@
import {useCallback} from 'react';
import {useTranslation} from 'react-i18next';
import {toast} from 'sonner';
import {usePreferences} from '../context/PreferencesContext.jsx';
import useSessionExpired from './useSessionExpired.js';
/**
* Returns `save(changes)`: changeAppearance plus the error messages and the session end on a 401. Resolves to
* true when saved.
*/
export default function useSaveAppearance() {
const {t} = useTranslation();
const {changeAppearance} = usePreferences();
const expireSession = useSessionExpired();
return useCallback(async (changes) => {
try {
const response = await changeAppearance(changes);
if (!response) return false;
if (response.status === 401) {
expireSession();
return false;
}
if (!response.ok) {
toast.error(t('SETTINGS.ERROR_SAVE'));
return false;
}
return true;
} catch (err) {
console.error('Appearance save error:', err);
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
return false;
}
}, [changeAppearance, expireSession, t]);
}
+227
View File
@@ -0,0 +1,227 @@
// @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {act, cleanup, renderHook} from '@testing-library/react';
import {MemoryRouter} from 'react-router-dom';
import {toast} from 'sonner';
import {PreferencesProvider, usePreferences} from '../context/PreferencesContext.jsx';
import useSaveAppearance from './useSaveAppearance.js';
import {DEFAULT_APPEARANCE} from '../utils/appearance.js';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
const wrapper = ({children}) => (
<MemoryRouter><PreferencesProvider>{children}</PreferencesProvider></MemoryRouter>
);
const renderSave = () => renderHook(() => ({save: useSaveAppearance(), preferences: usePreferences()}), {wrapper});
beforeEach(() => {
localStorage.setItem('tbp_jwt', 'token');
});
afterEach(() => {
cleanup();
localStorage.clear();
vi.clearAllMocks();
});
describe('useSaveAppearance', () => {
it('applies the change at once and sends the whole appearance', async () => {
globalThis.fetch = vi.fn(async () => ({ok: true, status: 204}));
const {result} = renderSave();
let saved;
await act(async () => {
saved = await result.current.save({accent: 'blue'});
});
expect(saved).toBe(true);
expect(result.current.preferences.appearance.accent).toBe('blue');
expect(document.documentElement.dataset.accent).toBe('blue');
const [url, request] = fetch.mock.calls[0];
expect(url).toContain('/api/users/settings/appearance');
expect(request.method).toBe('PUT');
expect(JSON.parse(request.body)).toEqual({accent: 'blue', corners: 'default', wideLayout: false, backgroundAppid: null});
});
it('puts the old value back when Auth refuses', async () => {
globalThis.fetch = vi.fn(async () => ({ok: false, status: 400}));
const {result} = renderSave();
await act(async () => {
await result.current.save({corners: 'round'});
});
expect(result.current.preferences.appearance.corners).toBe('default');
expect(toast.error).toHaveBeenCalledWith('SETTINGS.ERROR_SAVE');
});
it('keeps a later change when an earlier save fails', async () => {
let failFirst;
globalThis.fetch = vi.fn()
.mockImplementationOnce(() => new Promise((resolve) => {
failFirst = () => resolve({ok: false, status: 500});
}))
.mockImplementation(async () => ({ok: true, status: 204}));
const {result} = renderSave();
let first;
let second;
await act(async () => {
first = result.current.save({accent: 'green'});
});
await act(async () => {
second = result.current.save({accent: 'pink'});
});
await act(async () => {
failFirst();
await first;
await second;
});
expect(result.current.preferences.appearance.accent).toBe('pink');
expect(JSON.parse(fetch.mock.calls[1][1].body).accent).toBe('pink');
});
it('ends the session on a 401', async () => {
globalThis.fetch = vi.fn(async () => ({ok: false, status: 401}));
const {result} = renderSave();
await act(async () => {
await result.current.save({wideLayout: true});
});
expect(localStorage.getItem('tbp_jwt')).toBeNull();
});
it('ends the session with the default look on a 401, so the next user does not get this one', async () => {
localStorage.setItem('tbp_appearance', JSON.stringify({accent: 'green', corners: 'round', wideLayout: true, backgroundAppid: 620}));
globalThis.fetch = vi.fn(async () => ({ok: false, status: 401}));
const {result} = renderSave();
await act(async () => {
await result.current.save({corners: 'sharp'});
});
expect(result.current.preferences.appearance).toEqual(DEFAULT_APPEARANCE);
expect(JSON.parse(localStorage.getItem('tbp_appearance'))).toEqual(DEFAULT_APPEARANCE);
});
it('waits for a running load from Auth, then keeps its values for the fields not changed here', async () => {
const fromServer = {accent: 'green', corners: 'default', wideLayout: false, backgroundAppid: null};
let answerMe;
globalThis.fetch = vi.fn((url, init) => (init?.method === 'PUT'
? Promise.resolve({ok: true, status: 204})
: new Promise((resolve) => {
answerMe = () => resolve({ok: true, status: 200, json: async () => ({settings: {appearance: fromServer}})});
})));
const {result} = renderSave();
let sync;
let save;
act(() => {
sync = result.current.preferences.syncAppearance();
});
act(() => {
save = result.current.save({corners: 'round'});
});
expect(result.current.preferences.appearance.corners).toBe('round');
expect(fetch.mock.calls.some(([, init]) => init?.method === 'PUT')).toBe(false);
await act(async () => {
answerMe();
await sync;
await save;
});
expect(result.current.preferences.appearance).toEqual({...fromServer, corners: 'round'});
const [, put] = fetch.mock.calls.find(([, init]) => init?.method === 'PUT');
expect(JSON.parse(put.body)).toEqual({...fromServer, corners: 'round'});
});
it('does not bring back the look of the user before after a reset', async () => {
let answerMe;
globalThis.fetch = vi.fn(() => new Promise((resolve) => {
answerMe = () => resolve({ok: true, status: 200, json: async () => ({settings: {appearance: {accent: 'red', corners: 'sharp'}}})});
}));
const {result} = renderSave();
let sync;
act(() => {
sync = result.current.preferences.syncAppearance();
});
act(() => result.current.preferences.resetAppearance());
await act(async () => {
answerMe();
await sync;
});
expect(result.current.preferences.appearance).toEqual(DEFAULT_APPEARANCE);
});
it('sends saves one at a time, so a later save never carries an older value', async () => {
const answers = [];
globalThis.fetch = vi.fn((url, init) => (init?.method === 'PUT'
? new Promise((resolve) => answers.push(() => resolve({ok: true, status: 204})))
: Promise.resolve({ok: true, status: 200, json: async () => ({})})));
const puts = () => fetch.mock.calls.filter(([, init]) => init?.method === 'PUT').map(([, init]) => JSON.parse(init.body));
const {result} = renderSave();
let first;
let second;
await act(async () => {
first = result.current.save({accent: 'green'});
});
await act(async () => {
second = result.current.save({corners: 'round'});
});
expect(puts()).toHaveLength(1);
await act(async () => {
answers[0]();
await first;
});
await act(async () => {
answers[1]();
await second;
});
let third;
await act(async () => {
third = result.current.save({wideLayout: true});
});
await act(async () => {
answers[2]();
await third;
});
expect(result.current.preferences.appearance.corners).toBe('round');
expect(puts()[2]).toEqual({accent: 'green', corners: 'round', wideLayout: true, backgroundAppid: null});
});
it('sends nothing for a change that waited for the load when a reset came first', async () => {
let answerMe;
globalThis.fetch = vi.fn((url, init) => (init?.method === 'PUT'
? Promise.resolve({ok: true, status: 204})
: new Promise((resolve) => {
answerMe = () => resolve({ok: true, status: 200, json: async () => ({})});
})));
const {result} = renderSave();
act(() => {
result.current.preferences.syncAppearance();
});
let save;
act(() => {
save = result.current.save({accent: 'green'});
});
act(() => result.current.preferences.resetAppearance());
await act(async () => {
answerMe();
await save;
});
expect(fetch.mock.calls.some(([, init]) => init?.method === 'PUT')).toBe(false);
expect(result.current.preferences.appearance).toEqual(DEFAULT_APPEARANCE);
});
});
+7 -4
View File
@@ -1,13 +1,15 @@
import {useCallback, useRef} from 'react'; import {useCallback, useRef} from 'react';
import {useLocation, useNavigate} from 'react-router-dom'; import {useLocation, useNavigate} from 'react-router-dom';
import {useResetAppearance} from '../context/PreferencesContext.jsx';
/** /**
* Returns a function for a 401 answer: it removes the token and opens the login page, which then says that * Returns a function for a 401 answer: it removes the token and the user's look and opens the login page, which
* the session expired and comes back to the current page after login. The function is as stable as * then says that the session expired and comes back to the current page after login. The function is as stable
* `navigate`, so an effect that lists `navigate` can list it too. * as `navigate`, so an effect that lists `navigate` can list it too.
*/ */
export default function useSessionExpired() { export default function useSessionExpired() {
const navigate = useNavigate(); const navigate = useNavigate();
const resetAppearance = useResetAppearance();
const location = useLocation(); const location = useLocation();
const locationRef = useRef(location); const locationRef = useRef(location);
locationRef.current = location; locationRef.current = location;
@@ -15,6 +17,7 @@ export default function useSessionExpired() {
return useCallback(() => { return useCallback(() => {
const {pathname, search} = locationRef.current; const {pathname, search} = locationRef.current;
localStorage.removeItem('tbp_jwt'); localStorage.removeItem('tbp_jwt');
resetAppearance();
navigate('/login', {replace: true, state: {sessionExpired: true, from: pathname + search}}); navigate('/login', {replace: true, state: {sessionExpired: true, from: pathname + search}});
}, [navigate]); }, [navigate, resetAppearance]);
} }
+5 -4
View File
@@ -18,6 +18,7 @@ import useLibrarySync from '../hooks/useLibrarySync.js';
import useForceSync from '../hooks/useForceSync.js'; import useForceSync from '../hooks/useForceSync.js';
import {formatPlaytime} from '../utils/games.js'; import {formatPlaytime} from '../utils/games.js';
import useSessionExpired from '../hooks/useSessionExpired.js'; import useSessionExpired from '../hooks/useSessionExpired.js';
import PageContainer from '../components/PageContainer.jsx';
const TOP_GAMES = 5; const TOP_GAMES = 5;
// The games endpoint accepts only the page sizes 20, 40 and 80. // The games endpoint accepts only the page sizes 20, 40 and 80.
@@ -208,7 +209,7 @@ const Dashboard = () => {
const maxMinutes = topGames[0]?.playtimeMinutes || 1; const maxMinutes = topGames[0]?.playtimeMinutes || 1;
return ( return (
<div className="w-full max-w-5xl mx-auto p-4 md:p-8 space-y-6"> <PageContainer className="p-4 md:p-8 space-y-6">
<Card className="relative overflow-hidden p-6 md:p-8 flex flex-col md:flex-row md:items-center md:justify-between gap-6"> <Card className="relative overflow-hidden p-6 md:p-8 flex flex-col md:flex-row md:items-center md:justify-between gap-6">
<div className="pointer-events-none absolute inset-0 bg-gradient-to-br from-primary/15 via-transparent to-transparent"/> <div className="pointer-events-none absolute inset-0 bg-gradient-to-br from-primary/15 via-transparent to-transparent"/>
<div className="relative min-w-0"> <div className="relative min-w-0">
@@ -316,19 +317,19 @@ const Dashboard = () => {
)} )}
{hasSteamId && activity && <ActivityCard activity={activity}/>} {hasSteamId && activity && <ActivityCard activity={activity}/>}
</div> </PageContainer>
); );
}; };
function DashboardSkeleton() { function DashboardSkeleton() {
return ( return (
<div className="w-full max-w-5xl mx-auto p-4 md:p-8 space-y-6"> <PageContainer className="p-4 md:p-8 space-y-6">
<Skeleton className="h-40 rounded-xl"/> <Skeleton className="h-40 rounded-xl"/>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6"> <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<Skeleton className="h-64 rounded-xl"/> <Skeleton className="h-64 rounded-xl"/>
<Skeleton className="h-64 rounded-xl md:col-span-2"/> <Skeleton className="h-64 rounded-xl md:col-span-2"/>
</div> </div>
</div> </PageContainer>
); );
} }
+3 -2
View File
@@ -3,6 +3,7 @@ import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {cleanup, render, screen, waitFor} from '@testing-library/react'; import {cleanup, render, screen, waitFor} from '@testing-library/react';
import {MemoryRouter, Route, Routes, useLocation} from 'react-router-dom'; import {MemoryRouter, Route, Routes, useLocation} from 'react-router-dom';
import Dashboard from './Dashboard.jsx'; import Dashboard from './Dashboard.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('../hooks/useLibrarySync.js', () => ({default: () => ({status: 'OK', lastSyncedAt: null, isRunning: false})})); vi.mock('../hooks/useLibrarySync.js', () => ({default: () => ({status: 'OK', lastSyncedAt: null, isRunning: false})}));
@@ -32,12 +33,12 @@ describe('Dashboard', () => {
globalThis.fetch = vi.fn(async (url) => globalThis.fetch = vi.fn(async (url) =>
url.includes('/api/users/me') ? ok({email: 'a@b.de', steamId: '76561198000000000'}) : unauthorized()); url.includes('/api/users/me') ? ok({email: 'a@b.de', steamId: '76561198000000000'}) : unauthorized());
render( render(
<MemoryRouter initialEntries={['/dashboard']}> <MemoryRouter initialEntries={['/dashboard']}><PreferencesProvider>
<Routes> <Routes>
<Route path="/dashboard" element={<><Dashboard/><Spy/></>}/> <Route path="/dashboard" element={<><Dashboard/><Spy/></>}/>
<Route path="/login" element={<Spy/>}/> <Route path="/login" element={<Spy/>}/>
</Routes> </Routes>
</MemoryRouter>, </PreferencesProvider></MemoryRouter>,
); );
await waitFor(() => expect(fetch.mock.calls.length).toBeGreaterThanOrEqual(5)); await waitFor(() => expect(fetch.mock.calls.length).toBeGreaterThanOrEqual(5));
+22 -4
View File
@@ -1,7 +1,7 @@
import {useEffect, useMemo, useState} from 'react'; import {useEffect, useMemo, useState} from 'react';
import {Link, useNavigate, useParams} from 'react-router-dom'; import {Link, useNavigate, useParams} from 'react-router-dom';
import {useTranslation} from 'react-i18next'; import {useTranslation} from 'react-i18next';
import {ArrowLeft, Clock, ExternalLink, ShieldOff, Trophy} from 'lucide-react'; import {ArrowLeft, Clock, ExternalLink, Image as ImageIcon, ImageOff, ShieldOff, Trophy} from 'lucide-react';
import {Badge} from '@/components/ui/badge'; import {Badge} from '@/components/ui/badge';
import {Button} from '@/components/ui/button'; import {Button} from '@/components/ui/button';
@@ -15,6 +15,9 @@ import PlayingHint from '../components/PlayingHint.jsx';
import TimeToBeat from '../components/TimeToBeat.jsx'; import TimeToBeat from '../components/TimeToBeat.jsx';
import {formatPlaytime} from '../utils/games.js'; import {formatPlaytime} from '../utils/games.js';
import useSessionExpired from '../hooks/useSessionExpired.js'; import useSessionExpired from '../hooks/useSessionExpired.js';
import useSaveAppearance from '../hooks/useSaveAppearance.js';
import {usePreferences} from '../context/PreferencesContext.jsx';
import PageContainer from '../components/PageContainer.jsx';
const formatUnlockedAt = (instant, locale) => { const formatUnlockedAt = (instant, locale) => {
if (!instant) return ''; if (!instant) return '';
@@ -28,6 +31,8 @@ const formatUnlockedAt = (instant, locale) => {
export default function GameDetail() { export default function GameDetail() {
const {t, i18n} = useTranslation(); const {t, i18n} = useTranslation();
const {appid} = useParams(); const {appid} = useParams();
const {appearance} = usePreferences();
const saveAppearance = useSaveAppearance();
const navigate = useNavigate(); const navigate = useNavigate();
const expireSession = useSessionExpired(); const expireSession = useSessionExpired();
@@ -93,6 +98,8 @@ export default function GameDetail() {
}); });
}, [data]); }, [data]);
const isBackground = !!data && appearance.backgroundAppid === data.appid;
const stats = data ? [ const stats = data ? [
[t('GAME_DETAIL.UNLOCKED'), data.achievementsUnlocked], [t('GAME_DETAIL.UNLOCKED'), data.achievementsUnlocked],
[t('GAME_DETAIL.REMAINING'), data.achievementsRemaining], [t('GAME_DETAIL.REMAINING'), data.achievementsRemaining],
@@ -100,7 +107,7 @@ export default function GameDetail() {
] : []; ] : [];
return ( return (
<div className="w-full max-w-5xl mx-auto p-4 md:p-8 space-y-6"> <PageContainer className="p-4 md:p-8 space-y-6">
<Button variant="ghost" size="sm" className="-ml-2" onClick={() => navigate(-1)}> <Button variant="ghost" size="sm" className="-ml-2" onClick={() => navigate(-1)}>
<ArrowLeft/>{t('GAME_DETAIL.BACK')} <ArrowLeft/>{t('GAME_DETAIL.BACK')}
</Button> </Button>
@@ -139,11 +146,22 @@ export default function GameDetail() {
<TimeToBeat time={data.timeToBeat}/> <TimeToBeat time={data.timeToBeat}/>
</div> </div>
</div> </div>
<Button asChild variant="outline" size="sm" className="self-start"> <div className="flex flex-wrap gap-2">
<Button asChild variant="outline" size="sm">
<a href={`https://store.steampowered.com/app/${data.appid}`} target="_blank" rel="noopener noreferrer"> <a href={`https://store.steampowered.com/app/${data.appid}`} target="_blank" rel="noopener noreferrer">
{t('GAME_DETAIL.OPEN_IN_STEAM')}<ExternalLink/> {t('GAME_DETAIL.OPEN_IN_STEAM')}<ExternalLink/>
</a> </a>
</Button> </Button>
{isBackground ? (
<Button variant="outline" size="sm" onClick={() => saveAppearance({backgroundAppid: null})}>
<ImageOff/>{t('GAME_DETAIL.REMOVE_BACKGROUND')}
</Button>
) : (
<Button variant="outline" size="sm" onClick={() => saveAppearance({backgroundAppid: data.appid})}>
<ImageIcon/>{t('GAME_DETAIL.SET_BACKGROUND')}
</Button>
)}
</div>
</div> </div>
</Card> </Card>
@@ -227,6 +245,6 @@ export default function GameDetail() {
)} )}
</> </>
)} )}
</div> </PageContainer>
); );
} }
+17 -2
View File
@@ -3,6 +3,7 @@ import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react'; import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
import {MemoryRouter, Route, Routes} from 'react-router-dom'; import {MemoryRouter, Route, Routes} from 'react-router-dom';
import GameDetail from './GameDetail.jsx'; import GameDetail from './GameDetail.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()}})); vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
@@ -13,9 +14,9 @@ const game = {
}; };
const renderDetail = () => render( const renderDetail = () => render(
<MemoryRouter initialEntries={['/library/220']}> <MemoryRouter initialEntries={['/library/220']}><PreferencesProvider>
<Routes><Route path="/library/:appid" element={<GameDetail/>}/></Routes> <Routes><Route path="/library/:appid" element={<GameDetail/>}/></Routes>
</MemoryRouter>, </PreferencesProvider></MemoryRouter>,
); );
beforeEach(() => { beforeEach(() => {
@@ -41,4 +42,18 @@ describe('GameDetail', () => {
await waitFor(() => expect(screen.queryByRole('button', {name: 'GAME_DETAIL.SET_PLAYING'})).toBeNull()); await waitFor(() => expect(screen.queryByRole('button', {name: 'GAME_DETAIL.SET_PLAYING'})).toBeNull());
expect(screen.getByLabelText('BACKLOG.NOTES_LABEL').value).toBe('my draft'); expect(screen.getByLabelText('BACKLOG.NOTES_LABEL').value).toBe('my draft');
}); });
it('uses the game as background and can remove it again', async () => {
renderDetail();
fireEvent.click(await screen.findByRole('button', {name: 'GAME_DETAIL.SET_BACKGROUND'}));
const remove = await screen.findByRole('button', {name: 'GAME_DETAIL.REMOVE_BACKGROUND'});
const [url, init] = fetch.mock.calls.find(([, call]) => call?.method === 'PUT');
expect(url).toContain('/api/users/settings/appearance');
expect(JSON.parse(init.body).backgroundAppid).toBe(220);
fireEvent.click(remove);
expect(await screen.findByRole('button', {name: 'GAME_DETAIL.SET_BACKGROUND'})).toBeTruthy();
});
}); });
+3 -2
View File
@@ -18,6 +18,7 @@ import {Skeleton} from '@/components/ui/skeleton';
import GameCover from '../components/GameCover.jsx'; import GameCover from '../components/GameCover.jsx';
import LibraryTabs from '../components/LibraryTabs.jsx'; import LibraryTabs from '../components/LibraryTabs.jsx';
import GameStatusBadge from '../components/GameStatusBadge.jsx'; import GameStatusBadge from '../components/GameStatusBadge.jsx';
import PageContainer from '../components/PageContainer.jsx';
const PAGE_SIZES = [20, 40, 80]; const PAGE_SIZES = [20, 40, 80];
const DEFAULT_SIZE = 20; const DEFAULT_SIZE = 20;
@@ -146,7 +147,7 @@ export default function GameLibrary() {
const ready = !isLoading && !error; const ready = !isLoading && !error;
return ( return (
<div className="w-full max-w-5xl mx-auto p-4 md:p-8 space-y-6"> <PageContainer className="p-4 md:p-8 space-y-6">
<div className="flex flex-col gap-4 md:flex-row md:items-end md:justify-between"> <div className="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
<div className="space-y-2"> <div className="space-y-2">
<LibraryTabs/> <LibraryTabs/>
@@ -292,6 +293,6 @@ export default function GameLibrary() {
</Button> </Button>
</div> </div>
)} )}
</div> </PageContainer>
); );
} }
+3 -2
View File
@@ -3,6 +3,7 @@ import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {act, cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react'; import {act, cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
import {MemoryRouter, Route, Routes, useLocation, useNavigate} from 'react-router-dom'; import {MemoryRouter, Route, Routes, useLocation, useNavigate} from 'react-router-dom';
import GameLibrary from './GameLibrary.jsx'; import GameLibrary from './GameLibrary.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('../hooks/useLibrarySync.js', () => ({default: () => ({status: 'OK', lastSyncedAt: null, isRunning: false})})); vi.mock('../hooks/useLibrarySync.js', () => ({default: () => ({status: 'OK', lastSyncedAt: null, isRunning: false})}));
@@ -26,9 +27,9 @@ function renderLibrary(initialEntry = '/library') {
return null; return null;
} }
render( render(
<MemoryRouter initialEntries={[initialEntry]}> <MemoryRouter initialEntries={[initialEntry]}><PreferencesProvider>
<Routes><Route path="/library" element={<><GameLibrary/><Spy/></>}/></Routes> <Routes><Route path="/library" element={<><GameLibrary/><Spy/></>}/></Routes>
</MemoryRouter>, </PreferencesProvider></MemoryRouter>,
); );
return { return {
navigations: () => lib.navigations, navigations: () => lib.navigations,
+7 -6
View File
@@ -9,6 +9,7 @@ import GameCover from '../components/GameCover.jsx';
import GameStatusBadge, {STATUS_META} from '../components/GameStatusBadge.jsx'; import GameStatusBadge, {STATUS_META} from '../components/GameStatusBadge.jsx';
import {GAME_STATUSES} from '../utils/backlog.js'; import {GAME_STATUSES} from '../utils/backlog.js';
import {formatPlaytime} from '../utils/games.js'; import {formatPlaytime} from '../utils/games.js';
import PageContainer from '../components/PageContainer.jsx';
/** /**
* Public, no login. Auth returns a short-lived ticket for a public profile; Core returns the data for it. * Public, no login. Auth returns a short-lived ticket for a public profile; Core returns the data for it.
@@ -64,22 +65,22 @@ export default function PublicProfilePage() {
if (error) { if (error) {
return ( return (
<div className="w-full max-w-5xl mx-auto 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">{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>
</div> </PageContainer>
); );
} }
if (!library) { if (!library) {
return ( return (
<div className="w-full max-w-5xl mx-auto p-4 md:p-8 space-y-6"> <PageContainer className="p-4 md:p-8 space-y-6">
<Skeleton className="h-36 rounded-xl"/> <Skeleton className="h-36 rounded-xl"/>
<Skeleton className="h-64 rounded-xl"/> <Skeleton className="h-64 rounded-xl"/>
</div> </PageContainer>
); );
} }
@@ -94,7 +95,7 @@ export default function PublicProfilePage() {
: null; : null;
return ( return (
<div className="w-full max-w-5xl mx-auto p-4 md:p-8 space-y-6"> <PageContainer className="p-4 md:p-8 space-y-6">
<Card className="relative overflow-hidden p-6 md:p-8 gap-6"> <Card className="relative overflow-hidden p-6 md:p-8 gap-6">
<div className="pointer-events-none absolute inset-0 bg-gradient-to-br from-primary/15 via-transparent to-transparent"/> <div className="pointer-events-none absolute inset-0 bg-gradient-to-br from-primary/15 via-transparent to-transparent"/>
<div className="relative"> <div className="relative">
@@ -174,6 +175,6 @@ export default function PublicProfilePage() {
</ul> </ul>
</Card> </Card>
)} )}
</div> </PageContainer>
); );
} }
+3 -2
View File
@@ -3,6 +3,7 @@ import {afterEach, describe, expect, it, vi} from 'vitest';
import {cleanup, render, screen, waitFor} from '@testing-library/react'; import {cleanup, render, screen, waitFor} from '@testing-library/react';
import {MemoryRouter, Route, Routes} from 'react-router-dom'; import {MemoryRouter, Route, Routes} from 'react-router-dom';
import PublicProfilePage from './PublicProfilePage.jsx'; import PublicProfilePage from './PublicProfilePage.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'));
@@ -24,9 +25,9 @@ function mockBackends({profileStatus = 200, libraryStatus = 200} = {}) {
} }
const renderPage = (name = 'Lobo') => render( const renderPage = (name = 'Lobo') => render(
<MemoryRouter initialEntries={[`/u/${name}`]}> <MemoryRouter initialEntries={[`/u/${name}`]}><PreferencesProvider>
<Routes><Route path="/u/:name" element={<PublicProfilePage/>}/></Routes> <Routes><Route path="/u/:name" element={<PublicProfilePage/>}/></Routes>
</MemoryRouter>, </PreferencesProvider></MemoryRouter>,
); );
describe('PublicProfilePage', () => { describe('PublicProfilePage', () => {
+69 -2
View File
@@ -4,7 +4,7 @@ import {useTranslation} from 'react-i18next';
import {useForm} from 'react-hook-form'; import {useForm} from 'react-hook-form';
import {zodResolver} from '@hookform/resolvers/zod'; import {zodResolver} from '@hookform/resolvers/zod';
import {Copy, Gamepad2, Loader2, Save, SlidersHorizontal, UserRound} from 'lucide-react'; import {Copy, Gamepad2, ImageOff, Loader2, Palette, Save, SlidersHorizontal, UserRound} from 'lucide-react';
import {toast} from 'sonner'; import {toast} from 'sonner';
import {Button} from '@/components/ui/button'; import {Button} from '@/components/ui/button';
@@ -18,6 +18,10 @@ import FormField from '../components/FormField.jsx';
import {settingsSchema} from '../utils/validation.js'; import {settingsSchema} from '../utils/validation.js';
import {usePreferences} from '../context/PreferencesContext.jsx'; import {usePreferences} from '../context/PreferencesContext.jsx';
import useSessionExpired from '../hooks/useSessionExpired.js'; import useSessionExpired from '../hooks/useSessionExpired.js';
import useSaveAppearance from '../hooks/useSaveAppearance.js';
import {ACCENTS, CORNERS} from '../utils/appearance.js';
import {coverUrl} from '../utils/games.js';
import {cn} from '@/lib/utils';
const BACKEND_ERROR_KEY = { const BACKEND_ERROR_KEY = {
STEAM_ID_ALREADY_IN_USE: 'REGISTRATION.ERROR_STEAM_ID_IN_USE', STEAM_ID_ALREADY_IN_USE: 'REGISTRATION.ERROR_STEAM_ID_IN_USE',
@@ -39,7 +43,8 @@ export default function SettingsPage() {
const {t, i18n} = useTranslation(); const {t, i18n} = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const expireSession = useSessionExpired(); const expireSession = useSessionExpired();
const {isDarkMode, setDarkMode, setLanguage} = usePreferences(); const {isDarkMode, setDarkMode, setLanguage, appearance} = usePreferences();
const saveAppearance = useSaveAppearance();
const [profile, setProfile] = useState(null); const [profile, setProfile] = useState(null);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
@@ -268,6 +273,68 @@ export default function SettingsPage() {
</CardContent> </CardContent>
</Card> </Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2"><Palette className="size-4 text-primary"/>{t('SETTINGS.APPEARANCE')}</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<Label id="accent-label">{t('SETTINGS.ACCENT')}</Label>
<div role="group" aria-labelledby="accent-label" className="flex flex-wrap gap-2">
{ACCENTS.map((accent) => (
<button key={accent} type="button" data-accent={accent}
aria-label={t(`SETTINGS.ACCENT_OPTIONS.${accent.toUpperCase()}`)}
title={t(`SETTINGS.ACCENT_OPTIONS.${accent.toUpperCase()}`)}
aria-pressed={appearance.accent === accent}
onClick={() => saveAppearance({accent})}
className={cn('size-8 rounded-full bg-primary ring-offset-2 ring-offset-background transition hover:scale-110',
appearance.accent === accent && 'ring-2 ring-foreground')}/>
))}
</div>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<Label id="corners-label">{t('SETTINGS.CORNERS')}</Label>
<div role="group" aria-labelledby="corners-label" className="flex gap-2">
{CORNERS.map((corners) => (
<Button key={corners} type="button" size="sm" data-corners={corners}
variant={appearance.corners === corners ? 'default' : 'outline'}
aria-pressed={appearance.corners === corners}
onClick={() => saveAppearance({corners})}>
{t(`SETTINGS.CORNER_OPTIONS.${corners.toUpperCase()}`)}
</Button>
))}
</div>
</div>
<div className="flex items-center justify-between gap-4">
<div className="space-y-1">
<Label htmlFor="wideLayout">{t('SETTINGS.WIDE_LAYOUT')}</Label>
<p className="text-xs text-muted-foreground">{t('SETTINGS.WIDE_LAYOUT_HINT')}</p>
</div>
<Switch id="wideLayout" checked={appearance.wideLayout}
onCheckedChange={(wideLayout) => saveAppearance({wideLayout})}/>
</div>
<div className="flex items-center justify-between gap-4">
<div className="space-y-1">
<Label>{t('SETTINGS.BACKGROUND')}</Label>
{!appearance.backgroundAppid && (
<p className="text-xs text-muted-foreground">{t('SETTINGS.BACKGROUND_HINT')}</p>
)}
</div>
{appearance.backgroundAppid && (
<div className="flex items-center gap-2">
<img src={coverUrl(appearance.backgroundAppid)} alt=""
onError={(event) => { event.currentTarget.hidden = true; }}
className="h-10 w-20 rounded-md object-cover"/>
<Button type="button" variant="outline" size="sm"
onClick={() => saveAppearance({backgroundAppid: null})}>
<ImageOff/>{t('SETTINGS.BACKGROUND_REMOVE')}
</Button>
</div>
)}
</div>
</CardContent>
</Card>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"><Gamepad2 className="size-4 text-primary"/>{t('SETTINGS.PROFILE')}</CardTitle> <CardTitle className="flex items-center gap-2"><Gamepad2 className="size-4 text-primary"/>{t('SETTINGS.PROFILE')}</CardTitle>
+36
View File
@@ -74,3 +74,39 @@ describe('SettingsPage public profile', () => {
await waitFor(() => expect(screen.getByRole('switch', {name: 'SETTINGS.PUBLIC_PROFILE'}).getAttribute('aria-checked')).toBe('false')); await waitFor(() => expect(screen.getByRole('switch', {name: 'SETTINGS.PUBLIC_PROFILE'}).getAttribute('aria-checked')).toBe('false'));
}); });
}); });
describe('SettingsPage appearance', () => {
beforeEach(() => localStorage.setItem('tbp_jwt', 'token'));
afterEach(() => {
cleanup();
localStorage.clear();
});
const appearancePuts = () => fetch.mock.calls
.filter(([url, init]) => init?.method === 'PUT' && url.endsWith('/api/users/settings/appearance'))
.map(([, init]) => JSON.parse(init.body));
it('saves a picked accent and corner style and marks them as picked', async () => {
renderPage(profile());
fireEvent.click(await screen.findByRole('button', {name: 'SETTINGS.ACCENT_OPTIONS.GREEN'}));
fireEvent.click(screen.getByRole('button', {name: 'SETTINGS.CORNER_OPTIONS.ROUND'}));
await waitFor(() => expect(appearancePuts()).toHaveLength(2));
expect(appearancePuts()[1]).toEqual({accent: 'green', corners: 'round', wideLayout: false, backgroundAppid: null});
expect(screen.getByRole('button', {name: 'SETTINGS.ACCENT_OPTIONS.GREEN'}).getAttribute('aria-pressed')).toBe('true');
expect(screen.getByRole('button', {name: 'SETTINGS.ACCENT_OPTIONS.VIOLET'}).getAttribute('aria-pressed')).toBe('false');
});
it('removes the background', async () => {
localStorage.setItem('tbp_appearance', JSON.stringify({backgroundAppid: 620}));
renderPage(profile());
fireEvent.click(await screen.findByRole('button', {name: 'SETTINGS.BACKGROUND_REMOVE'}));
await waitFor(() => expect(appearancePuts()).toHaveLength(1));
expect(appearancePuts()[0].backgroundAppid).toBeNull();
expect(screen.getByText('SETTINGS.BACKGROUND_HINT')).toBeTruthy();
});
});
+3 -2
View File
@@ -10,6 +10,7 @@ import {Skeleton} from '@/components/ui/skeleton';
import GameCover from '../components/GameCover.jsx'; import GameCover from '../components/GameCover.jsx';
import LibraryTabs from '../components/LibraryTabs.jsx'; import LibraryTabs from '../components/LibraryTabs.jsx';
import useSessionExpired from '../hooks/useSessionExpired.js'; import useSessionExpired from '../hooks/useSessionExpired.js';
import PageContainer from '../components/PageContainer.jsx';
const formatAdded = (instant, locale) => const formatAdded = (instant, locale) =>
instant ? new Date(instant).toLocaleDateString(locale, {year: 'numeric', month: 'short', day: 'numeric'}) : null; instant ? new Date(instant).toLocaleDateString(locale, {year: 'numeric', month: 'short', day: 'numeric'}) : null;
@@ -59,7 +60,7 @@ export default function WishlistPage() {
const onSaleCount = (games ?? []).filter((g) => g.discountPct > 0).length; const onSaleCount = (games ?? []).filter((g) => g.discountPct > 0).length;
return ( return (
<div className="w-full max-w-5xl mx-auto p-4 md:p-8 space-y-6"> <PageContainer className="p-4 md:p-8 space-y-6">
<div className="flex flex-col gap-4 md:flex-row md:items-end md:justify-between"> <div className="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
<div className="space-y-3"> <div className="space-y-3">
<LibraryTabs/> <LibraryTabs/>
@@ -142,6 +143,6 @@ export default function WishlistPage() {
</ul> </ul>
</Card> </Card>
)} )}
</div> </PageContainer>
); );
} }
+3 -2
View File
@@ -3,6 +3,7 @@ import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react'; import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
import {MemoryRouter, Route, Routes, useLocation} from 'react-router-dom'; import {MemoryRouter, Route, Routes, useLocation} from 'react-router-dom';
import WishlistPage from './WishlistPage.jsx'; import WishlistPage from './WishlistPage.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'));
@@ -16,12 +17,12 @@ function LoginState() {
} }
const renderPage = () => render( const renderPage = () => render(
<MemoryRouter initialEntries={['/wishlist']}> <MemoryRouter initialEntries={['/wishlist']}><PreferencesProvider>
<Routes> <Routes>
<Route path="/wishlist" element={<WishlistPage/>}/> <Route path="/wishlist" element={<WishlistPage/>}/>
<Route path="/login" element={<LoginState/>}/> <Route path="/login" element={<LoginState/>}/>
</Routes> </Routes>
</MemoryRouter>, </PreferencesProvider></MemoryRouter>,
); );
describe('WishlistPage', () => { describe('WishlistPage', () => {
+108
View File
@@ -48,6 +48,114 @@
--ring: oklch(0.541 0.281 293.009); --ring: oklch(0.541 0.281 293.009);
} }
/*
* Appearance presets (utils/appearance.js). Set as data attributes on <html>; the settings page also sets them on
* its preview buttons, so each button shows its own preset. Violet repeats the defaults above for that reason.
* Green and orange are too light for white text, so their dark buttons get dark text.
*/
[data-accent="violet"] {
--primary: oklch(0.541 0.281 293.009);
--primary-foreground: oklch(0.969 0.016 293.756);
--accent: oklch(0.943 0.029 294.588);
--accent-foreground: oklch(0.38 0.189 293.745);
--ring: oklch(0.606 0.25 292.717);
}
.dark[data-accent="violet"],
.dark [data-accent="violet"] {
--primary: oklch(0.606 0.25 292.717);
--primary-foreground: oklch(0.969 0.016 293.756);
--accent: oklch(0.283 0.141 291.089);
--accent-foreground: oklch(0.894 0.057 293.283);
--ring: oklch(0.541 0.281 293.009);
}
[data-accent="blue"] {
--primary: oklch(0.546 0.245 262.881);
--primary-foreground: oklch(0.97 0.014 254.604);
--accent: oklch(0.932 0.032 255.585);
--accent-foreground: oklch(0.424 0.199 265.638);
--ring: oklch(0.623 0.214 259.815);
}
.dark[data-accent="blue"],
.dark [data-accent="blue"] {
--primary: oklch(0.623 0.214 259.815);
--primary-foreground: oklch(0.97 0.014 254.604);
--accent: oklch(0.282 0.091 267.935);
--accent-foreground: oklch(0.882 0.059 254.128);
--ring: oklch(0.546 0.245 262.881);
}
[data-accent="green"] {
--primary: oklch(0.527 0.154 150.069);
--primary-foreground: oklch(0.982 0.018 155.826);
--accent: oklch(0.962 0.044 156.743);
--accent-foreground: oklch(0.448 0.119 151.328);
--ring: oklch(0.627 0.194 149.214);
}
.dark[data-accent="green"],
.dark [data-accent="green"] {
--primary: oklch(0.723 0.219 149.579);
--primary-foreground: oklch(0.266 0.065 152.934);
--accent: oklch(0.266 0.065 152.934);
--accent-foreground: oklch(0.925 0.084 155.995);
--ring: oklch(0.627 0.194 149.214);
}
[data-accent="orange"] {
--primary: oklch(0.553 0.195 38.402);
--primary-foreground: oklch(0.98 0.016 73.684);
--accent: oklch(0.954 0.038 75.164);
--accent-foreground: oklch(0.47 0.157 37.304);
--ring: oklch(0.646 0.222 41.116);
}
.dark[data-accent="orange"],
.dark [data-accent="orange"] {
--primary: oklch(0.705 0.213 47.604);
--primary-foreground: oklch(0.266 0.079 36.259);
--accent: oklch(0.266 0.079 36.259);
--accent-foreground: oklch(0.901 0.076 70.697);
--ring: oklch(0.646 0.222 41.116);
}
[data-accent="pink"] {
--primary: oklch(0.592 0.249 0.584);
--primary-foreground: oklch(0.971 0.014 343.198);
--accent: oklch(0.948 0.028 342.258);
--accent-foreground: oklch(0.459 0.187 3.815);
--ring: oklch(0.656 0.241 354.308);
}
.dark[data-accent="pink"],
.dark [data-accent="pink"] {
--primary: oklch(0.592 0.249 0.584);
--primary-foreground: oklch(0.971 0.014 343.198);
--accent: oklch(0.284 0.109 3.907);
--accent-foreground: oklch(0.899 0.061 343.231);
--ring: oklch(0.656 0.241 354.308);
}
[data-accent="red"] {
--primary: oklch(0.577 0.245 27.325);
--primary-foreground: oklch(0.971 0.013 17.38);
--accent: oklch(0.936 0.032 17.717);
--accent-foreground: oklch(0.444 0.177 26.899);
--ring: oklch(0.637 0.237 25.331);
}
.dark[data-accent="red"],
.dark [data-accent="red"] {
--primary: oklch(0.577 0.245 27.325);
--primary-foreground: oklch(0.971 0.013 17.38);
--accent: oklch(0.258 0.092 26.042);
--accent-foreground: oklch(0.885 0.062 18.334);
--ring: oklch(0.637 0.237 25.331);
}
[data-corners="sharp"] {
--radius: 0.25rem;
}
[data-corners="default"] {
--radius: 0.75rem;
}
[data-corners="round"] {
--radius: 1.25rem;
}
@theme inline { @theme inline {
--font-sans: "Inter Variable", ui-sans-serif, system-ui, sans-serif; --font-sans: "Inter Variable", ui-sans-serif, system-ui, sans-serif;
--radius-sm: calc(var(--radius) - 4px); --radius-sm: calc(var(--radius) - 4px);
+65
View File
@@ -0,0 +1,65 @@
// Auth accepts only these names (AppearanceRequest); style.css has a preset for each.
export const ACCENTS = ['violet', 'blue', 'green', 'orange', 'pink', 'red'];
export const CORNERS = ['sharp', 'default', 'round'];
export const DEFAULT_APPEARANCE = {accent: 'violet', corners: 'default', wideLayout: false, backgroundAppid: 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,
corners: CORNERS.includes(value?.corners) ? value.corners : DEFAULT_APPEARANCE.corners,
wideLayout: value?.wideLayout === true,
backgroundAppid: Number.isInteger(value?.backgroundAppid) && value.backgroundAppid > 0 ? value.backgroundAppid : null,
});
/**
* The last known appearance, so the first paint does not flash the default look before Auth answers.
*/
export const readCachedAppearance = () => {
try {
return normalizeAppearance(JSON.parse(localStorage.getItem(STORAGE_KEY)));
} catch {
return DEFAULT_APPEARANCE;
}
};
export const cacheAppearance = (appearance) => {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(appearance));
} catch {
// Private mode or a full storage: the look is only not cached.
}
};
export const applyAppearance = (appearance, root = document.documentElement) => {
root.dataset.accent = appearance.accent;
root.dataset.corners = appearance.corners;
};
/**
* PUT /api/users/settings/appearance on Auth. The caller handles the response status.
*/
export const saveAppearance = (appearance) =>
fetch(`${import.meta.env.VITE_API_URL}/api/users/settings/appearance`, {
method: 'PUT',
headers: {'Authorization': `Bearer ${localStorage.getItem('tbp_jwt')}`, 'Content-Type': 'application/json'},
body: JSON.stringify(appearance),
// Saves run one after another; a hung request must not hold back the next ones.
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
/**
* GET /api/users/me on Auth, only for its appearance. Null when there is no answer to use.
*/
export const loadAppearance = async () => {
const response = await fetch(`${import.meta.env.VITE_API_URL}/api/users/me?v=1`, {
headers: {'Authorization': `Bearer ${localStorage.getItem('tbp_jwt')}`},
// Saves wait for this load.
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
if (!response.ok) return null;
const data = await response.json();
return data.settings?.appearance ? normalizeAppearance(data.settings.appearance) : null;
};
+52
View File
@@ -0,0 +1,52 @@
// @vitest-environment jsdom
import {afterEach, describe, expect, it} from 'vitest';
import {
applyAppearance,
cacheAppearance,
DEFAULT_APPEARANCE,
normalizeAppearance,
readCachedAppearance,
} from './appearance.js';
describe('normalizeAppearance', () => {
it('keeps known values', () => {
const appearance = {accent: 'blue', corners: 'round', wideLayout: true, backgroundAppid: 620};
expect(normalizeAppearance(appearance)).toEqual(appearance);
});
it('replaces unknown or missing values with the defaults', () => {
expect(normalizeAppearance({accent: 'red;x', corners: 'pointy', wideLayout: 'yes', backgroundAppid: -1}))
.toEqual(DEFAULT_APPEARANCE);
expect(normalizeAppearance(null)).toEqual(DEFAULT_APPEARANCE);
expect(normalizeAppearance({backgroundAppid: 1.5})).toEqual(DEFAULT_APPEARANCE);
});
});
describe('appearance cache', () => {
afterEach(() => localStorage.clear());
it('reads back what was cached', () => {
cacheAppearance({accent: 'green', corners: 'sharp', wideLayout: false, backgroundAppid: null});
expect(readCachedAppearance()).toEqual({accent: 'green', corners: 'sharp', wideLayout: false, backgroundAppid: null});
});
it('falls back to the defaults for a broken or empty cache', () => {
expect(readCachedAppearance()).toEqual(DEFAULT_APPEARANCE);
localStorage.setItem('tbp_appearance', '{broken');
expect(readCachedAppearance()).toEqual(DEFAULT_APPEARANCE);
});
});
describe('applyAppearance', () => {
it('sets the presets as data attributes for the CSS', () => {
const root = document.createElement('html');
applyAppearance({accent: 'orange', corners: 'round', wideLayout: true, backgroundAppid: 620}, root);
expect(root.dataset.accent).toBe('orange');
expect(root.dataset.corners).toBe('round');
});
});
+3
View File
@@ -12,3 +12,6 @@ export const formatPlaytime = (minutes) => {
// 460x215 store header. Not every app has one; callers need an onError fallback. // 460x215 store header. Not every app has one; callers need an onError fallback.
export const coverUrl = (appid) => `https://cdn.cloudflare.steamstatic.com/steam/apps/${appid}/header.jpg`; export const coverUrl = (appid) => `https://cdn.cloudflare.steamstatic.com/steam/apps/${appid}/header.jpg`;
// 1920x620 library art. Older apps have none; callers need an onError fallback.
export const heroUrl = (appid) => `https://cdn.cloudflare.steamstatic.com/steam/apps/${appid}/library_hero.jpg`;