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
+209 -24

No files matched your search

+1 -1
View File
@@ -49,7 +49,7 @@ function App() {
<TooltipProvider> <TooltipProvider>
{/* isolate: the background layer sits behind the content but in front of this div's own background. */} {/* 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"> <div className="isolate min-h-screen flex flex-col bg-background text-foreground font-sans">
{isAuthenticated && <PageBackground key={appearance.backgroundAppid} appid={appearance.backgroundAppid}/>} {isAuthenticated && <PageBackground 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">
+6 -3
View File
@@ -1,6 +1,6 @@
import {Link, NavLink, useLocation} from 'react-router-dom'; import {Link, NavLink, useLocation} from 'react-router-dom';
import {useTranslation} from 'react-i18next'; 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 {Button} from '@/components/ui/button';
import { import {
@@ -13,6 +13,8 @@ import {
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'; import PageContainer from './PageContainer.jsx';
import SteamAvatar from './SteamAvatar.jsx';
import useSteamProfile from '../hooks/useSteamProfile.js';
const NAV_ITEMS = [ const NAV_ITEMS = [
{to: '/dashboard', labelKey: 'NAV.DASHBOARD', Icon: LayoutDashboard}, {to: '/dashboard', labelKey: 'NAV.DASHBOARD', Icon: LayoutDashboard},
@@ -31,6 +33,7 @@ export default function AppHeader({isAuthenticated, onLogout}) {
const {t, i18n} = useTranslation(); const {t, i18n} = useTranslation();
const {isDarkMode, setDarkMode, setLanguage} = usePreferences(); const {isDarkMode, setDarkMode, setLanguage} = usePreferences();
const {pathname} = useLocation(); const {pathname} = useLocation();
const steamProfile = useSteamProfile(isAuthenticated);
const toggleLanguage = () => setLanguage(i18n.language === 'de' ? 'en' : 'de'); const toggleLanguage = () => setLanguage(i18n.language === 'de' ? 'en' : 'de');
const toggleTheme = () => setDarkMode(!isDarkMode); const toggleTheme = () => setDarkMode(!isDarkMode);
@@ -62,8 +65,8 @@ export default function AppHeader({isAuthenticated, onLogout}) {
{isAuthenticated ? ( {isAuthenticated ? (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" aria-label={t('NAV.MENU')}> <Button variant="ghost" size="icon" aria-label={t('NAV.MENU')} title={steamProfile?.personaName ?? undefined}>
<UserRound className="size-5"/> <SteamAvatar url={steamProfile?.avatarUrl}/>
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52"> <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 // @vitest-environment jsdom
import {afterEach, describe, expect, it} from 'vitest'; import {afterEach, describe, expect, it} from 'vitest';
import {cleanup, fireEvent, render} from '@testing-library/react'; import {cleanup, fireEvent, render} from '@testing-library/react';
import HeroArt from './HeroArt.jsx';
import PageBackground from './PageBackground.jsx'; import PageBackground from './PageBackground.jsx';
afterEach(cleanup); 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', () => { describe('PageBackground', () => {
it('shows nothing without a game', () => { it('shows nothing without a game', () => {
const {container} = render(<PageBackground appid={null}/>); const {container} = render(<PageBackground appid={null}/>);
@@ -12,15 +27,12 @@ describe('PageBackground', () => {
expect(container.innerHTML).toBe(''); expect(container.innerHTML).toBe('');
}); });
it('shows the library art, then the store header, then nothing', () => { it('starts with the library art again for another game', () => {
const {container} = render(<PageBackground appid={620}/>); const {container, rerender} = render(<PageBackground appid={620}/>);
const img = () => container.querySelector('img'); fireEvent.error(container.querySelector('img'));
expect(img().getAttribute('src')).toContain('/620/library_hero.jpg');
fireEvent.error(img()); rerender(<PageBackground appid={220}/>);
expect(img().getAttribute('src')).toContain('/620/header.jpg');
fireEvent.error(img()); expect(container.querySelector('img').getAttribute('src')).toContain('/220/library_hero.jpg');
expect(container.innerHTML).toBe('');
}); });
}); });
+4 -10
View File
@@ -1,20 +1,14 @@
import {useState} from 'react'; import HeroArt from './HeroArt.jsx';
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 * Game art behind the whole page, blurred and dimmed so text on it stays readable.
* 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}) { export default function PageBackground({appid}) {
const [failures, setFailures] = useState(0); if (!appid) return null;
if (!appid || failures > 1) return null;
return ( return (
<div aria-hidden="true" className="pointer-events-none fixed inset-0 -z-10 overflow-hidden"> <div aria-hidden="true" className="pointer-events-none fixed inset-0 -z-10 overflow-hidden">
<img src={failures === 0 ? heroUrl(appid) : coverUrl(appid)} alt="" <HeroArt key={appid} appid={appid} className="size-full scale-110 object-cover blur-md"/>
onError={() => setFailures((count) => count + 1)}
className="size-full scale-110 object-cover blur-md"/>
<div className="absolute inset-0 bg-background/80"/> <div className="absolute inset-0 bg-background/80"/>
</div> </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();
});
});
+35
View File
@@ -0,0 +1,35 @@
import {useEffect, useState} from 'react';
/**
* The user's Steam name and avatar from Core, or null while loading, without a Steam ID or on any error. Only
* decoration, so a failed call shows the default icon and never ends the session. Loads again for a new token
* (new Steam ID or another login) on the next render, e.g. the next navigation.
*/
export default function useSteamProfile(enabled) {
const [profile, setProfile] = useState(null);
const token = localStorage.getItem('tbp_jwt');
useEffect(() => {
if (!enabled || !token) {
setProfile(null);
return undefined;
}
let cancelled = false;
(async () => {
try {
const response = await fetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/steam-profile`, {
headers: {'Authorization': `Bearer ${token}`},
});
const data = response.ok ? await response.json() : null;
if (!cancelled) setProfile(data);
} catch {
if (!cancelled) setProfile(null);
}
})();
return () => {
cancelled = true;
};
}, [enabled, token]);
return profile;
}
+39
View File
@@ -0,0 +1,39 @@
// @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {cleanup, renderHook, waitFor} from '@testing-library/react';
import useSteamProfile from './useSteamProfile.js';
const ok = (body) => ({ok: true, status: 200, json: async () => body});
beforeEach(() => {
localStorage.setItem('tbp_jwt', 'old-token');
});
afterEach(() => {
cleanup();
localStorage.clear();
});
describe('useSteamProfile', () => {
it('loads again after a new token was stored, e.g. with a new Steam ID', async () => {
globalThis.fetch = vi.fn(async (url, init) => ok(init.headers.Authorization === 'Bearer new-token'
? {personaName: 'New', avatarUrl: 'https://avatars.steamstatic.com/new_full.jpg'}
: {personaName: 'Old', avatarUrl: 'https://avatars.steamstatic.com/old_full.jpg'}));
const {result, rerender} = renderHook(() => useSteamProfile(true));
await waitFor(() => expect(result.current?.avatarUrl).toContain('old_full'));
localStorage.setItem('tbp_jwt', 'new-token');
rerender();
await waitFor(() => expect(result.current?.avatarUrl).toContain('new_full'));
});
it('sends nothing without a token, e.g. right after the session expired', async () => {
localStorage.removeItem('tbp_jwt');
globalThis.fetch = vi.fn(async () => ({ok: false, status: 401, json: async () => ({})}));
renderHook(() => useSteamProfile(true));
await new Promise((resolve) => setTimeout(resolve, 50));
expect(fetch).not.toHaveBeenCalled();
});
});
+21 -2
View File
@@ -19,6 +19,10 @@ 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'; import PageContainer from '../components/PageContainer.jsx';
import HeroArt from '../components/HeroArt.jsx';
import SteamAvatar from '../components/SteamAvatar.jsx';
import useSteamProfile from '../hooks/useSteamProfile.js';
import {cn} from '@/lib/utils';
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.
@@ -185,6 +189,8 @@ const Dashboard = () => {
}, [navigate, expireSession, t, fetchSteamCount, fetchTopGames, fetchPlaying, fetchSummary, fetchActivity]); }, [navigate, expireSession, t, fetchSteamCount, fetchTopGames, fetchPlaying, fetchSummary, fetchActivity]);
const hasSteamId = !!userData?.steamId; const hasSteamId = !!userData?.steamId;
const steamProfile = useSteamProfile(hasSteamId);
const bannerAppid = activity?.recentGames?.[0]?.appid ?? null;
const sync = useLibrarySync({ const sync = useLibrarySync({
enabled: hasSteamId, enabled: hasSteamId,
expectInitialSync: hasSteamId && steamData.count === 0, expectInitialSync: hasSteamId && steamData.count === 0,
@@ -211,8 +217,20 @@ const Dashboard = () => {
return ( return (
<PageContainer className="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"/> {/* The banner is the game played most in the last two weeks; the gradient keeps the text readable. */}
<div className="relative min-w-0"> <HeroArt key={bannerAppid} appid={bannerAppid}
className="pointer-events-none absolute inset-0 size-full object-cover opacity-60"/>
<div className={cn('pointer-events-none absolute inset-0', bannerAppid
? 'bg-gradient-to-r from-card via-card/85 to-card/60'
: 'bg-gradient-to-br from-primary/15 via-transparent to-transparent')}/>
<div className="relative flex min-w-0 items-center gap-4">
{/* The slot is there before the avatar loads, so the title does not jump. */}
{hasSteamId && (
<span className="flex size-16 shrink-0 items-center justify-center rounded-full bg-muted ring-2 ring-primary/60">
<SteamAvatar url={steamProfile?.avatarUrl} className="size-16" iconClassName="size-8 text-muted-foreground"/>
</span>
)}
<div className="min-w-0">
<p className="text-sm font-medium text-muted-foreground">{t('DASHBOARD.WELCOME_BACK')}</p> <p className="text-sm font-medium text-muted-foreground">{t('DASHBOARD.WELCOME_BACK')}</p>
<h1 className="mt-1 text-3xl md:text-4xl font-bold tracking-tight truncate"> <h1 className="mt-1 text-3xl md:text-4xl font-bold tracking-tight truncate">
{t('DASHBOARD.HELLO')} {userData?.displayName ?? userData?.email?.split('@')[0]} 👋 {t('DASHBOARD.HELLO')} {userData?.displayName ?? userData?.email?.split('@')[0]} 👋
@@ -228,6 +246,7 @@ const Dashboard = () => {
</Button> </Button>
)} )}
</div> </div>
</div>
{hasSteamId && ( {hasSteamId && (
<div className="relative flex flex-col items-start md:items-end gap-3 shrink-0"> <div className="relative flex flex-col items-start md:items-end gap-3 shrink-0">
+20
View File
@@ -47,4 +47,24 @@ describe('Dashboard', () => {
expect(current.location.state).toEqual({sessionExpired: true, from: '/dashboard'}); expect(current.location.state).toEqual({sessionExpired: true, from: '/dashboard'});
expect(screen.queryByText('LIBRARY.EMPTY')).toBeNull(); expect(screen.queryByText('LIBRARY.EMPTY')).toBeNull();
}); });
it('shows the Steam avatar and the most played game of the last two weeks as banner', async () => {
const avatar = 'https://avatars.steamstatic.com/abc_full.jpg';
globalThis.fetch = vi.fn(async (url) => {
if (url.includes('/api/users/me')) return ok({email: 'a@b.de', displayName: 'Lobo', steamId: '76561198000000000'});
if (url.includes('/api/library/steam-profile')) return ok({personaName: 'Lobo', avatarUrl: avatar});
if (url.includes('/api/library/activity')) return ok({totalMinutes: 90, days: [{day: '2026-09-23', minutes: 0}, {day: '2026-09-24', minutes: 90}], recentGames: [
{appid: 1086940, name: "Baldur's Gate 3", playtimeMinutes: 900, playtime2Weeks: 90, status: 'PLAYING'}]});
if (url.includes('/api/library/games-count')) return ok({count: 1, hasSteamId: true});
return ok({content: [], total: 0, byStatus: {}});
});
const {container} = render(
<MemoryRouter initialEntries={['/dashboard']}><PreferencesProvider>
<Dashboard/>
</PreferencesProvider></MemoryRouter>,
);
await waitFor(() => expect(container.querySelector(`img[src="${avatar}"]`)).not.toBeNull());
expect(container.querySelector('img[src$="/1086940/library_hero.jpg"]')).not.toBeNull();
});
}); });