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

+3 -2
View File
@@ -12,6 +12,7 @@ import {
} from '@/components/ui/dropdown-menu';
import {usePreferences} from '@/context/PreferencesContext.jsx';
import {cn} from '@/lib/utils';
import PageContainer from './PageContainer.jsx';
const NAV_ITEMS = [
{to: '/dashboard', labelKey: 'NAV.DASHBOARD', Icon: LayoutDashboard},
@@ -37,7 +38,7 @@ export default function AppHeader({isAuthenticated, onLogout}) {
return (
<header className="sticky top-0 z-50 border-b bg-background/80 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="mx-auto flex h-16 max-w-5xl items-center gap-2 px-4 md:px-8">
<PageContainer className="flex h-16 items-center gap-2 px-4 md:px-8">
<Link to={isAuthenticated ? '/dashboard' : '/'} className="mr-2 flex items-center gap-2 font-bold tracking-tight whitespace-nowrap">
<span className="flex size-8 items-center justify-center rounded-lg bg-primary text-primary-foreground">
<Gamepad2 className="size-5"/>
@@ -95,7 +96,7 @@ export default function AppHeader({isAuthenticated, onLogout}) {
</>
)}
</div>
</div>
</PageContainer>
</header>
);
}
+21
View File
@@ -0,0 +1,21 @@
import {useState} from 'react';
import {coverUrl, heroUrl} from '../utils/games.js';
/**
* Game art behind the whole page, blurred and dimmed so text on it stays readable. Falls back to the store
* header for apps without library art. Give it `key={appid}`, so a new game starts with its library art again.
*/
export default function PageBackground({appid}) {
const [failures, setFailures] = useState(0);
if (!appid || failures > 1) return null;
return (
<div aria-hidden="true" className="pointer-events-none fixed inset-0 -z-10 overflow-hidden">
<img src={failures === 0 ? heroUrl(appid) : coverUrl(appid)} alt=""
onError={() => setFailures((count) => count + 1)}
className="size-full scale-110 object-cover blur-md"/>
<div className="absolute inset-0 bg-background/80"/>
</div>
);
}
+26
View File
@@ -0,0 +1,26 @@
// @vitest-environment jsdom
import {afterEach, describe, expect, it} from 'vitest';
import {cleanup, fireEvent, render} from '@testing-library/react';
import PageBackground from './PageBackground.jsx';
afterEach(cleanup);
describe('PageBackground', () => {
it('shows nothing without a game', () => {
const {container} = render(<PageBackground appid={null}/>);
expect(container.innerHTML).toBe('');
});
it('shows the library art, then the store header, then nothing', () => {
const {container} = render(<PageBackground appid={620}/>);
const img = () => container.querySelector('img');
expect(img().getAttribute('src')).toContain('/620/library_hero.jpg');
fireEvent.error(img());
expect(img().getAttribute('src')).toContain('/620/header.jpg');
fireEvent.error(img());
expect(container.innerHTML).toBe('');
});
});
+14
View File
@@ -0,0 +1,14 @@
import {cn} from '@/lib/utils';
import {usePreferences} from '../context/PreferencesContext.jsx';
/**
* The centered page column. Its width follows the wide layout setting.
*/
export default function PageContainer({className, children}) {
const {appearance} = usePreferences();
return (
<div className={cn('w-full mx-auto', appearance.wideLayout ? 'max-w-7xl' : 'max-w-5xl', className)}>
{children}
</div>
);
}
+31
View File
@@ -0,0 +1,31 @@
// @vitest-environment jsdom
import {afterEach, describe, expect, it, vi} from 'vitest';
import {cleanup, render} from '@testing-library/react';
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
import PageContainer from './PageContainer.jsx';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
afterEach(() => {
cleanup();
localStorage.clear();
});
const renderContainer = () => render(
<PreferencesProvider><PageContainer className="p-4">content</PageContainer></PreferencesProvider>,
).container.firstChild;
describe('PageContainer', () => {
it('is the normal column by default', () => {
expect(renderContainer().className).toContain('max-w-5xl');
});
it('is wider with the wide layout', () => {
localStorage.setItem('tbp_appearance', JSON.stringify({wideLayout: true}));
const container = renderContainer();
expect(container.className).toContain('max-w-7xl');
expect(container.className).toContain('p-4');
});
});