feat(points): apply worn effects on covers, details and backgrounds
Build, Test & Push Frontend / quality-check (push) Successful in 28s
Build, Test & Push Frontend / docker (push) Successful in 27s

This commit is contained in:
lobo committed 2026-10-01 15:09:25 +02:00
1 parent dd45c0b5f6
commit 249973dd69
19 files changed
+317 -35

No files matched your search

+5 -2
View File
@@ -29,6 +29,7 @@ import {Toaster} from '@/components/ui/sonner';
import {TooltipProvider} from '@/components/ui/tooltip'; import {TooltipProvider} from '@/components/ui/tooltip';
import PageBackground from './components/PageBackground.jsx'; import PageBackground from './components/PageBackground.jsx';
import {usePreferences} from './context/PreferencesContext.jsx'; import {usePreferences} from './context/PreferencesContext.jsx';
import {backgroundEffect} from './utils/rewards.js';
import {clearSession} from './utils/auth.js'; import {clearSession} from './utils/auth.js';
import useSession from './hooks/useSession.js'; import useSession from './hooks/useSession.js';
import {UnsavedChangesGuard} from './hooks/useUnsavedChanges.js'; import {UnsavedChangesGuard} from './hooks/useUnsavedChanges.js';
@@ -37,7 +38,7 @@ import {logout} from './utils/api.js';
function App() { function App() {
const {t} = useTranslation(); const {t} = useTranslation();
const {active: isAuthenticated} = useSession(); const {active: isAuthenticated} = useSession();
const {appearance, syncPreferences} = usePreferences(); const {appearance, equipped, syncPreferences} = usePreferences();
// Hooks for routing // Hooks for routing
const navigate = useNavigate(); const navigate = useNavigate();
@@ -60,7 +61,9 @@ function App() {
{/* 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">
{/* A public profile shows the owner's background instead. */} {/* A public profile shows the owner's background instead. */}
{isAuthenticated && !location.pathname.startsWith('/u/') && <PageBackground appid={appearance.backgroundAppid}/>} {isAuthenticated && !location.pathname.startsWith('/u/') && (
<PageBackground appid={appearance.backgroundAppid} effect={backgroundEffect(equipped)}/>
)}
<AppHeader isAuthenticated={isAuthenticated} onLogout={handleLogout}/> <AppHeader isAuthenticated={isAuthenticated} onLogout={handleLogout}/>
<main className="grow flex flex-col"> <main className="grow flex flex-col">
+2 -2
View File
@@ -11,7 +11,7 @@ import PointsBadge from './PointsBadge.jsx';
import SteamAvatar from './SteamAvatar.jsx'; import SteamAvatar from './SteamAvatar.jsx';
import SyncStatusBadge from './SyncStatusBadge.jsx'; import SyncStatusBadge from './SyncStatusBadge.jsx';
import {useCurrentUserContext} from '../context/CurrentUserContext.jsx'; import {useCurrentUserContext} from '../context/CurrentUserContext.jsx';
import {lookClass} from '../utils/rewards.js'; import {bannerEffect, lookClass} from '../utils/rewards.js';
export default function DashboardWelcomeCard({bannerAppid, avatarUrl, sync, forceSync, editing, onCustomize, equipped}) { export default function DashboardWelcomeCard({bannerAppid, avatarUrl, sync, forceSync, editing, onCustomize, equipped}) {
const {t} = useTranslation(); const {t} = useTranslation();
@@ -23,7 +23,7 @@ export default function DashboardWelcomeCard({bannerAppid, avatarUrl, sync, forc
<Card className={cn('relative overflow-hidden p-6 md:p-8 flex flex-col md:flex-row md:items-center md:justify-between gap-6', <Card className={cn('relative overflow-hidden p-6 md:p-8 flex flex-col md:flex-row md:items-center md:justify-between gap-6',
lookClass(equipped, 'BANNER_FRAME'))}> lookClass(equipped, 'BANNER_FRAME'))}>
{/* A picked game, else the game played most in the last two weeks. */} {/* A picked game, else the game played most in the last two weeks. */}
<HeroBanner appid={bannerAppid}/> <HeroBanner appid={bannerAppid} effect={bannerEffect(equipped)}/>
<div className="relative flex min-w-0 items-center gap-4"> <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. */} {/* The slot is there before the avatar loads, so the title does not jump. */}
{hasSteamId && ( {hasSteamId && (
+18 -2
View File
@@ -12,10 +12,10 @@ afterEach(() => {
localStorage.clear(); localStorage.clear();
}); });
const renderCard = async (equipped) => { const renderCard = async (equipped, bannerAppid) => {
localStorage.setItem('tbp_session', '1'); localStorage.setItem('tbp_session', '1');
fetchRoutes({'GET /api/users/me': json(200, {displayName: 'Lobo', steamId: '1'}), 'GET /api/library/hero-art': json(200, {})}); fetchRoutes({'GET /api/users/me': json(200, {displayName: 'Lobo', steamId: '1'}), 'GET /api/library/hero-art': json(200, {})});
renderWithProviders(<DashboardWelcomeCard sync={{}} forceSync={{}} equipped={equipped}/>); renderWithProviders(<DashboardWelcomeCard bannerAppid={bannerAppid} sync={{}} forceSync={{}} equipped={equipped}/>);
await screen.findByText(/Lobo/); await screen.findByText(/Lobo/);
}; };
@@ -29,6 +29,22 @@ describe('DashboardWelcomeCard looks', () => {
expect(screen.getByText('POINTS.ITEMS.BADGE_COMPLETIONIST')).toBeTruthy(); expect(screen.getByText('POINTS.ITEMS.BADGE_COMPLETIONIST')).toBeTruthy();
}); });
it('shows a worn banner background instead of the game art', async () => {
await renderCard({BANNER_BACKGROUND: 'BN_GRID'}, 620);
const card = screen.getByText(/Lobo/).closest('[data-slot="card"]');
expect(card.querySelector('[data-effect="grid"]')).not.toBeNull();
expect(card.querySelector('img[src*="library_hero"]')).toBeNull();
});
it('shows the game art without a worn banner background', async () => {
await renderCard({}, 620);
const card = screen.getByText(/Lobo/).closest('[data-slot="card"]');
expect(card.querySelector('[data-effect]')).toBeNull();
expect(card.querySelector('img[src*="library_hero"]')).not.toBeNull();
});
it('keeps the default avatar ring without a worn border', async () => { it('keeps the default avatar ring without a worn border', async () => {
await renderCard({}); await renderCard({});
+8 -3
View File
@@ -1,23 +1,28 @@
import {useState} from 'react'; import {useRef, useState} from 'react';
import {Gamepad2} from 'lucide-react'; import {Gamepad2} from 'lucide-react';
import {cn} from '@/lib/utils'; import {cn} from '@/lib/utils';
import {coverUrl} from '@/utils/games.js'; import {coverUrl} from '@/utils/games.js';
import {COVER, reportBrokenImage} from '@/utils/brokenImages.js'; import {COVER, reportBrokenImage} from '@/utils/brokenImages.js';
import useInView from '../hooks/useInView.js';
/** /**
* Steam store header (460x215). Falls back to a neutral tile when the app has no header image. * Steam store header (460x215). Falls back to a neutral tile when the app has no header image.
* *
* @param imageUrl a game added by hand from IGDB, or a Steam header Core repaired; wins over the fixed Steam header * @param imageUrl a game added by hand from IGDB, or a Steam header Core repaired; wins over the fixed Steam header
* @param fx the classes of a completed effect (utils/rewards.js coverEffect); it holds still while the cover is off screen
*/ */
export default function GameCover({appid, imageUrl, className}) { export default function GameCover({appid, imageUrl, fx, className}) {
const src = imageUrl ?? coverUrl(appid); const src = imageUrl ?? coverUrl(appid);
// Per URL: a reused cover (e.g. the next suggestion) loads and reports its own picture. // Per URL: a reused cover (e.g. the next suggestion) loads and reports its own picture.
const [failedSrc, setFailedSrc] = useState(null); const [failedSrc, setFailedSrc] = useState(null);
const failed = failedSrc === src; const failed = failedSrc === src;
const ref = useRef(null);
const inView = useInView(ref, !!fx);
return ( return (
<div className={cn('aspect-[460/215] overflow-hidden rounded-md bg-muted', className)}> <div ref={ref}
className={cn('relative aspect-[460/215] overflow-hidden rounded-md bg-muted', fx, fx && !inView && 'fx-paused', className)}>
{failed ? ( {failed ? (
<div className="flex size-full items-center justify-center text-muted-foreground"> <div className="flex size-full items-center justify-center text-muted-foreground">
<Gamepad2 className="size-1/3 max-h-8"/> <Gamepad2 className="size-1/3 max-h-8"/>
+68 -2
View File
@@ -1,6 +1,6 @@
// @vitest-environment jsdom // @vitest-environment jsdom
import {afterEach, describe, expect, it, vi} from 'vitest'; import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {cleanup, fireEvent, render} from '@testing-library/react'; import {act, cleanup, fireEvent, render} from '@testing-library/react';
import GameCover from './GameCover.jsx'; import GameCover from './GameCover.jsx';
import {reportBrokenImage} from '../utils/brokenImages.js'; import {reportBrokenImage} from '../utils/brokenImages.js';
@@ -8,6 +8,7 @@ vi.mock('../utils/brokenImages.js', () => ({COVER: 'COVER', reportBrokenImage: v
afterEach(() => { afterEach(() => {
cleanup(); cleanup();
vi.unstubAllGlobals();
vi.clearAllMocks(); vi.clearAllMocks();
}); });
@@ -38,4 +39,69 @@ describe('GameCover', () => {
fireEvent.error(container.querySelector('img')); fireEvent.error(container.querySelector('img'));
expect(reportBrokenImage).toHaveBeenCalledWith(220, 'COVER'); expect(reportBrokenImage).toHaveBeenCalledWith(220, 'COVER');
}); });
describe('completed effect', () => {
const FX = 'fx fx-cover-gold-shine';
let callbacks;
let observers;
beforeEach(() => {
callbacks = [];
observers = [];
vi.stubGlobal('IntersectionObserver', class {
constructor(callback) {
callbacks.push(callback);
observers.push(this);
}
observe = vi.fn();
unobserve = vi.fn();
disconnect = vi.fn();
});
});
const tell = (target, isIntersecting) => act(() => callbacks[0]([{target, isIntersecting}]));
it('animates on screen, holds its frame off screen and goes on when it comes back', () => {
const {container} = render(<GameCover appid={620} fx={FX}/>);
const root = container.firstChild;
expect(root.classList.contains('fx-cover-gold-shine')).toBe(true);
expect(root.classList.contains('fx-paused')).toBe(false);
tell(root, false);
expect(root.classList.contains('fx-paused')).toBe(true);
tell(root, true);
expect(root.classList.contains('fx-paused')).toBe(false);
});
it('watches every cover with one shared observer', () => {
const {container} = render(<><GameCover appid={620} fx={FX}/><GameCover appid={220} fx={FX}/></>);
expect(observers).toHaveLength(1);
expect(observers[0].observe).toHaveBeenCalledTimes(2);
expect(observers[0].observe).toHaveBeenCalledWith(container.children[0]);
});
it('stops watching when the cover goes', () => {
const {unmount} = render(<GameCover appid={620} fx={FX}/>);
unmount();
expect(observers[0].unobserve).toHaveBeenCalledTimes(1);
expect(observers[0].disconnect).toHaveBeenCalledTimes(1);
});
it('does not watch a cover without an effect', () => {
render(<GameCover appid={620}/>);
expect(observers).toHaveLength(0);
});
it('animates where the browser has no IntersectionObserver', () => {
vi.stubGlobal('IntersectionObserver', undefined);
const {container} = render(<GameCover appid={620} fx={FX}/>);
expect(container.firstChild.classList.contains('fx-paused')).toBe(false);
});
});
}); });
+14 -3
View File
@@ -6,6 +6,7 @@ import {toast} from 'sonner';
import {Badge} from '@/components/ui/badge'; import {Badge} from '@/components/ui/badge';
import {Button} from '@/components/ui/button'; import {Button} from '@/components/ui/button';
import {Card} from '@/components/ui/card'; import {Card} from '@/components/ui/card';
import {cn} from '@/lib/utils';
import GameCover from './GameCover.jsx'; import GameCover from './GameCover.jsx';
import GameStatusBadge from './GameStatusBadge.jsx'; import GameStatusBadge from './GameStatusBadge.jsx';
import ProfileArtMenu from './ProfileArtMenu.jsx'; import ProfileArtMenu from './ProfileArtMenu.jsx';
@@ -16,12 +17,15 @@ import {formatPlaytime} from '../utils/games.js';
import {setGameHidden} from '../utils/hiddenGames.js'; import {setGameHidden} from '../utils/hiddenGames.js';
import {isSteamKey} from '../utils/manualGames.js'; import {isSteamKey} from '../utils/manualGames.js';
import {platformLabel} from '../utils/platforms.js'; import {platformLabel} from '../utils/platforms.js';
import {coverEffect, detailEffect} from '../utils/rewards.js';
const SPEEDRUN_PREFIX = 'https://www.speedrun.com/'; const SPEEDRUN_PREFIX = 'https://www.speedrun.com/';
// The spans of each completed effect decoration; effects.css places them.
const DETAIL_ELEMENTS = {'fx-detail-gold-shine': 8, 'fx-detail-neon-pulse': 1, 'fx-detail-holo': 1, 'fx-detail-ember': 6};
export default function GameHeroCard({game, manual, auditor, profileStyle, onCorrect, onMerge, onHiddenChanged, onProfileStyleSaved}) { export default function GameHeroCard({game, manual, auditor, profileStyle, onCorrect, onMerge, onHiddenChanged, onProfileStyleSaved}) {
const {t} = useTranslation(); const {t} = useTranslation();
const {appearance} = usePreferences(); const {appearance, equipped} = usePreferences();
const saveAppearance = useSaveAppearance(); const saveAppearance = useSaveAppearance();
const [hiding, setHiding] = useState(false); const [hiding, setHiding] = useState(false);
@@ -31,6 +35,7 @@ export default function GameHeroCard({game, manual, auditor, profileStyle, onCor
? game.speedrunUrl : null; ? game.speedrunUrl : null;
const isBackground = appearance.backgroundAppid === game.appid; const isBackground = appearance.backgroundAppid === game.appid;
const isBanner = appearance.showBanner && appearance.bannerAppid === game.appid; const isBanner = appearance.showBanner && appearance.bannerAppid === game.appid;
const detailFx = detailEffect(equipped, game.status);
const toggleHidden = async () => { const toggleHidden = async () => {
const hidden = !game.hidden; const hidden = !game.hidden;
@@ -54,8 +59,14 @@ export default function GameHeroCard({game, manual, auditor, profileStyle, onCor
return ( return (
<Card className="relative overflow-hidden p-0 gap-0 md:flex-row"> <Card className="relative overflow-hidden p-0 gap-0 md:flex-row">
<GameCover appid={game.appid} imageUrl={game.imageUrl} className="w-full rounded-none md:w-96 md:shrink-0"/> {detailFx && (
<div className="flex flex-1 flex-col justify-between gap-4 p-6"> <div aria-hidden="true" className={cn('fx pointer-events-none absolute inset-0', detailFx)}>
{Array.from({length: DETAIL_ELEMENTS[detailFx]}, (_, i) => <span key={i}/>)}
</div>
)}
<GameCover appid={game.appid} imageUrl={game.imageUrl} fx={coverEffect(equipped, game.status)}
className="w-full rounded-none md:w-96 md:shrink-0"/>
<div className="relative flex flex-1 flex-col justify-between gap-4 p-6">
<div> <div>
<h1 className="text-2xl md:text-3xl font-bold tracking-tight"> <h1 className="text-2xl md:text-3xl font-bold tracking-tight">
{game.name ?? `App ${game.appid}`} {game.name ?? `App ${game.appid}`}
+7
View File
@@ -27,6 +27,13 @@ describe('PageBackground', () => {
expect(container.innerHTML).toBe(''); expect(container.innerHTML).toBe('');
}); });
it('shows a worn animated background instead of the art', () => {
const {container} = render(<PageBackground appid={620} effect="aurora"/>);
expect(container.querySelector('[data-effect="aurora"]')).not.toBeNull();
expect(container.querySelector('img')).toBeNull();
});
it('starts with the library art again for another game', () => { it('starts with the library art again for another game', () => {
const {container, rerender} = render(<PageBackground appid={620}/>); const {container, rerender} = render(<PageBackground appid={620}/>);
fireEvent.error(container.querySelector('img')); fireEvent.error(container.querySelector('img'));
+8 -3
View File
@@ -1,15 +1,20 @@
import {cn} from '@/lib/utils'; import {cn} from '@/lib/utils';
import EffectBackground from './EffectBackground.jsx';
import HeroArt from './HeroArt.jsx'; import HeroArt from './HeroArt.jsx';
/** /**
* The art of a game behind a card's content, or the accent gradient without one. Put it first in a card with * The art of a game behind a card's content, or the accent gradient without one. Put it first in a card with
* `relative overflow-hidden`; the gradient keeps the text readable. * `relative overflow-hidden`; the gradient keeps the text readable.
*
* @param effect a worn banner background (utils/rewards.js bannerEffect); it replaces the art
*/ */
export default function HeroBanner({appid}) { export default function HeroBanner({appid, effect}) {
return ( return (
<> <>
<HeroArt key={appid} appid={appid} className="pointer-events-none absolute inset-0 size-full object-cover opacity-60"/> {effect
<div className={cn('pointer-events-none absolute inset-0', appid ? <EffectBackground effect={effect} className="absolute inset-0"/>
: <HeroArt key={appid} appid={appid} className="pointer-events-none absolute inset-0 size-full object-cover opacity-60"/>}
<div className={cn('pointer-events-none absolute inset-0', effect || appid
? 'bg-gradient-to-r from-card via-card/85 to-card/60' ? 'bg-gradient-to-r from-card via-card/85 to-card/60'
: 'bg-gradient-to-br from-primary/15 via-transparent to-transparent')}/> : 'bg-gradient-to-br from-primary/15 via-transparent to-transparent')}/>
</> </>
+4 -2
View File
@@ -6,8 +6,9 @@ import {Button} from '@/components/ui/button';
import {Card} from '@/components/ui/card'; import {Card} from '@/components/ui/card';
import GameCover from './GameCover.jsx'; import GameCover from './GameCover.jsx';
import {formatPlaytime} from '../utils/games.js'; import {formatPlaytime} from '../utils/games.js';
import {coverEffect} from '../utils/rewards.js';
export default function MostPlayedCard({games, syncing}) { export default function MostPlayedCard({games, syncing, equipped}) {
const {t} = useTranslation(); const {t} = useTranslation();
const maxMinutes = games[0]?.playtimeMinutes || 1; const maxMinutes = games[0]?.playtimeMinutes || 1;
return ( return (
@@ -37,7 +38,8 @@ export default function MostPlayedCard({games, syncing}) {
<span className="w-4 text-right text-sm font-semibold text-muted-foreground tabular-nums"> <span className="w-4 text-right text-sm font-semibold text-muted-foreground tabular-nums">
{index + 1} {index + 1}
</span> </span>
<GameCover appid={g.appid} imageUrl={g.imageUrl} className="w-16 sm:w-24 shrink-0"/> <GameCover appid={g.appid} imageUrl={g.imageUrl} fx={coverEffect(equipped, g.status)}
className="w-16 sm:w-24 shrink-0"/>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="truncate font-medium group-hover:text-primary transition-colors"> <div className="truncate font-medium group-hover:text-primary transition-colors">
{g.name ?? `App ${g.appid}`} {g.name ?? `App ${g.appid}`}
+12 -2
View File
@@ -1,9 +1,19 @@
import EffectBackground from './EffectBackground.jsx';
import HeroArt from './HeroArt.jsx'; import HeroArt from './HeroArt.jsx';
/** /**
* Game art behind the whole page, blurred and dimmed so text on it stays readable. * A worn animated background (utils/rewards.js backgroundEffect) or the game art behind the whole page, dimmed
* (and the art blurred) so text on it stays readable. The effect wins over the art.
*/ */
export default function PageBackground({appid}) { export default function PageBackground({appid, effect}) {
if (effect) {
return (
<>
<EffectBackground effect={effect} className="fixed inset-0 -z-10"/>
<div aria-hidden="true" className="pointer-events-none fixed inset-0 -z-10 bg-background/40"/>
</>
);
}
if (!appid) return null; if (!appid) return null;
return ( return (
+12 -4
View File
@@ -12,7 +12,7 @@ import {
import {hasSession} from '../utils/auth.js'; import {hasSession} from '../utils/auth.js';
import useSession from '../hooks/useSession.js'; import useSession from '../hooks/useSession.js';
import {LANGUAGES, saveSettings} from '../utils/settings.js'; import {LANGUAGES, saveSettings} from '../utils/settings.js';
import {equippedAccent, loadWallet} from '../utils/rewards.js'; import {cacheEquipped, equippedAccent, loadWallet, readCachedEquipped} from '../utils/rewards.js';
import {storage} from '../utils/storage.js'; import {storage} from '../utils/storage.js';
const DARK_MODE_STORAGE_KEY = 'tbp_dark_mode'; const DARK_MODE_STORAGE_KEY = 'tbp_dark_mode';
@@ -52,17 +52,23 @@ export function PreferencesProvider({children}) {
// Pile Points: balance, shop and worn looks of Core; null until loaded. // Pile Points: balance, shop and worn looks of Core; null until loaded.
const [wallet, setWallet] = useState(null); const [wallet, setWallet] = useState(null);
// The worn looks: the cached ones until the wallet answers.
const [equipped, setEquipped] = useState(readCachedEquipped);
// Layout effect: the cached look is set before the first paint. // Layout effect: the cached look is set before the first paint.
useLayoutEffect(() => { useLayoutEffect(() => {
applyAppearance(appearance, equippedAccent(wallet?.equipped)); applyAppearance(appearance, equippedAccent(equipped));
cacheAppearance(appearance); cacheAppearance(appearance);
}, [appearance, wallet]); cacheEquipped(equipped);
}, [appearance, equipped]);
const reloadWallet = useCallback(async () => { const reloadWallet = useCallback(async () => {
const started = generation.current; const started = generation.current;
const {ok, data} = await loadWallet(); const {ok, data} = await loadWallet();
if (ok && generation.current === started) setWallet(data); if (ok && generation.current === started) {
setWallet(data);
setEquipped(data.equipped ?? {});
}
}, []); }, []);
// Grows on every language pick, so a load that started before does not undo it. // Grows on every language pick, so a load that started before does not undo it.
@@ -136,6 +142,7 @@ export function PreferencesProvider({children}) {
savedAppearance.current = DEFAULT_APPEARANCE; savedAppearance.current = DEFAULT_APPEARANCE;
setAppearanceState(DEFAULT_APPEARANCE); setAppearanceState(DEFAULT_APPEARANCE);
setWallet(null); setWallet(null);
setEquipped({});
}, []); }, []);
// Without a session the next user must not get this look. // Without a session the next user must not get this look.
@@ -166,6 +173,7 @@ export function PreferencesProvider({children}) {
appearance, appearance,
changeAppearance, changeAppearance,
wallet, wallet,
equipped,
reloadWallet, reloadWallet,
syncPreferences, syncPreferences,
resetAppearance, resetAppearance,
+29
View File
@@ -109,3 +109,32 @@ describe('PreferencesProvider theme color', () => {
expect(themeColor()).toBe('#09090b'); expect(themeColor()).toBe('#09090b');
}); });
}); });
describe('PreferencesProvider worn looks', () => {
it('has the cached worn looks before the wallet answers and caches the answer', async () => {
localStorage.setItem('tbp_session', '1');
localStorage.setItem('tbp_equipped', JSON.stringify({PAGE_BACKGROUND: 'BG_GRID'}));
fetchRoutes({
'GET /api/users/me': json(200, {displayName: 'Lobo', settings: {}}),
'GET /api/rewards/wallet': json(200, {balance: 0, catalog: [], owned: [], equipped: {PAGE_BACKGROUND: 'BG_MESH'}, history: []}),
});
render(<PreferencesProvider><Spy/></PreferencesProvider>);
expect(preferences.wallet).toBeNull();
expect(preferences.equipped).toEqual({PAGE_BACKGROUND: 'BG_GRID'});
await act(() => preferences.syncPreferences());
expect(preferences.equipped).toEqual({PAGE_BACKGROUND: 'BG_MESH'});
expect(JSON.parse(localStorage.getItem('tbp_equipped'))).toEqual({PAGE_BACKGROUND: 'BG_MESH'});
});
it('ignores a damaged cache', () => {
localStorage.setItem('tbp_session', '1');
localStorage.setItem('tbp_equipped', '[1]');
render(<PreferencesProvider><Spy/></PreferencesProvider>);
expect(preferences.equipped).toEqual({});
});
});
+4 -3
View File
@@ -60,7 +60,7 @@ const Dashboard = () => {
const hasSteamId = !!userData?.steamId; const hasSteamId = !!userData?.steamId;
const showLibrary = hasSteamId || steamData.count > 0; const showLibrary = hasSteamId || steamData.count > 0;
const {appearance, wallet} = usePreferences(); const {appearance, equipped} = usePreferences();
const steamProfile = useSteamProfile(hasSteamId); const steamProfile = useSteamProfile(hasSteamId);
const bannerAppid = appearance.showBanner const bannerAppid = appearance.showBanner
? appearance.bannerAppid ?? activity?.recentGames?.[0]?.appid ?? null ? appearance.bannerAppid ?? activity?.recentGames?.[0]?.appid ?? null
@@ -99,7 +99,8 @@ const Dashboard = () => {
switch (id) { switch (id) {
case 'GAME_COUNT': return showLibrary && <SteamGameCount targetCount={steamData.count}/>; case 'GAME_COUNT': return showLibrary && <SteamGameCount targetCount={steamData.count}/>;
case 'MOST_PLAYED': case 'MOST_PLAYED':
return showLibrary && <MostPlayedCard games={topGames.slice(0, TOP_GAMES[size])} syncing={sync.isRunning}/>; return showLibrary && <MostPlayedCard games={topGames.slice(0, TOP_GAMES[size])} syncing={sync.isRunning}
equipped={equipped}/>;
case 'PLAYING': case 'PLAYING':
return showLibrary && ( return showLibrary && (
<CurrentlyPlayingCard games={playing.slice(0, PLAYING_GAMES[size])} syncing={sync.isRunning} wide={size === 'L'}/> <CurrentlyPlayingCard games={playing.slice(0, PLAYING_GAMES[size])} syncing={sync.isRunning} wide={size === 'L'}/>
@@ -127,7 +128,7 @@ const Dashboard = () => {
return ( return (
<PageContainer className="p-4 md:p-8 space-y-6"> <PageContainer className="p-4 md:p-8 space-y-6">
<DashboardWelcomeCard bannerAppid={bannerAppid} avatarUrl={steamProfile?.avatarUrl} <DashboardWelcomeCard bannerAppid={bannerAppid} avatarUrl={steamProfile?.avatarUrl}
equipped={wallet?.equipped} equipped={equipped}
sync={sync} forceSync={forceSync} editing={editing} onCustomize={() => setEditing(true)}/> sync={sync} forceSync={forceSync} editing={editing} onCustomize={() => setEditing(true)}/>
<TileGrid tiles={DASHBOARD_TILES} titles={DASHBOARD_TITLES} layout={layout} renderTile={renderTile} editing={editing} <TileGrid tiles={DASHBOARD_TILES} titles={DASHBOARD_TITLES} layout={layout} renderTile={renderTile} editing={editing}
+52
View File
@@ -0,0 +1,52 @@
// @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {act, cleanup, screen, waitFor} from '@testing-library/react';
import GameDetail from './GameDetail.jsx';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
import {renderWithProviders} from '../test/renderWithProviders.jsx';
import WalletLoader from '../test/WalletLoader.jsx';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
const game = {
appid: 220, name: 'Half-Life 2', playtimeMinutes: 100, playtime2Weeks: 0, source: 'STEAM',
rating: null, notes: null, priority: null, hasAchievements: false, statsVisible: false, achievements: [],
};
const renderDetail = (status) => {
fetchRoutes({
'GET /api/users/me': json(200, {settings: {}}),
'GET /api/rewards/wallet': json(200, {balance: 0, catalog: [], owned: [], equipped: {COMPLETED_EFFECT: 'FX_GOLD_SHINE'}, history: []}),
'GET /api/library/games/220': json(200, {...game, status}),
});
return renderWithProviders(<><WalletLoader/><GameDetail/></>, {path: '/library/:appid', entries: ['/library/220']});
};
beforeEach(() => localStorage.setItem('tbp_session', '1'));
afterEach(() => {
cleanup();
localStorage.clear();
});
describe('GameDetail completed effect', () => {
it('decorates the card of a COMPLETED game with the worn effect and its cover', async () => {
const {container} = renderDetail('COMPLETED');
await waitFor(() => expect(container.querySelector('.fx-detail-gold-shine')).not.toBeNull());
const decoration = container.querySelector('.fx-detail-gold-shine');
expect(decoration.getAttribute('aria-hidden')).toBe('true');
expect(decoration.children).toHaveLength(8);
expect(container.querySelector('.fx-cover-gold-shine')).not.toBeNull();
});
it('shows nothing for a FINISHED game', async () => {
const {container} = renderDetail('FINISHED');
await screen.findByText('Half-Life 2');
await waitFor(() => expect(fetch.mock.calls.some(([url]) => url.includes('/rewards/wallet'))).toBe(true));
await act(async () => {});
expect(container.querySelector('.fx-detail-gold-shine')).toBeNull();
expect(container.querySelector('.fx-cover-gold-shine')).toBeNull();
});
});
+42
View File
@@ -0,0 +1,42 @@
// @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {cleanup, screen, waitFor} from '@testing-library/react';
import GameLibrary from './GameLibrary.jsx';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
import {renderWithProviders} from '../test/renderWithProviders.jsx';
import WalletLoader from '../test/WalletLoader.jsx';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
vi.mock('../hooks/useLibrarySync.js', () => ({default: () => ({status: 'OK', lastSyncedAt: null, isRunning: false})}));
vi.mock('../hooks/useForceSync.js', () => ({default: () => ({trigger: vi.fn(), busy: false})}));
const games = [
{appid: 620, name: 'Portal 2', playtimeMinutes: 0, status: 'COMPLETED'},
{appid: 220, name: 'Half-Life 2', playtimeMinutes: 0, status: 'FINISHED'},
];
beforeEach(() => {
localStorage.setItem('tbp_session', '1');
fetchRoutes({
'GET /api/users/me': json(200, {settings: {}}),
'GET /api/rewards/wallet': json(200, {balance: 0, catalog: [], owned: [], equipped: {COMPLETED_EFFECT: 'FX_GOLD_SHINE'}, history: []}),
'GET /api/library/games': json(200, {content: games, page: 0, size: 20, totalElements: 2, totalPages: 1}),
'GET /api/library/games-count': json(200, {count: 2, hasSteamId: true}),
});
});
afterEach(() => {
cleanup();
localStorage.clear();
});
describe('GameLibrary completed effect', () => {
it('puts the worn effect on the cover of a COMPLETED game only', async () => {
renderWithProviders(<><WalletLoader/><GameLibrary/></>, {entries: ['/library']});
const cover = async (name) => (await screen.findByText(name)).closest('li').querySelector('img').parentElement;
await waitFor(async () => expect((await cover('Portal 2')).classList.contains('fx-cover-gold-shine')).toBe(true));
expect((await cover('Half-Life 2')).classList.contains('fx-cover-gold-shine')).toBe(false);
});
});
+5 -1
View File
@@ -20,6 +20,8 @@ import GameCover from '../components/GameCover.jsx';
import LibraryTabs from '../components/LibraryTabs.jsx'; import LibraryTabs from '../components/LibraryTabs.jsx';
import GameStatusBadge from '../components/GameStatusBadge.jsx'; import GameStatusBadge from '../components/GameStatusBadge.jsx';
import PageContainer from '../components/PageContainer.jsx'; import PageContainer from '../components/PageContainer.jsx';
import {usePreferences} from '../context/PreferencesContext.jsx';
import {coverEffect} from '../utils/rewards.js';
import {apiFetch, request} from '../utils/api.js'; import {apiFetch, request} from '../utils/api.js';
import useAbortPrevious from '../hooks/useAbortPrevious.js'; import useAbortPrevious from '../hooks/useAbortPrevious.js';
import {platformLabel} from '../utils/platforms.js'; import {platformLabel} from '../utils/platforms.js';
@@ -41,6 +43,7 @@ const SORT_OPTIONS = [
export default function GameLibrary() { export default function GameLibrary() {
const {t} = useTranslation(); const {t} = useTranslation();
const {equipped} = usePreferences();
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const page = Math.max(0, parseInt(searchParams.get('page') ?? '0', 10) || 0); const page = Math.max(0, parseInt(searchParams.get('page') ?? '0', 10) || 0);
@@ -286,7 +289,8 @@ export default function GameLibrary() {
to={`/library/${g.appid}`} to={`/library/${g.appid}`}
className="group flex items-center gap-3 sm:gap-4 px-4 sm:px-5 py-3 transition-colors hover:bg-muted/50" className="group flex items-center gap-3 sm:gap-4 px-4 sm:px-5 py-3 transition-colors hover:bg-muted/50"
> >
<GameCover appid={g.appid} imageUrl={g.imageUrl} className="w-16 sm:w-24 shrink-0"/> <GameCover appid={g.appid} imageUrl={g.imageUrl} fx={coverEffect(equipped, g.status)}
className="w-16 sm:w-24 shrink-0"/>
<span className="min-w-0 flex-1 truncate font-medium group-hover:text-primary transition-colors"> <span className="min-w-0 flex-1 truncate font-medium group-hover:text-primary transition-colors">
{g.name ?? `App ${g.appid}`} {g.name ?? `App ${g.appid}`}
</span> </span>
+7 -6
View File
@@ -10,7 +10,7 @@ import {cn} from '@/lib/utils';
import GameCover from '../components/GameCover.jsx'; import GameCover from '../components/GameCover.jsx';
import GameStatusBadge, {STATUS_META} from '../components/GameStatusBadge.jsx'; import GameStatusBadge, {STATUS_META} from '../components/GameStatusBadge.jsx';
import {formatDay} from '../utils/format.js'; import {formatDay} from '../utils/format.js';
import {equippedAccent, lookClass} from '../utils/rewards.js'; import {backgroundEffect, bannerEffect, coverEffect, equippedAccent, lookClass} from '../utils/rewards.js';
import PointsBadge from '../components/PointsBadge.jsx'; import PointsBadge from '../components/PointsBadge.jsx';
import {GAME_STATUSES} from '../utils/limits.js'; import {GAME_STATUSES} from '../utils/limits.js';
import {formatPlaytime} from '../utils/games.js'; import {formatPlaytime} from '../utils/games.js';
@@ -135,7 +135,7 @@ export default function PublicProfilePage() {
return library.playing?.length > 0 return library.playing?.length > 0
&& <Playing games={library.playing.slice(0, PLAYING_GAMES[size])} compact={size === 'S'}/>; && <Playing games={library.playing.slice(0, PLAYING_GAMES[size])} compact={size === 'S'}/>;
case 'FINISHED': case 'FINISHED':
return library.finished?.length > 0 && <Finished games={library.finished.slice(0, FINISHED_GAMES[size])}/>; return library.finished?.length > 0 && <Finished games={library.finished.slice(0, FINISHED_GAMES[size])} equipped={equipped}/>;
default: return statGroupTile(id, library.stats, false); default: return statGroupTile(id, library.stats, false);
} }
}; };
@@ -153,10 +153,10 @@ export default function PublicProfilePage() {
// The owner's accent and corners apply to this page only; light or dark mode stays the viewer's. // The owner's accent and corners apply to this page only; light or dark mode stays the viewer's.
return ( return (
<div data-accent={equippedAccent(equipped) ?? style.accent} data-corners={style.corners}> <div data-accent={equippedAccent(equipped) ?? style.accent} data-corners={style.corners}>
<PageBackground appid={style.backgroundAppid}/> <PageBackground appid={style.backgroundAppid} effect={backgroundEffect(equipped)}/>
<PageContainer className="p-4 md:p-8 space-y-6"> <PageContainer className="p-4 md:p-8 space-y-6">
<Card className={cn('relative overflow-hidden p-6 md:p-8', lookClass(equipped, 'BANNER_FRAME'))}> <Card className={cn('relative overflow-hidden p-6 md:p-8', lookClass(equipped, 'BANNER_FRAME'))}>
<HeroBanner appid={style.bannerAppid}/> <HeroBanner appid={style.bannerAppid} effect={bannerEffect(equipped)}/>
<div className="relative"> <div className="relative">
<p className="text-sm font-medium text-muted-foreground">{t('PROFILE.TITLE')}</p> <p className="text-sm font-medium text-muted-foreground">{t('PROFILE.TITLE')}</p>
<h1 className="mt-1 text-3xl md:text-4xl font-bold tracking-tight break-words">{profile.displayName}</h1> <h1 className="mt-1 text-3xl md:text-4xl font-bold tracking-tight break-words">{profile.displayName}</h1>
@@ -247,7 +247,7 @@ function Playing({games, compact}) {
); );
} }
function Finished({games}) { function Finished({games, equipped}) {
const {t} = useTranslation(); const {t} = useTranslation();
return ( return (
<Card className="gap-0 py-0"> <Card className="gap-0 py-0">
@@ -257,7 +257,8 @@ function Finished({games}) {
<ul className="divide-y"> <ul className="divide-y">
{games.map((g) => ( {games.map((g) => (
<li key={g.appid} className="flex items-center gap-3 sm:gap-4 px-4 sm:px-5 py-3"> <li key={g.appid} className="flex items-center gap-3 sm:gap-4 px-4 sm:px-5 py-3">
<GameCover appid={g.appid} imageUrl={g.imageUrl} className="w-16 sm:w-24 shrink-0"/> <GameCover appid={g.appid} imageUrl={g.imageUrl} fx={coverEffect(equipped, g.status)}
className="w-16 sm:w-24 shrink-0"/>
<span className="min-w-0 flex-1 truncate font-medium">{g.name ?? `App ${g.appid}`}</span> <span className="min-w-0 flex-1 truncate font-medium">{g.name ?? `App ${g.appid}`}</span>
{g.rating && ( {g.rating && (
<span className="flex items-center gap-0.5" aria-label={t('BACKLOG.RATING_STARS', {count: g.rating})}> <span className="flex items-center gap-0.5" aria-label={t('BACKLOG.RATING_STARS', {count: g.rating})}>
+11
View File
@@ -44,6 +44,17 @@ describe('PublicProfilePage', () => {
expect(fetch.mock.calls[2][1].headers['X-Profile-Ticket']).toBe('the-ticket'); expect(fetch.mock.calls[2][1].headers['X-Profile-Ticket']).toBe('the-ticket');
}); });
it('wears the effects of the owner: background, banner and the cover of a COMPLETED game', async () => {
mockBackends({style: {accent: 'red'}, equipped: {
PAGE_BACKGROUND: 'BG_STARFIELD', BANNER_BACKGROUND: 'BN_WAVES', COMPLETED_EFFECT: 'FX_NEON_PULSE'}});
const {container} = renderPage();
expect(await screen.findByText('Portal 2')).toBeTruthy();
await waitFor(() => expect(container.querySelector('[data-effect="starfield"]')).not.toBeNull());
expect(screen.getByText('Lobo').closest('[data-slot="card"]').querySelector('[data-effect="waves"]')).not.toBeNull();
expect(screen.getByText('Portal 2').closest('li').querySelector('.fx-cover-neon-pulse')).not.toBeNull();
});
it('still shows the profile when the looks call fails', async () => { it('still shows the profile when the looks call fails', async () => {
mockBackends({style: {accent: 'red'}, rewardsStatus: 500}); mockBackends({style: {accent: 'red'}, rewardsStatus: 500});
renderPage(); renderPage();
+9
View File
@@ -0,0 +1,9 @@
import {useEffect} from 'react';
import {usePreferences} from '../context/PreferencesContext.jsx';
/** Loads the preferences and the wallet like App does after the login, so a page under test sees the worn looks. */
export default function WalletLoader() {
const {syncPreferences} = usePreferences();
useEffect(() => { syncPreferences(); }, [syncPreferences]);
return null;
}