feat(dashboard): pick the banner game or turn the banner off
Build, Test & Push Frontend / quality-check (push) Successful in 13s
Build, Test & Push Frontend / docker (push) Successful in 24s

This commit is contained in:
lobo committed 2026-09-24 15:35:48 +02:00
1 parent 23e502bf5b
commit 83494519f9
11 files changed
+137 -23

No files matched your search

+6 -2
View File
@@ -85,7 +85,10 @@
"WIDE_LAYOUT_HINT": "Nutzt mehr Platz auf breiten Bildschirmen.",
"BACKGROUND": "Hintergrund",
"BACKGROUND_HINT": "Öffne ein Spiel in deiner Bibliothek und wähle „Als Hintergrund“.",
"BACKGROUND_REMOVE": "Hintergrund entfernen"
"BACKGROUND_REMOVE": "Hintergrund entfernen",
"BANNER": "Dashboard-Banner",
"BANNER_HINT": "Zeigt dein meistgespieltes Spiel der letzten zwei Wochen. Öffne ein Spiel und wähle „Als Banner“, um eins festzulegen.",
"BANNER_AUTO": "Wieder automatisch"
},
"VALIDATION": {
"EMAIL_REQUIRED": "Email ist erforderlich.",
@@ -181,7 +184,8 @@
"TTB_NORMALLY": "+ Extras",
"TTB_COMPLETELY": "100 %",
"SET_BACKGROUND": "Als Hintergrund",
"REMOVE_BACKGROUND": "Hintergrund entfernen"
"REMOVE_BACKGROUND": "Hintergrund entfernen",
"SET_BANNER": "Als Banner"
},
"LANDING": {
"STATS_TITLE": "Community-Statistiken",
+6 -2
View File
@@ -85,7 +85,10 @@
"WIDE_LAYOUT_HINT": "Uses more of a wide screen.",
"BACKGROUND": "Background",
"BACKGROUND_HINT": "Open a game in your library and choose \"Use as background\".",
"BACKGROUND_REMOVE": "Remove background"
"BACKGROUND_REMOVE": "Remove background",
"BANNER": "Dashboard banner",
"BANNER_HINT": "Shows your most played game of the last two weeks. Open a game and choose \"Use as banner\" to pick one.",
"BANNER_AUTO": "Back to automatic"
},
"VALIDATION": {
"EMAIL_REQUIRED": "Email is required.",
@@ -181,7 +184,8 @@
"TTB_NORMALLY": "+ Extras",
"TTB_COMPLETELY": "100%",
"SET_BACKGROUND": "Use as background",
"REMOVE_BACKGROUND": "Remove background"
"REMOVE_BACKGROUND": "Remove background",
"SET_BANNER": "Use as banner"
},
"LANDING": {
"STATS_TITLE": "Community stats",
+3 -3
View File
@@ -42,7 +42,7 @@ describe('useSaveAppearance', () => {
const [url, request] = fetch.mock.calls[0];
expect(url).toContain('/api/users/settings/appearance');
expect(request.method).toBe('PUT');
expect(JSON.parse(request.body)).toEqual({accent: 'blue', corners: 'default', wideLayout: false, backgroundAppid: null});
expect(JSON.parse(request.body)).toEqual({accent: 'blue', corners: 'default', wideLayout: false, backgroundAppid: null, bannerAppid: null, showBanner: true});
});
it('puts the old value back when Auth refuses', async () => {
@@ -109,7 +109,7 @@ describe('useSaveAppearance', () => {
});
it('waits for a running load from Auth, then keeps its values for the fields not changed here', async () => {
const fromServer = {accent: 'green', corners: 'default', wideLayout: false, backgroundAppid: null};
const fromServer = {accent: 'green', corners: 'default', wideLayout: false, backgroundAppid: null, bannerAppid: 620, showBanner: false};
let answerMe;
globalThis.fetch = vi.fn((url, init) => (init?.method === 'PUT'
? Promise.resolve({ok: true, status: 204})
@@ -196,7 +196,7 @@ describe('useSaveAppearance', () => {
});
expect(result.current.preferences.appearance.corners).toBe('round');
expect(puts()[2]).toEqual({accent: 'green', corners: 'round', wideLayout: true, backgroundAppid: null});
expect(puts()[2]).toEqual({accent: 'green', corners: 'round', wideLayout: true, backgroundAppid: null, bannerAppid: null, showBanner: true});
});
it('sends nothing for a change that waited for the load when a reset came first', async () => {
+6 -2
View File
@@ -23,6 +23,7 @@ import HeroArt from '../components/HeroArt.jsx';
import SteamAvatar from '../components/SteamAvatar.jsx';
import useSteamProfile from '../hooks/useSteamProfile.js';
import {cn} from '@/lib/utils';
import {usePreferences} from '../context/PreferencesContext.jsx';
const TOP_GAMES = 5;
// The games endpoint accepts only the page sizes 20, 40 and 80.
@@ -189,8 +190,11 @@ const Dashboard = () => {
}, [navigate, expireSession, t, fetchSteamCount, fetchTopGames, fetchPlaying, fetchSummary, fetchActivity]);
const hasSteamId = !!userData?.steamId;
const {appearance} = usePreferences();
const steamProfile = useSteamProfile(hasSteamId);
const bannerAppid = activity?.recentGames?.[0]?.appid ?? null;
const bannerAppid = appearance.showBanner
? appearance.bannerAppid ?? activity?.recentGames?.[0]?.appid ?? null
: null;
const sync = useLibrarySync({
enabled: hasSteamId,
expectInitialSync: hasSteamId && steamData.count === 0,
@@ -217,7 +221,7 @@ const Dashboard = () => {
return (
<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">
{/* The banner is the game played most in the last two weeks; the gradient keeps the text readable. */}
{/* A picked game, else the game played most in the last two weeks; the gradient keeps the text readable. */}
<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
+25 -4
View File
@@ -48,8 +48,8 @@ describe('Dashboard', () => {
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';
const avatar = 'https://avatars.steamstatic.com/abc_full.jpg';
const mockDashboard = () => {
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});
@@ -58,13 +58,34 @@ describe('Dashboard', () => {
if (url.includes('/api/library/games-count')) return ok({count: 1, hasSteamId: true});
return ok({content: [], total: 0, byStatus: {}});
});
const {container} = render(
return render(
<MemoryRouter initialEntries={['/dashboard']}><PreferencesProvider>
<Dashboard/>
</PreferencesProvider></MemoryRouter>,
);
).container;
};
it('shows the Steam avatar and the most played game of the last two weeks as banner', async () => {
const container = mockDashboard();
await waitFor(() => expect(container.querySelector(`img[src="${avatar}"]`)).not.toBeNull());
expect(container.querySelector('img[src$="/1086940/library_hero.jpg"]')).not.toBeNull();
});
it('shows the picked banner game instead', async () => {
localStorage.setItem('tbp_appearance', JSON.stringify({bannerAppid: 620}));
const container = mockDashboard();
await waitFor(() => expect(container.querySelector(`img[src="${avatar}"]`)).not.toBeNull());
expect(container.querySelector('img[src$="/620/library_hero.jpg"]')).not.toBeNull();
expect(container.querySelector('img[src$="/1086940/library_hero.jpg"]')).toBeNull();
});
it('shows no banner when it is turned off', async () => {
localStorage.setItem('tbp_appearance', JSON.stringify({bannerAppid: 620, showBanner: false}));
const container = mockDashboard();
await waitFor(() => expect(container.querySelector(`img[src="${avatar}"]`)).not.toBeNull());
expect(container.querySelector('img[src*="library_hero"]')).toBeNull();
});
});
+12 -1
View File
@@ -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, Image as ImageIcon, ImageOff, ShieldOff, Trophy} from 'lucide-react';
import {ArrowLeft, Clock, ExternalLink, Image as ImageIcon, ImageOff, PanelTop, RotateCcw, ShieldOff, Trophy} from 'lucide-react';
import {Badge} from '@/components/ui/badge';
import {Button} from '@/components/ui/button';
@@ -99,6 +99,7 @@ export default function GameDetail() {
}, [data]);
const isBackground = !!data && appearance.backgroundAppid === data.appid;
const isBanner = !!data && appearance.showBanner && appearance.bannerAppid === data.appid;
const stats = data ? [
[t('GAME_DETAIL.UNLOCKED'), data.achievementsUnlocked],
@@ -161,6 +162,16 @@ export default function GameDetail() {
<ImageIcon/>{t('GAME_DETAIL.SET_BACKGROUND')}
</Button>
)}
{isBanner ? (
<Button variant="outline" size="sm" onClick={() => saveAppearance({bannerAppid: null})}>
<RotateCcw/>{t('SETTINGS.BANNER_AUTO')}
</Button>
) : (
<Button variant="outline" size="sm"
onClick={() => saveAppearance({bannerAppid: data.appid, showBanner: true})}>
<PanelTop/>{t('GAME_DETAIL.SET_BANNER')}
</Button>
)}
</div>
</div>
</Card>
+24
View File
@@ -56,4 +56,28 @@ describe('GameDetail', () => {
fireEvent.click(remove);
expect(await screen.findByRole('button', {name: 'GAME_DETAIL.SET_BACKGROUND'})).toBeTruthy();
});
it('uses the game as banner and goes back to the automatic banner', async () => {
renderDetail();
fireEvent.click(await screen.findByRole('button', {name: 'GAME_DETAIL.SET_BANNER'}));
const auto = await screen.findByRole('button', {name: 'SETTINGS.BANNER_AUTO'});
const [, init] = fetch.mock.calls.find(([, call]) => call?.method === 'PUT');
expect(JSON.parse(init.body)).toMatchObject({bannerAppid: 220, showBanner: true});
fireEvent.click(auto);
expect(await screen.findByRole('button', {name: 'GAME_DETAIL.SET_BANNER'})).toBeTruthy();
});
it('offers "use as banner" for the picked game while the banner is off, and turns it on', async () => {
localStorage.setItem('tbp_appearance', JSON.stringify({bannerAppid: 220, showBanner: false}));
renderDetail();
fireEvent.click(await screen.findByRole('button', {name: 'GAME_DETAIL.SET_BANNER'}));
await waitFor(() => expect(fetch.mock.calls.some(([, call]) => call?.method === 'PUT')).toBe(true));
const [, init] = fetch.mock.calls.find(([, call]) => call?.method === 'PUT');
expect(JSON.parse(init.body)).toMatchObject({bannerAppid: 220, showBanner: true});
});
});
+27 -2
View File
@@ -4,7 +4,7 @@ import {useTranslation} from 'react-i18next';
import {useForm} from 'react-hook-form';
import {zodResolver} from '@hookform/resolvers/zod';
import {Copy, Gamepad2, ImageOff, Loader2, Palette, Save, SlidersHorizontal, UserRound} from 'lucide-react';
import {Copy, Gamepad2, ImageOff, Loader2, Palette, RotateCcw, Save, SlidersHorizontal, UserRound} from 'lucide-react';
import {toast} from 'sonner';
import {Button} from '@/components/ui/button';
@@ -315,7 +315,7 @@ export default function SettingsPage() {
</div>
<div className="flex items-center justify-between gap-4">
<div className="space-y-1">
<Label>{t('SETTINGS.BACKGROUND')}</Label>
<Label id="background-label">{t('SETTINGS.BACKGROUND')}</Label>
{!appearance.backgroundAppid && (
<p className="text-xs text-muted-foreground">{t('SETTINGS.BACKGROUND_HINT')}</p>
)}
@@ -326,12 +326,37 @@ export default function SettingsPage() {
onError={(event) => { event.currentTarget.hidden = true; }}
className="h-10 w-20 rounded-md object-cover"/>
<Button type="button" variant="outline" size="sm"
aria-describedby="background-label"
onClick={() => saveAppearance({backgroundAppid: null})}>
<ImageOff/>{t('SETTINGS.BACKGROUND_REMOVE')}
</Button>
</div>
)}
</div>
<div className="space-y-3">
<div className="flex items-center justify-between gap-4">
<div className="space-y-1">
<Label id="banner-label" htmlFor="showBanner">{t('SETTINGS.BANNER')}</Label>
{appearance.showBanner && !appearance.bannerAppid && (
<p className="text-xs text-muted-foreground">{t('SETTINGS.BANNER_HINT')}</p>
)}
</div>
<Switch id="showBanner" checked={appearance.showBanner}
onCheckedChange={(showBanner) => saveAppearance({showBanner})}/>
</div>
{appearance.showBanner && appearance.bannerAppid && (
<div className="flex items-center justify-end gap-2">
<img src={coverUrl(appearance.bannerAppid)} alt=""
onError={(event) => { event.currentTarget.hidden = true; }}
className="h-10 w-20 rounded-md object-cover"/>
<Button type="button" variant="outline" size="sm"
aria-describedby="banner-label"
onClick={() => saveAppearance({bannerAppid: null})}>
<RotateCcw/>{t('SETTINGS.BANNER_AUTO')}
</Button>
</div>
)}
</div>
</CardContent>
</Card>
+15 -1
View File
@@ -94,7 +94,7 @@ describe('SettingsPage appearance', () => {
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(appearancePuts()[1]).toEqual({accent: 'green', corners: 'round', wideLayout: false, backgroundAppid: null, bannerAppid: null, showBanner: true});
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');
});
@@ -109,4 +109,18 @@ describe('SettingsPage appearance', () => {
expect(appearancePuts()[0].backgroundAppid).toBeNull();
expect(screen.getByText('SETTINGS.BACKGROUND_HINT')).toBeTruthy();
});
it('turns the banner off and goes back to the automatic banner', async () => {
localStorage.setItem('tbp_appearance', JSON.stringify({bannerAppid: 620}));
renderPage(profile());
fireEvent.click(await screen.findByRole('button', {name: 'SETTINGS.BANNER_AUTO'}));
await waitFor(() => expect(appearancePuts()).toHaveLength(1));
expect(appearancePuts()[0]).toMatchObject({bannerAppid: null, showBanner: true});
expect(screen.getByText('SETTINGS.BANNER_HINT')).toBeTruthy();
fireEvent.click(screen.getByRole('switch', {name: 'SETTINGS.BANNER'}));
await waitFor(() => expect(appearancePuts()).toHaveLength(2));
expect(appearancePuts()[1].showBanner).toBe(false);
});
});
+9 -2
View File
@@ -2,7 +2,12 @@
export const ACCENTS = ['violet', 'blue', 'green', 'orange', 'pink', 'red'];
export const CORNERS = ['sharp', 'default', 'round'];
export const DEFAULT_APPEARANCE = {accent: 'violet', corners: 'default', wideLayout: false, backgroundAppid: null};
// bannerAppid null: the dashboard banner shows the game played most in the last two weeks.
export const DEFAULT_APPEARANCE = {
accent: 'violet', corners: 'default', wideLayout: false, backgroundAppid: null, bannerAppid: null, showBanner: true,
};
const appid = (value) => (Number.isInteger(value) && value > 0 ? value : null);
const STORAGE_KEY = 'tbp_appearance';
const REQUEST_TIMEOUT_MS = 10000;
@@ -11,7 +16,9 @@ export const normalizeAppearance = (value) => ({
accent: ACCENTS.includes(value?.accent) ? value.accent : DEFAULT_APPEARANCE.accent,
corners: CORNERS.includes(value?.corners) ? value.corners : DEFAULT_APPEARANCE.corners,
wideLayout: value?.wideLayout === true,
backgroundAppid: Number.isInteger(value?.backgroundAppid) && value.backgroundAppid > 0 ? value.backgroundAppid : null,
backgroundAppid: appid(value?.backgroundAppid),
bannerAppid: appid(value?.bannerAppid),
showBanner: value?.showBanner !== false,
});
/**
+4 -4
View File
@@ -10,13 +10,13 @@ import {
describe('normalizeAppearance', () => {
it('keeps known values', () => {
const appearance = {accent: 'blue', corners: 'round', wideLayout: true, backgroundAppid: 620};
const appearance = {accent: 'blue', corners: 'round', wideLayout: true, backgroundAppid: 620, bannerAppid: 1086940, showBanner: false};
expect(normalizeAppearance(appearance)).toEqual(appearance);
});
it('replaces unknown or missing values with the defaults', () => {
expect(normalizeAppearance({accent: 'red;x', corners: 'pointy', wideLayout: 'yes', backgroundAppid: -1}))
expect(normalizeAppearance({accent: 'red;x', corners: 'pointy', wideLayout: 'yes', backgroundAppid: -1, bannerAppid: '620', showBanner: 'no'}))
.toEqual(DEFAULT_APPEARANCE);
expect(normalizeAppearance(null)).toEqual(DEFAULT_APPEARANCE);
expect(normalizeAppearance({backgroundAppid: 1.5})).toEqual(DEFAULT_APPEARANCE);
@@ -27,9 +27,9 @@ describe('appearance cache', () => {
afterEach(() => localStorage.clear());
it('reads back what was cached', () => {
cacheAppearance({accent: 'green', corners: 'sharp', wideLayout: false, backgroundAppid: null});
cacheAppearance({accent: 'green', corners: 'sharp', wideLayout: false, backgroundAppid: null, bannerAppid: 620, showBanner: true});
expect(readCachedAppearance()).toEqual({accent: 'green', corners: 'sharp', wideLayout: false, backgroundAppid: null});
expect(readCachedAppearance()).toEqual({accent: 'green', corners: 'sharp', wideLayout: false, backgroundAppid: null, bannerAppid: 620, showBanner: true});
});
it('falls back to the defaults for a broken or empty cache', () => {