Public Access
feat(dashboard): pick the banner game or turn the banner off
This commit is contained in:
1 parent
23e502bf5b
commit
83494519f9
11 files changed
+137
-23
No files matched your search
+6
-2
@@ -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
@@ -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",
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in new issue
Block a user