Public Access
feat(dashboard): Steam avatar and a banner from the most played game
This commit is contained in:
1 parent
2c360c9421
commit
23e502bf5b
11 files changed
+222
-37
No files matched your search
@@ -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">
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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)}/>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user