Public Access
feat(appearance): accent color, corners, wide layout and game art background
This commit is contained in:
1 parent
91d5672421
commit
2c360c9421
28 files changed
+934
-46
No files matched your search
+25
-2
@@ -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_NEEDS_NAME": "Lege zuerst einen Anzeigenamen fest; er wird Teil des Links.",
|
||||
"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": {
|
||||
"EMAIL_REQUIRED": "Email ist erforderlich.",
|
||||
@@ -158,7 +179,9 @@
|
||||
"TTB": "Spieldauer laut IGDB",
|
||||
"TTB_HASTILY": "Story",
|
||||
"TTB_NORMALLY": "+ Extras",
|
||||
"TTB_COMPLETELY": "100 %"
|
||||
"TTB_COMPLETELY": "100 %",
|
||||
"SET_BACKGROUND": "Als Hintergrund",
|
||||
"REMOVE_BACKGROUND": "Hintergrund entfernen"
|
||||
},
|
||||
"LANDING": {
|
||||
"STATS_TITLE": "Community-Statistiken",
|
||||
|
||||
+25
-2
@@ -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_NEEDS_NAME": "Set a display name first; it becomes part of the 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": {
|
||||
"EMAIL_REQUIRED": "Email is required.",
|
||||
@@ -158,7 +179,9 @@
|
||||
"TTB": "Time to beat (IGDB)",
|
||||
"TTB_HASTILY": "Story",
|
||||
"TTB_NORMALLY": "+ Extras",
|
||||
"TTB_COMPLETELY": "100%"
|
||||
"TTB_COMPLETELY": "100%",
|
||||
"SET_BACKGROUND": "Use as background",
|
||||
"REMOVE_BACKGROUND": "Remove background"
|
||||
},
|
||||
"LANDING": {
|
||||
"STATS_TITLE": "Community stats",
|
||||
|
||||
+12
-1
@@ -15,10 +15,13 @@ import PublicProfilePage from './pages/PublicProfilePage.jsx';
|
||||
import AppHeader from './components/AppHeader.jsx';
|
||||
import {Toaster} from '@/components/ui/sonner';
|
||||
import {TooltipProvider} from '@/components/ui/tooltip';
|
||||
import PageBackground from './components/PageBackground.jsx';
|
||||
import {usePreferences} from './context/PreferencesContext.jsx';
|
||||
|
||||
function App() {
|
||||
const {t} = useTranslation();
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const {appearance, syncAppearance, resetAppearance} = usePreferences();
|
||||
|
||||
// Hooks for routing
|
||||
const navigate = useNavigate();
|
||||
@@ -30,15 +33,23 @@ function App() {
|
||||
setIsAuthenticated(!!localStorage.getItem('tbp_jwt'));
|
||||
}, [location]);
|
||||
|
||||
// On login and on page load; the cached look shows until Auth answers.
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) syncAppearance();
|
||||
}, [isAuthenticated, syncAppearance]);
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem('tbp_jwt');
|
||||
resetAppearance();
|
||||
setIsAuthenticated(false);
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
return (
|
||||
<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}/>
|
||||
|
||||
<main className="grow flex flex-col">
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {usePreferences} from '@/context/PreferencesContext.jsx';
|
||||
import {cn} from '@/lib/utils';
|
||||
import PageContainer from './PageContainer.jsx';
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{to: '/dashboard', labelKey: 'NAV.DASHBOARD', Icon: LayoutDashboard},
|
||||
@@ -37,7 +38,7 @@ export default function AppHeader({isAuthenticated, onLogout}) {
|
||||
|
||||
return (
|
||||
<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">
|
||||
<span className="flex size-8 items-center justify-center rounded-lg bg-primary text-primary-foreground">
|
||||
<Gamepad2 className="size-5"/>
|
||||
@@ -95,7 +96,7 @@ export default function AppHeader({isAuthenticated, onLogout}) {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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('');
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
applyAppearance,
|
||||
cacheAppearance,
|
||||
DEFAULT_APPEARANCE,
|
||||
loadAppearance,
|
||||
normalizeAppearance,
|
||||
readCachedAppearance,
|
||||
saveAppearance,
|
||||
} from '../utils/appearance.js';
|
||||
|
||||
const DARK_MODE_STORAGE_KEY = 'tbp_dark_mode';
|
||||
|
||||
@@ -22,6 +31,85 @@ export function PreferencesProvider({children}) {
|
||||
localStorage.setItem(DARK_MODE_STORAGE_KEY, String(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);
|
||||
|
||||
return (
|
||||
@@ -31,6 +119,10 @@ export function PreferencesProvider({children}) {
|
||||
setDarkMode: setIsDarkMode,
|
||||
language: i18n.language,
|
||||
setLanguage,
|
||||
appearance,
|
||||
changeAppearance,
|
||||
syncAppearance,
|
||||
resetAppearance,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
@@ -45,3 +137,12 @@ export function usePreferences() {
|
||||
}
|
||||
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 = () => {};
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,15 @@
|
||||
import {useCallback, useRef} from 'react';
|
||||
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
|
||||
* the session expired and comes back to the current page after login. The function is as stable as
|
||||
* `navigate`, so an effect that lists `navigate` can list it too.
|
||||
* Returns a function for a 401 answer: it removes the token and the user's look and opens the login page, which
|
||||
* then says that the session expired and comes back to the current page after login. The function is as stable
|
||||
* as `navigate`, so an effect that lists `navigate` can list it too.
|
||||
*/
|
||||
export default function useSessionExpired() {
|
||||
const navigate = useNavigate();
|
||||
const resetAppearance = useResetAppearance();
|
||||
const location = useLocation();
|
||||
const locationRef = useRef(location);
|
||||
locationRef.current = location;
|
||||
@@ -15,6 +17,7 @@ export default function useSessionExpired() {
|
||||
return useCallback(() => {
|
||||
const {pathname, search} = locationRef.current;
|
||||
localStorage.removeItem('tbp_jwt');
|
||||
resetAppearance();
|
||||
navigate('/login', {replace: true, state: {sessionExpired: true, from: pathname + search}});
|
||||
}, [navigate]);
|
||||
}, [navigate, resetAppearance]);
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import useLibrarySync from '../hooks/useLibrarySync.js';
|
||||
import useForceSync from '../hooks/useForceSync.js';
|
||||
import {formatPlaytime} from '../utils/games.js';
|
||||
import useSessionExpired from '../hooks/useSessionExpired.js';
|
||||
import PageContainer from '../components/PageContainer.jsx';
|
||||
|
||||
const TOP_GAMES = 5;
|
||||
// The games endpoint accepts only the page sizes 20, 40 and 80.
|
||||
@@ -208,7 +209,7 @@ const Dashboard = () => {
|
||||
const maxMinutes = topGames[0]?.playtimeMinutes || 1;
|
||||
|
||||
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">
|
||||
<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">
|
||||
@@ -316,19 +317,19 @@ const Dashboard = () => {
|
||||
)}
|
||||
|
||||
{hasSteamId && activity && <ActivityCard activity={activity}/>}
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
};
|
||||
|
||||
function DashboardSkeleton() {
|
||||
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"/>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<Skeleton className="h-64 rounded-xl"/>
|
||||
<Skeleton className="h-64 rounded-xl md:col-span-2"/>
|
||||
</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
import {cleanup, render, screen, waitFor} from '@testing-library/react';
|
||||
import {MemoryRouter, Route, Routes, useLocation} from 'react-router-dom';
|
||||
import Dashboard from './Dashboard.jsx';
|
||||
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
|
||||
|
||||
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||
vi.mock('../hooks/useLibrarySync.js', () => ({default: () => ({status: 'OK', lastSyncedAt: null, isRunning: false})}));
|
||||
@@ -32,12 +33,12 @@ describe('Dashboard', () => {
|
||||
globalThis.fetch = vi.fn(async (url) =>
|
||||
url.includes('/api/users/me') ? ok({email: 'a@b.de', steamId: '76561198000000000'}) : unauthorized());
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/dashboard']}>
|
||||
<MemoryRouter initialEntries={['/dashboard']}><PreferencesProvider>
|
||||
<Routes>
|
||||
<Route path="/dashboard" element={<><Dashboard/><Spy/></>}/>
|
||||
<Route path="/login" element={<Spy/>}/>
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
</PreferencesProvider></MemoryRouter>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(fetch.mock.calls.length).toBeGreaterThanOrEqual(5));
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {useEffect, useMemo, useState} from 'react';
|
||||
import {Link, useNavigate, useParams} from 'react-router-dom';
|
||||
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 {Button} from '@/components/ui/button';
|
||||
@@ -15,6 +15,9 @@ import PlayingHint from '../components/PlayingHint.jsx';
|
||||
import TimeToBeat from '../components/TimeToBeat.jsx';
|
||||
import {formatPlaytime} from '../utils/games.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) => {
|
||||
if (!instant) return '';
|
||||
@@ -28,6 +31,8 @@ const formatUnlockedAt = (instant, locale) => {
|
||||
export default function GameDetail() {
|
||||
const {t, i18n} = useTranslation();
|
||||
const {appid} = useParams();
|
||||
const {appearance} = usePreferences();
|
||||
const saveAppearance = useSaveAppearance();
|
||||
const navigate = useNavigate();
|
||||
const expireSession = useSessionExpired();
|
||||
|
||||
@@ -93,6 +98,8 @@ export default function GameDetail() {
|
||||
});
|
||||
}, [data]);
|
||||
|
||||
const isBackground = !!data && appearance.backgroundAppid === data.appid;
|
||||
|
||||
const stats = data ? [
|
||||
[t('GAME_DETAIL.UNLOCKED'), data.achievementsUnlocked],
|
||||
[t('GAME_DETAIL.REMAINING'), data.achievementsRemaining],
|
||||
@@ -100,7 +107,7 @@ export default function GameDetail() {
|
||||
] : [];
|
||||
|
||||
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)}>
|
||||
<ArrowLeft/>{t('GAME_DETAIL.BACK')}
|
||||
</Button>
|
||||
@@ -139,11 +146,22 @@ export default function GameDetail() {
|
||||
<TimeToBeat time={data.timeToBeat}/>
|
||||
</div>
|
||||
</div>
|
||||
<Button asChild variant="outline" size="sm" className="self-start">
|
||||
<a href={`https://store.steampowered.com/app/${data.appid}`} target="_blank" rel="noopener noreferrer">
|
||||
{t('GAME_DETAIL.OPEN_IN_STEAM')}<ExternalLink/>
|
||||
</a>
|
||||
</Button>
|
||||
<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">
|
||||
{t('GAME_DETAIL.OPEN_IN_STEAM')}<ExternalLink/>
|
||||
</a>
|
||||
</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>
|
||||
</Card>
|
||||
|
||||
@@ -227,6 +245,6 @@ export default function GameDetail() {
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import {afterEach, beforeEach, describe, 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'));
|
||||
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
|
||||
@@ -13,9 +14,9 @@ const game = {
|
||||
};
|
||||
|
||||
const renderDetail = () => render(
|
||||
<MemoryRouter initialEntries={['/library/220']}>
|
||||
<MemoryRouter initialEntries={['/library/220']}><PreferencesProvider>
|
||||
<Routes><Route path="/library/:appid" element={<GameDetail/>}/></Routes>
|
||||
</MemoryRouter>,
|
||||
</PreferencesProvider></MemoryRouter>,
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -41,4 +42,18 @@ describe('GameDetail', () => {
|
||||
await waitFor(() => expect(screen.queryByRole('button', {name: 'GAME_DETAIL.SET_PLAYING'})).toBeNull());
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,7 @@ import {Skeleton} from '@/components/ui/skeleton';
|
||||
import GameCover from '../components/GameCover.jsx';
|
||||
import LibraryTabs from '../components/LibraryTabs.jsx';
|
||||
import GameStatusBadge from '../components/GameStatusBadge.jsx';
|
||||
import PageContainer from '../components/PageContainer.jsx';
|
||||
|
||||
const PAGE_SIZES = [20, 40, 80];
|
||||
const DEFAULT_SIZE = 20;
|
||||
@@ -146,7 +147,7 @@ export default function GameLibrary() {
|
||||
const ready = !isLoading && !error;
|
||||
|
||||
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="space-y-2">
|
||||
<LibraryTabs/>
|
||||
@@ -292,6 +293,6 @@ export default function GameLibrary() {
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -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 {MemoryRouter, Route, Routes, useLocation, useNavigate} from 'react-router-dom';
|
||||
import GameLibrary from './GameLibrary.jsx';
|
||||
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
|
||||
|
||||
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||
vi.mock('../hooks/useLibrarySync.js', () => ({default: () => ({status: 'OK', lastSyncedAt: null, isRunning: false})}));
|
||||
@@ -26,9 +27,9 @@ function renderLibrary(initialEntry = '/library') {
|
||||
return null;
|
||||
}
|
||||
render(
|
||||
<MemoryRouter initialEntries={[initialEntry]}>
|
||||
<MemoryRouter initialEntries={[initialEntry]}><PreferencesProvider>
|
||||
<Routes><Route path="/library" element={<><GameLibrary/><Spy/></>}/></Routes>
|
||||
</MemoryRouter>,
|
||||
</PreferencesProvider></MemoryRouter>,
|
||||
);
|
||||
return {
|
||||
navigations: () => lib.navigations,
|
||||
|
||||
@@ -9,6 +9,7 @@ import GameCover from '../components/GameCover.jsx';
|
||||
import GameStatusBadge, {STATUS_META} from '../components/GameStatusBadge.jsx';
|
||||
import {GAME_STATUSES} from '../utils/backlog.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.
|
||||
@@ -64,22 +65,22 @@ export default function PublicProfilePage() {
|
||||
|
||||
if (error) {
|
||||
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">
|
||||
<UserRoundX className="size-10"/>
|
||||
<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>
|
||||
</Card>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
if (!library) {
|
||||
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-64 rounded-xl"/>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -94,7 +95,7 @@ export default function PublicProfilePage() {
|
||||
: null;
|
||||
|
||||
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">
|
||||
<div className="pointer-events-none absolute inset-0 bg-gradient-to-br from-primary/15 via-transparent to-transparent"/>
|
||||
<div className="relative">
|
||||
@@ -174,6 +175,6 @@ export default function PublicProfilePage() {
|
||||
</ul>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import {afterEach, describe, expect, it, vi} from 'vitest';
|
||||
import {cleanup, render, screen, waitFor} from '@testing-library/react';
|
||||
import {MemoryRouter, Route, Routes} from 'react-router-dom';
|
||||
import PublicProfilePage from './PublicProfilePage.jsx';
|
||||
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
|
||||
|
||||
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||
|
||||
@@ -24,9 +25,9 @@ function mockBackends({profileStatus = 200, libraryStatus = 200} = {}) {
|
||||
}
|
||||
|
||||
const renderPage = (name = 'Lobo') => render(
|
||||
<MemoryRouter initialEntries={[`/u/${name}`]}>
|
||||
<MemoryRouter initialEntries={[`/u/${name}`]}><PreferencesProvider>
|
||||
<Routes><Route path="/u/:name" element={<PublicProfilePage/>}/></Routes>
|
||||
</MemoryRouter>,
|
||||
</PreferencesProvider></MemoryRouter>,
|
||||
);
|
||||
|
||||
describe('PublicProfilePage', () => {
|
||||
|
||||
@@ -4,7 +4,7 @@ import {useTranslation} from 'react-i18next';
|
||||
import {useForm} from 'react-hook-form';
|
||||
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 {Button} from '@/components/ui/button';
|
||||
@@ -18,6 +18,10 @@ import FormField from '../components/FormField.jsx';
|
||||
import {settingsSchema} from '../utils/validation.js';
|
||||
import {usePreferences} from '../context/PreferencesContext.jsx';
|
||||
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 = {
|
||||
STEAM_ID_ALREADY_IN_USE: 'REGISTRATION.ERROR_STEAM_ID_IN_USE',
|
||||
@@ -39,7 +43,8 @@ export default function SettingsPage() {
|
||||
const {t, i18n} = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const expireSession = useSessionExpired();
|
||||
const {isDarkMode, setDarkMode, setLanguage} = usePreferences();
|
||||
const {isDarkMode, setDarkMode, setLanguage, appearance} = usePreferences();
|
||||
const saveAppearance = useSaveAppearance();
|
||||
|
||||
const [profile, setProfile] = useState(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
@@ -268,6 +273,68 @@ export default function SettingsPage() {
|
||||
</CardContent>
|
||||
</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>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2"><Gamepad2 className="size-4 text-primary"/>{t('SETTINGS.PROFILE')}</CardTitle>
|
||||
|
||||
@@ -74,3 +74,39 @@ describe('SettingsPage public profile', () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@ import {Skeleton} from '@/components/ui/skeleton';
|
||||
import GameCover from '../components/GameCover.jsx';
|
||||
import LibraryTabs from '../components/LibraryTabs.jsx';
|
||||
import useSessionExpired from '../hooks/useSessionExpired.js';
|
||||
import PageContainer from '../components/PageContainer.jsx';
|
||||
|
||||
const formatAdded = (instant, locale) =>
|
||||
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;
|
||||
|
||||
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="space-y-3">
|
||||
<LibraryTabs/>
|
||||
@@ -142,6 +143,6 @@ export default function WishlistPage() {
|
||||
</ul>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
|
||||
import {MemoryRouter, Route, Routes, useLocation} from 'react-router-dom';
|
||||
import WishlistPage from './WishlistPage.jsx';
|
||||
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
|
||||
|
||||
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||
|
||||
@@ -16,12 +17,12 @@ function LoginState() {
|
||||
}
|
||||
|
||||
const renderPage = () => render(
|
||||
<MemoryRouter initialEntries={['/wishlist']}>
|
||||
<MemoryRouter initialEntries={['/wishlist']}><PreferencesProvider>
|
||||
<Routes>
|
||||
<Route path="/wishlist" element={<WishlistPage/>}/>
|
||||
<Route path="/login" element={<LoginState/>}/>
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
</PreferencesProvider></MemoryRouter>,
|
||||
);
|
||||
|
||||
describe('WishlistPage', () => {
|
||||
|
||||
+108
@@ -48,6 +48,114 @@
|
||||
--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 {
|
||||
--font-sans: "Inter Variable", ui-sans-serif, system-ui, sans-serif;
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -12,3 +12,6 @@ export const formatPlaytime = (minutes) => {
|
||||
|
||||
// 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`;
|
||||
|
||||
// 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`;
|
||||
Reference in new issue
Block a user