feat(dashboard): Steam avatar and a banner from the most played game

This commit is contained in:
lobo committed 2026-09-24 15:27:33 +02:00
1 parent 2c360c9421
commit 23e502bf5b
11 files changed
+222 -37

No files matched your search

+6 -3
View File
@@ -1,6 +1,6 @@
import {Link, NavLink, useLocation} from 'react-router-dom';
import {useTranslation} from 'react-i18next';
import {Gamepad2, Languages, LayoutDashboard, Library, LogOut, Moon, Settings, Sun, UserRound} from 'lucide-react';
import {Gamepad2, Languages, LayoutDashboard, Library, LogOut, Moon, Settings, Sun} from 'lucide-react';
import {Button} from '@/components/ui/button';
import {
@@ -13,6 +13,8 @@ import {
import {usePreferences} from '@/context/PreferencesContext.jsx';
import {cn} from '@/lib/utils';
import PageContainer from './PageContainer.jsx';
import SteamAvatar from './SteamAvatar.jsx';
import useSteamProfile from '../hooks/useSteamProfile.js';
const NAV_ITEMS = [
{to: '/dashboard', labelKey: 'NAV.DASHBOARD', Icon: LayoutDashboard},
@@ -31,6 +33,7 @@ export default function AppHeader({isAuthenticated, onLogout}) {
const {t, i18n} = useTranslation();
const {isDarkMode, setDarkMode, setLanguage} = usePreferences();
const {pathname} = useLocation();
const steamProfile = useSteamProfile(isAuthenticated);
const toggleLanguage = () => setLanguage(i18n.language === 'de' ? 'en' : 'de');
const toggleTheme = () => setDarkMode(!isDarkMode);
@@ -62,8 +65,8 @@ export default function AppHeader({isAuthenticated, onLogout}) {
{isAuthenticated ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" aria-label={t('NAV.MENU')}>
<UserRound className="size-5"/>
<Button variant="ghost" size="icon" aria-label={t('NAV.MENU')} title={steamProfile?.personaName ?? undefined}>
<SteamAvatar url={steamProfile?.avatarUrl}/>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52">
+17
View File
@@ -0,0 +1,17 @@
import {useState} from 'react';
import {coverUrl, heroUrl} from '../utils/games.js';
/**
* The wide library art of a game, else its store header, else nothing. Give it `key={appid}`, so a new game
* starts with its library art again.
*/
export default function HeroArt({appid, className}) {
const [failures, setFailures] = useState(0);
if (!appid || failures > 1) return null;
return (
<img src={failures === 0 ? heroUrl(appid) : coverUrl(appid)} alt=""
onError={() => setFailures((count) => count + 1)} className={className}/>
);
}
@@ -1,10 +1,25 @@
// @vitest-environment jsdom
import {afterEach, describe, expect, it} from 'vitest';
import {cleanup, fireEvent, render} from '@testing-library/react';
import HeroArt from './HeroArt.jsx';
import PageBackground from './PageBackground.jsx';
afterEach(cleanup);
describe('HeroArt', () => {
it('shows the library art, then the store header, then nothing', () => {
const {container} = render(<HeroArt 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(img()).toBeNull();
});
});
describe('PageBackground', () => {
it('shows nothing without a game', () => {
const {container} = render(<PageBackground appid={null}/>);
@@ -12,15 +27,12 @@ describe('PageBackground', () => {
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');
it('starts with the library art again for another game', () => {
const {container, rerender} = render(<PageBackground appid={620}/>);
fireEvent.error(container.querySelector('img'));
fireEvent.error(img());
expect(img().getAttribute('src')).toContain('/620/header.jpg');
rerender(<PageBackground appid={220}/>);
fireEvent.error(img());
expect(container.innerHTML).toBe('');
expect(container.querySelector('img').getAttribute('src')).toContain('/220/library_hero.jpg');
});
});
+4 -10
View File
@@ -1,20 +1,14 @@
import {useState} from 'react';
import {coverUrl, heroUrl} from '../utils/games.js';
import HeroArt from './HeroArt.jsx';
/**
* 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.
* Game art behind the whole page, blurred and dimmed so text on it stays readable.
*/
export default function PageBackground({appid}) {
const [failures, setFailures] = useState(0);
if (!appid || failures > 1) return null;
if (!appid) 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"/>
<HeroArt key={appid} appid={appid} className="size-full scale-110 object-cover blur-md"/>
<div className="absolute inset-0 bg-background/80"/>
</div>
);
+21
View File
@@ -0,0 +1,21 @@
import {useState} from 'react';
import {UserRound} from 'lucide-react';
import {cn} from '@/lib/utils';
/**
* The Steam avatar, or the default user icon without one or when it does not load.
*
* @param className for the avatar image
* @param iconClassName for the default icon, which should be smaller than the image
*/
export default function SteamAvatar({url, className, iconClassName}) {
const [failedUrl, setFailedUrl] = useState(null);
if (!url || failedUrl === url) {
return <UserRound className={cn('size-5', iconClassName)}/>;
}
return (
<img src={url} alt="" onError={() => setFailedUrl(url)}
className={cn('size-7 rounded-full object-cover', className)}/>
);
}
+25
View File
@@ -0,0 +1,25 @@
// @vitest-environment jsdom
import {afterEach, describe, expect, it} from 'vitest';
import {cleanup, fireEvent, render} from '@testing-library/react';
import SteamAvatar from './SteamAvatar.jsx';
afterEach(cleanup);
describe('SteamAvatar', () => {
it('shows the avatar and the default icon when it does not load', () => {
const {container} = render(<SteamAvatar url="https://avatars.steamstatic.com/a_full.jpg"/>);
expect(container.querySelector('img').getAttribute('src')).toBe('https://avatars.steamstatic.com/a_full.jpg');
fireEvent.error(container.querySelector('img'));
expect(container.querySelector('img')).toBeNull();
expect(container.querySelector('svg')).not.toBeNull();
});
it('shows the default icon without an avatar', () => {
const {container} = render(<SteamAvatar url={null}/>);
expect(container.querySelector('img')).toBeNull();
expect(container.querySelector('svg')).not.toBeNull();
});
});