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_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
@@ -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
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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 {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 = () => {};
|
||||||
@@ -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 {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]);
|
||||||
}
|
}
|
||||||
@@ -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,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));
|
||||||
|
|||||||
@@ -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">
|
||||||
<a href={`https://store.steampowered.com/app/${data.appid}`} target="_blank" rel="noopener noreferrer">
|
<Button asChild variant="outline" size="sm">
|
||||||
{t('GAME_DETAIL.OPEN_IN_STEAM')}<ExternalLink/>
|
<a href={`https://store.steampowered.com/app/${data.appid}`} target="_blank" rel="noopener noreferrer">
|
||||||
</a>
|
{t('GAME_DETAIL.OPEN_IN_STEAM')}<ExternalLink/>
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
</Card>
|
</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 {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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -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,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,
|
||||||
|
|||||||
@@ -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,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', () => {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,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
@@ -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);
|
||||||
|
|||||||
@@ -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.
|
// 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`;
|
||||||
Reference in new issue
Block a user