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
@@ -18,6 +18,7 @@ import useLibrarySync from '../hooks/useLibrarySync.js';
|
||||
import useForceSync from '../hooks/useForceSync.js';
|
||||
import {formatPlaytime} from '../utils/games.js';
|
||||
import useSessionExpired from '../hooks/useSessionExpired.js';
|
||||
import PageContainer from '../components/PageContainer.jsx';
|
||||
|
||||
const TOP_GAMES = 5;
|
||||
// The games endpoint accepts only the page sizes 20, 40 and 80.
|
||||
@@ -208,7 +209,7 @@ const Dashboard = () => {
|
||||
const maxMinutes = topGames[0]?.playtimeMinutes || 1;
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-5xl mx-auto p-4 md:p-8 space-y-6">
|
||||
<PageContainer className="p-4 md:p-8 space-y-6">
|
||||
<Card className="relative overflow-hidden p-6 md:p-8 flex flex-col md:flex-row md:items-center md:justify-between gap-6">
|
||||
<div className="pointer-events-none absolute inset-0 bg-gradient-to-br from-primary/15 via-transparent to-transparent"/>
|
||||
<div className="relative min-w-0">
|
||||
@@ -316,19 +317,19 @@ const Dashboard = () => {
|
||||
)}
|
||||
|
||||
{hasSteamId && activity && <ActivityCard activity={activity}/>}
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
};
|
||||
|
||||
function DashboardSkeleton() {
|
||||
return (
|
||||
<div className="w-full max-w-5xl mx-auto p-4 md:p-8 space-y-6">
|
||||
<PageContainer className="p-4 md:p-8 space-y-6">
|
||||
<Skeleton className="h-40 rounded-xl"/>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<Skeleton className="h-64 rounded-xl"/>
|
||||
<Skeleton className="h-64 rounded-xl md:col-span-2"/>
|
||||
</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
import {cleanup, render, screen, waitFor} from '@testing-library/react';
|
||||
import {MemoryRouter, Route, Routes, useLocation} from 'react-router-dom';
|
||||
import Dashboard from './Dashboard.jsx';
|
||||
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
|
||||
|
||||
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||
vi.mock('../hooks/useLibrarySync.js', () => ({default: () => ({status: 'OK', lastSyncedAt: null, isRunning: false})}));
|
||||
@@ -32,12 +33,12 @@ describe('Dashboard', () => {
|
||||
globalThis.fetch = vi.fn(async (url) =>
|
||||
url.includes('/api/users/me') ? ok({email: 'a@b.de', steamId: '76561198000000000'}) : unauthorized());
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/dashboard']}>
|
||||
<MemoryRouter initialEntries={['/dashboard']}><PreferencesProvider>
|
||||
<Routes>
|
||||
<Route path="/dashboard" element={<><Dashboard/><Spy/></>}/>
|
||||
<Route path="/login" element={<Spy/>}/>
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
</PreferencesProvider></MemoryRouter>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(fetch.mock.calls.length).toBeGreaterThanOrEqual(5));
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {useEffect, useMemo, useState} from 'react';
|
||||
import {Link, useNavigate, useParams} from 'react-router-dom';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {ArrowLeft, Clock, ExternalLink, ShieldOff, Trophy} from 'lucide-react';
|
||||
import {ArrowLeft, Clock, ExternalLink, Image as ImageIcon, ImageOff, ShieldOff, Trophy} from 'lucide-react';
|
||||
|
||||
import {Badge} from '@/components/ui/badge';
|
||||
import {Button} from '@/components/ui/button';
|
||||
@@ -15,6 +15,9 @@ import PlayingHint from '../components/PlayingHint.jsx';
|
||||
import TimeToBeat from '../components/TimeToBeat.jsx';
|
||||
import {formatPlaytime} from '../utils/games.js';
|
||||
import useSessionExpired from '../hooks/useSessionExpired.js';
|
||||
import useSaveAppearance from '../hooks/useSaveAppearance.js';
|
||||
import {usePreferences} from '../context/PreferencesContext.jsx';
|
||||
import PageContainer from '../components/PageContainer.jsx';
|
||||
|
||||
const formatUnlockedAt = (instant, locale) => {
|
||||
if (!instant) return '';
|
||||
@@ -28,6 +31,8 @@ const formatUnlockedAt = (instant, locale) => {
|
||||
export default function GameDetail() {
|
||||
const {t, i18n} = useTranslation();
|
||||
const {appid} = useParams();
|
||||
const {appearance} = usePreferences();
|
||||
const saveAppearance = useSaveAppearance();
|
||||
const navigate = useNavigate();
|
||||
const expireSession = useSessionExpired();
|
||||
|
||||
@@ -93,6 +98,8 @@ export default function GameDetail() {
|
||||
});
|
||||
}, [data]);
|
||||
|
||||
const isBackground = !!data && appearance.backgroundAppid === data.appid;
|
||||
|
||||
const stats = data ? [
|
||||
[t('GAME_DETAIL.UNLOCKED'), data.achievementsUnlocked],
|
||||
[t('GAME_DETAIL.REMAINING'), data.achievementsRemaining],
|
||||
@@ -100,7 +107,7 @@ export default function GameDetail() {
|
||||
] : [];
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-5xl mx-auto p-4 md:p-8 space-y-6">
|
||||
<PageContainer className="p-4 md:p-8 space-y-6">
|
||||
<Button variant="ghost" size="sm" className="-ml-2" onClick={() => navigate(-1)}>
|
||||
<ArrowLeft/>{t('GAME_DETAIL.BACK')}
|
||||
</Button>
|
||||
@@ -139,11 +146,22 @@ export default function GameDetail() {
|
||||
<TimeToBeat time={data.timeToBeat}/>
|
||||
</div>
|
||||
</div>
|
||||
<Button asChild variant="outline" size="sm" className="self-start">
|
||||
<a href={`https://store.steampowered.com/app/${data.appid}`} target="_blank" rel="noopener noreferrer">
|
||||
{t('GAME_DETAIL.OPEN_IN_STEAM')}<ExternalLink/>
|
||||
</a>
|
||||
</Button>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<a href={`https://store.steampowered.com/app/${data.appid}`} target="_blank" rel="noopener noreferrer">
|
||||
{t('GAME_DETAIL.OPEN_IN_STEAM')}<ExternalLink/>
|
||||
</a>
|
||||
</Button>
|
||||
{isBackground ? (
|
||||
<Button variant="outline" size="sm" onClick={() => saveAppearance({backgroundAppid: null})}>
|
||||
<ImageOff/>{t('GAME_DETAIL.REMOVE_BACKGROUND')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="outline" size="sm" onClick={() => saveAppearance({backgroundAppid: data.appid})}>
|
||||
<ImageIcon/>{t('GAME_DETAIL.SET_BACKGROUND')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -227,6 +245,6 @@ export default function GameDetail() {
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
|
||||
import {MemoryRouter, Route, Routes} from 'react-router-dom';
|
||||
import GameDetail from './GameDetail.jsx';
|
||||
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
|
||||
|
||||
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
|
||||
@@ -13,9 +14,9 @@ const game = {
|
||||
};
|
||||
|
||||
const renderDetail = () => render(
|
||||
<MemoryRouter initialEntries={['/library/220']}>
|
||||
<MemoryRouter initialEntries={['/library/220']}><PreferencesProvider>
|
||||
<Routes><Route path="/library/:appid" element={<GameDetail/>}/></Routes>
|
||||
</MemoryRouter>,
|
||||
</PreferencesProvider></MemoryRouter>,
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -41,4 +42,18 @@ describe('GameDetail', () => {
|
||||
await waitFor(() => expect(screen.queryByRole('button', {name: 'GAME_DETAIL.SET_PLAYING'})).toBeNull());
|
||||
expect(screen.getByLabelText('BACKLOG.NOTES_LABEL').value).toBe('my draft');
|
||||
});
|
||||
|
||||
it('uses the game as background and can remove it again', async () => {
|
||||
renderDetail();
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', {name: 'GAME_DETAIL.SET_BACKGROUND'}));
|
||||
|
||||
const remove = await screen.findByRole('button', {name: 'GAME_DETAIL.REMOVE_BACKGROUND'});
|
||||
const [url, init] = fetch.mock.calls.find(([, call]) => call?.method === 'PUT');
|
||||
expect(url).toContain('/api/users/settings/appearance');
|
||||
expect(JSON.parse(init.body).backgroundAppid).toBe(220);
|
||||
|
||||
fireEvent.click(remove);
|
||||
expect(await screen.findByRole('button', {name: 'GAME_DETAIL.SET_BACKGROUND'})).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,7 @@ import {Skeleton} from '@/components/ui/skeleton';
|
||||
import GameCover from '../components/GameCover.jsx';
|
||||
import LibraryTabs from '../components/LibraryTabs.jsx';
|
||||
import GameStatusBadge from '../components/GameStatusBadge.jsx';
|
||||
import PageContainer from '../components/PageContainer.jsx';
|
||||
|
||||
const PAGE_SIZES = [20, 40, 80];
|
||||
const DEFAULT_SIZE = 20;
|
||||
@@ -146,7 +147,7 @@ export default function GameLibrary() {
|
||||
const ready = !isLoading && !error;
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-5xl mx-auto p-4 md:p-8 space-y-6">
|
||||
<PageContainer className="p-4 md:p-8 space-y-6">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
|
||||
<div className="space-y-2">
|
||||
<LibraryTabs/>
|
||||
@@ -292,6 +293,6 @@ export default function GameLibrary() {
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
import {act, cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
|
||||
import {MemoryRouter, Route, Routes, useLocation, useNavigate} from 'react-router-dom';
|
||||
import GameLibrary from './GameLibrary.jsx';
|
||||
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
|
||||
|
||||
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||
vi.mock('../hooks/useLibrarySync.js', () => ({default: () => ({status: 'OK', lastSyncedAt: null, isRunning: false})}));
|
||||
@@ -26,9 +27,9 @@ function renderLibrary(initialEntry = '/library') {
|
||||
return null;
|
||||
}
|
||||
render(
|
||||
<MemoryRouter initialEntries={[initialEntry]}>
|
||||
<MemoryRouter initialEntries={[initialEntry]}><PreferencesProvider>
|
||||
<Routes><Route path="/library" element={<><GameLibrary/><Spy/></>}/></Routes>
|
||||
</MemoryRouter>,
|
||||
</PreferencesProvider></MemoryRouter>,
|
||||
);
|
||||
return {
|
||||
navigations: () => lib.navigations,
|
||||
|
||||
@@ -9,6 +9,7 @@ import GameCover from '../components/GameCover.jsx';
|
||||
import GameStatusBadge, {STATUS_META} from '../components/GameStatusBadge.jsx';
|
||||
import {GAME_STATUSES} from '../utils/backlog.js';
|
||||
import {formatPlaytime} from '../utils/games.js';
|
||||
import PageContainer from '../components/PageContainer.jsx';
|
||||
|
||||
/**
|
||||
* Public, no login. Auth returns a short-lived ticket for a public profile; Core returns the data for it.
|
||||
@@ -64,22 +65,22 @@ export default function PublicProfilePage() {
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="w-full max-w-5xl mx-auto p-4 md:p-8">
|
||||
<PageContainer className="p-4 md:p-8">
|
||||
<Card className="items-center py-16 text-center text-muted-foreground">
|
||||
<UserRoundX className="size-10"/>
|
||||
<p className="max-w-sm text-sm">{error}</p>
|
||||
<Link to="/" className="text-sm font-medium text-primary hover:underline">{t('PROFILE.TO_START')}</Link>
|
||||
</Card>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
if (!library) {
|
||||
return (
|
||||
<div className="w-full max-w-5xl mx-auto p-4 md:p-8 space-y-6">
|
||||
<PageContainer className="p-4 md:p-8 space-y-6">
|
||||
<Skeleton className="h-36 rounded-xl"/>
|
||||
<Skeleton className="h-64 rounded-xl"/>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -94,7 +95,7 @@ export default function PublicProfilePage() {
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-5xl mx-auto p-4 md:p-8 space-y-6">
|
||||
<PageContainer className="p-4 md:p-8 space-y-6">
|
||||
<Card className="relative overflow-hidden p-6 md:p-8 gap-6">
|
||||
<div className="pointer-events-none absolute inset-0 bg-gradient-to-br from-primary/15 via-transparent to-transparent"/>
|
||||
<div className="relative">
|
||||
@@ -174,6 +175,6 @@ export default function PublicProfilePage() {
|
||||
</ul>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import {afterEach, describe, expect, it, vi} from 'vitest';
|
||||
import {cleanup, render, screen, waitFor} from '@testing-library/react';
|
||||
import {MemoryRouter, Route, Routes} from 'react-router-dom';
|
||||
import PublicProfilePage from './PublicProfilePage.jsx';
|
||||
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
|
||||
|
||||
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||
|
||||
@@ -24,9 +25,9 @@ function mockBackends({profileStatus = 200, libraryStatus = 200} = {}) {
|
||||
}
|
||||
|
||||
const renderPage = (name = 'Lobo') => render(
|
||||
<MemoryRouter initialEntries={[`/u/${name}`]}>
|
||||
<MemoryRouter initialEntries={[`/u/${name}`]}><PreferencesProvider>
|
||||
<Routes><Route path="/u/:name" element={<PublicProfilePage/>}/></Routes>
|
||||
</MemoryRouter>,
|
||||
</PreferencesProvider></MemoryRouter>,
|
||||
);
|
||||
|
||||
describe('PublicProfilePage', () => {
|
||||
|
||||
@@ -4,7 +4,7 @@ import {useTranslation} from 'react-i18next';
|
||||
import {useForm} from 'react-hook-form';
|
||||
import {zodResolver} from '@hookform/resolvers/zod';
|
||||
|
||||
import {Copy, Gamepad2, Loader2, Save, SlidersHorizontal, UserRound} from 'lucide-react';
|
||||
import {Copy, Gamepad2, ImageOff, Loader2, Palette, Save, SlidersHorizontal, UserRound} from 'lucide-react';
|
||||
import {toast} from 'sonner';
|
||||
|
||||
import {Button} from '@/components/ui/button';
|
||||
@@ -18,6 +18,10 @@ import FormField from '../components/FormField.jsx';
|
||||
import {settingsSchema} from '../utils/validation.js';
|
||||
import {usePreferences} from '../context/PreferencesContext.jsx';
|
||||
import useSessionExpired from '../hooks/useSessionExpired.js';
|
||||
import useSaveAppearance from '../hooks/useSaveAppearance.js';
|
||||
import {ACCENTS, CORNERS} from '../utils/appearance.js';
|
||||
import {coverUrl} from '../utils/games.js';
|
||||
import {cn} from '@/lib/utils';
|
||||
|
||||
const BACKEND_ERROR_KEY = {
|
||||
STEAM_ID_ALREADY_IN_USE: 'REGISTRATION.ERROR_STEAM_ID_IN_USE',
|
||||
@@ -39,7 +43,8 @@ export default function SettingsPage() {
|
||||
const {t, i18n} = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const expireSession = useSessionExpired();
|
||||
const {isDarkMode, setDarkMode, setLanguage} = usePreferences();
|
||||
const {isDarkMode, setDarkMode, setLanguage, appearance} = usePreferences();
|
||||
const saveAppearance = useSaveAppearance();
|
||||
|
||||
const [profile, setProfile] = useState(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
@@ -268,6 +273,68 @@ export default function SettingsPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2"><Palette className="size-4 text-primary"/>{t('SETTINGS.APPEARANCE')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<Label id="accent-label">{t('SETTINGS.ACCENT')}</Label>
|
||||
<div role="group" aria-labelledby="accent-label" className="flex flex-wrap gap-2">
|
||||
{ACCENTS.map((accent) => (
|
||||
<button key={accent} type="button" data-accent={accent}
|
||||
aria-label={t(`SETTINGS.ACCENT_OPTIONS.${accent.toUpperCase()}`)}
|
||||
title={t(`SETTINGS.ACCENT_OPTIONS.${accent.toUpperCase()}`)}
|
||||
aria-pressed={appearance.accent === accent}
|
||||
onClick={() => saveAppearance({accent})}
|
||||
className={cn('size-8 rounded-full bg-primary ring-offset-2 ring-offset-background transition hover:scale-110',
|
||||
appearance.accent === accent && 'ring-2 ring-foreground')}/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<Label id="corners-label">{t('SETTINGS.CORNERS')}</Label>
|
||||
<div role="group" aria-labelledby="corners-label" className="flex gap-2">
|
||||
{CORNERS.map((corners) => (
|
||||
<Button key={corners} type="button" size="sm" data-corners={corners}
|
||||
variant={appearance.corners === corners ? 'default' : 'outline'}
|
||||
aria-pressed={appearance.corners === corners}
|
||||
onClick={() => saveAppearance({corners})}>
|
||||
{t(`SETTINGS.CORNER_OPTIONS.${corners.toUpperCase()}`)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="wideLayout">{t('SETTINGS.WIDE_LAYOUT')}</Label>
|
||||
<p className="text-xs text-muted-foreground">{t('SETTINGS.WIDE_LAYOUT_HINT')}</p>
|
||||
</div>
|
||||
<Switch id="wideLayout" checked={appearance.wideLayout}
|
||||
onCheckedChange={(wideLayout) => saveAppearance({wideLayout})}/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<Label>{t('SETTINGS.BACKGROUND')}</Label>
|
||||
{!appearance.backgroundAppid && (
|
||||
<p className="text-xs text-muted-foreground">{t('SETTINGS.BACKGROUND_HINT')}</p>
|
||||
)}
|
||||
</div>
|
||||
{appearance.backgroundAppid && (
|
||||
<div className="flex items-center gap-2">
|
||||
<img src={coverUrl(appearance.backgroundAppid)} alt=""
|
||||
onError={(event) => { event.currentTarget.hidden = true; }}
|
||||
className="h-10 w-20 rounded-md object-cover"/>
|
||||
<Button type="button" variant="outline" size="sm"
|
||||
onClick={() => saveAppearance({backgroundAppid: null})}>
|
||||
<ImageOff/>{t('SETTINGS.BACKGROUND_REMOVE')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2"><Gamepad2 className="size-4 text-primary"/>{t('SETTINGS.PROFILE')}</CardTitle>
|
||||
|
||||
@@ -74,3 +74,39 @@ describe('SettingsPage public profile', () => {
|
||||
await waitFor(() => expect(screen.getByRole('switch', {name: 'SETTINGS.PUBLIC_PROFILE'}).getAttribute('aria-checked')).toBe('false'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('SettingsPage appearance', () => {
|
||||
beforeEach(() => localStorage.setItem('tbp_jwt', 'token'));
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
const appearancePuts = () => fetch.mock.calls
|
||||
.filter(([url, init]) => init?.method === 'PUT' && url.endsWith('/api/users/settings/appearance'))
|
||||
.map(([, init]) => JSON.parse(init.body));
|
||||
|
||||
it('saves a picked accent and corner style and marks them as picked', async () => {
|
||||
renderPage(profile());
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', {name: 'SETTINGS.ACCENT_OPTIONS.GREEN'}));
|
||||
fireEvent.click(screen.getByRole('button', {name: 'SETTINGS.CORNER_OPTIONS.ROUND'}));
|
||||
|
||||
await waitFor(() => expect(appearancePuts()).toHaveLength(2));
|
||||
expect(appearancePuts()[1]).toEqual({accent: 'green', corners: 'round', wideLayout: false, backgroundAppid: null});
|
||||
expect(screen.getByRole('button', {name: 'SETTINGS.ACCENT_OPTIONS.GREEN'}).getAttribute('aria-pressed')).toBe('true');
|
||||
expect(screen.getByRole('button', {name: 'SETTINGS.ACCENT_OPTIONS.VIOLET'}).getAttribute('aria-pressed')).toBe('false');
|
||||
});
|
||||
|
||||
it('removes the background', async () => {
|
||||
localStorage.setItem('tbp_appearance', JSON.stringify({backgroundAppid: 620}));
|
||||
renderPage(profile());
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', {name: 'SETTINGS.BACKGROUND_REMOVE'}));
|
||||
|
||||
await waitFor(() => expect(appearancePuts()).toHaveLength(1));
|
||||
expect(appearancePuts()[0].backgroundAppid).toBeNull();
|
||||
expect(screen.getByText('SETTINGS.BACKGROUND_HINT')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@ import {Skeleton} from '@/components/ui/skeleton';
|
||||
import GameCover from '../components/GameCover.jsx';
|
||||
import LibraryTabs from '../components/LibraryTabs.jsx';
|
||||
import useSessionExpired from '../hooks/useSessionExpired.js';
|
||||
import PageContainer from '../components/PageContainer.jsx';
|
||||
|
||||
const formatAdded = (instant, locale) =>
|
||||
instant ? new Date(instant).toLocaleDateString(locale, {year: 'numeric', month: 'short', day: 'numeric'}) : null;
|
||||
@@ -59,7 +60,7 @@ export default function WishlistPage() {
|
||||
const onSaleCount = (games ?? []).filter((g) => g.discountPct > 0).length;
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-5xl mx-auto p-4 md:p-8 space-y-6">
|
||||
<PageContainer className="p-4 md:p-8 space-y-6">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
|
||||
<div className="space-y-3">
|
||||
<LibraryTabs/>
|
||||
@@ -142,6 +143,6 @@ export default function WishlistPage() {
|
||||
</ul>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
|
||||
import {MemoryRouter, Route, Routes, useLocation} from 'react-router-dom';
|
||||
import WishlistPage from './WishlistPage.jsx';
|
||||
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
|
||||
|
||||
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||
|
||||
@@ -16,12 +17,12 @@ function LoginState() {
|
||||
}
|
||||
|
||||
const renderPage = () => render(
|
||||
<MemoryRouter initialEntries={['/wishlist']}>
|
||||
<MemoryRouter initialEntries={['/wishlist']}><PreferencesProvider>
|
||||
<Routes>
|
||||
<Route path="/wishlist" element={<WishlistPage/>}/>
|
||||
<Route path="/login" element={<LoginState/>}/>
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
</PreferencesProvider></MemoryRouter>,
|
||||
);
|
||||
|
||||
describe('WishlistPage', () => {
|
||||
|
||||
Reference in new issue
Block a user