diff --git a/public/i18n/de.json b/public/i18n/de.json index 73a9f26..7f171c5 100644 --- a/public/i18n/de.json +++ b/public/i18n/de.json @@ -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", diff --git a/public/i18n/en.json b/public/i18n/en.json index 0f2b195..748ae48 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -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", diff --git a/src/hooks/useSaveAppearance.test.jsx b/src/hooks/useSaveAppearance.test.jsx index 5768706..75c8e11 100644 --- a/src/hooks/useSaveAppearance.test.jsx +++ b/src/hooks/useSaveAppearance.test.jsx @@ -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 () => { diff --git a/src/pages/Dashboard.jsx b/src/pages/Dashboard.jsx index f348822..8a03006 100644 --- a/src/pages/Dashboard.jsx +++ b/src/pages/Dashboard.jsx @@ -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 ( - {/* 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. */}
{ 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( , - ); + ).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(); + }); }); diff --git a/src/pages/GameDetail.jsx b/src/pages/GameDetail.jsx index b23f9f4..cb6c11b 100644 --- a/src/pages/GameDetail.jsx +++ b/src/pages/GameDetail.jsx @@ -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() { {t('GAME_DETAIL.SET_BACKGROUND')} )} + {isBanner ? ( + + ) : ( + + )}
diff --git a/src/pages/GameDetail.test.jsx b/src/pages/GameDetail.test.jsx index 6925a5c..0ac50c2 100644 --- a/src/pages/GameDetail.test.jsx +++ b/src/pages/GameDetail.test.jsx @@ -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}); + }); }); diff --git a/src/pages/SettingsPage.jsx b/src/pages/SettingsPage.jsx index b603c9a..e9167b0 100644 --- a/src/pages/SettingsPage.jsx +++ b/src/pages/SettingsPage.jsx @@ -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() {
- + {!appearance.backgroundAppid && (

{t('SETTINGS.BACKGROUND_HINT')}

)} @@ -326,12 +326,37 @@ export default function SettingsPage() { onError={(event) => { event.currentTarget.hidden = true; }} className="h-10 w-20 rounded-md object-cover"/>
)}
+
+
+
+ + {appearance.showBanner && !appearance.bannerAppid && ( +

{t('SETTINGS.BANNER_HINT')}

+ )} +
+ saveAppearance({showBanner})}/> +
+ {appearance.showBanner && appearance.bannerAppid && ( +
+ { event.currentTarget.hidden = true; }} + className="h-10 w-20 rounded-md object-cover"/> + +
+ )} +
diff --git a/src/pages/SettingsPage.test.jsx b/src/pages/SettingsPage.test.jsx index 422f80c..bd0d072 100644 --- a/src/pages/SettingsPage.test.jsx +++ b/src/pages/SettingsPage.test.jsx @@ -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); + }); }); diff --git a/src/utils/appearance.js b/src/utils/appearance.js index fcb2b19..ea226a4 100644 --- a/src/utils/appearance.js +++ b/src/utils/appearance.js @@ -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, }); /** diff --git a/src/utils/appearance.test.js b/src/utils/appearance.test.js index c7c57b4..29f2224 100644 --- a/src/utils/appearance.test.js +++ b/src/utils/appearance.test.js @@ -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', () => {