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

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

No files matched your search

+5 -4
View File
@@ -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 -2
View File
@@ -3,6 +3,7 @@ import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {cleanup, render, screen, waitFor} from '@testing-library/react';
import {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));
+26 -8
View File
@@ -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>
);
}
+17 -2
View File
@@ -3,6 +3,7 @@ import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
import {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();
});
});
+3 -2
View File
@@ -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 -2
View File
@@ -3,6 +3,7 @@ import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {act, cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
import {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,
+7 -6
View File
@@ -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 -2
View File
@@ -3,6 +3,7 @@ import {afterEach, describe, expect, it, vi} from 'vitest';
import {cleanup, render, screen, waitFor} from '@testing-library/react';
import {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', () => {
+69 -2
View File
@@ -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>
+36
View File
@@ -74,3 +74,39 @@ describe('SettingsPage public profile', () => {
await waitFor(() => expect(screen.getByRole('switch', {name: 'SETTINGS.PUBLIC_PROFILE'}).getAttribute('aria-checked')).toBe('false'));
});
});
describe('SettingsPage appearance', () => {
beforeEach(() => localStorage.setItem('tbp_jwt', 'token'));
afterEach(() => {
cleanup();
localStorage.clear();
});
const appearancePuts = () => fetch.mock.calls
.filter(([url, init]) => init?.method === 'PUT' && url.endsWith('/api/users/settings/appearance'))
.map(([, init]) => JSON.parse(init.body));
it('saves a picked accent and corner style and marks them as picked', async () => {
renderPage(profile());
fireEvent.click(await screen.findByRole('button', {name: 'SETTINGS.ACCENT_OPTIONS.GREEN'}));
fireEvent.click(screen.getByRole('button', {name: 'SETTINGS.CORNER_OPTIONS.ROUND'}));
await waitFor(() => expect(appearancePuts()).toHaveLength(2));
expect(appearancePuts()[1]).toEqual({accent: 'green', corners: 'round', wideLayout: false, backgroundAppid: null});
expect(screen.getByRole('button', {name: 'SETTINGS.ACCENT_OPTIONS.GREEN'}).getAttribute('aria-pressed')).toBe('true');
expect(screen.getByRole('button', {name: 'SETTINGS.ACCENT_OPTIONS.VIOLET'}).getAttribute('aria-pressed')).toBe('false');
});
it('removes the background', async () => {
localStorage.setItem('tbp_appearance', JSON.stringify({backgroundAppid: 620}));
renderPage(profile());
fireEvent.click(await screen.findByRole('button', {name: 'SETTINGS.BACKGROUND_REMOVE'}));
await waitFor(() => expect(appearancePuts()).toHaveLength(1));
expect(appearancePuts()[0].backgroundAppid).toBeNull();
expect(screen.getByText('SETTINGS.BACKGROUND_HINT')).toBeTruthy();
});
});
+3 -2
View File
@@ -10,6 +10,7 @@ import {Skeleton} from '@/components/ui/skeleton';
import GameCover from '../components/GameCover.jsx';
import 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 -2
View File
@@ -3,6 +3,7 @@ import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
import {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', () => {