From 249973dd69a52ad3e084525db952ecf612737c85 Mon Sep 17 00:00:00 2001 From: lobo Date: Thu, 1 Oct 2026 15:09:25 +0200 Subject: [PATCH] feat(points): apply worn effects on covers, details and backgrounds --- src/App.jsx | 7 +- src/components/DashboardWelcomeCard.jsx | 4 +- src/components/DashboardWelcomeCard.test.jsx | 20 +++++- src/components/GameCover.jsx | 11 ++- src/components/GameCover.test.jsx | 70 +++++++++++++++++++- src/components/GameHeroCard.jsx | 17 ++++- src/components/HeroArt.test.jsx | 7 ++ src/components/HeroBanner.jsx | 11 ++- src/components/MostPlayedCard.jsx | 6 +- src/components/PageBackground.jsx | 14 +++- src/context/PreferencesContext.jsx | 16 +++-- src/context/PreferencesContext.test.jsx | 29 ++++++++ src/pages/Dashboard.jsx | 7 +- src/pages/GameDetail.effects.test.jsx | 52 +++++++++++++++ src/pages/GameLibrary.effects.test.jsx | 42 ++++++++++++ src/pages/GameLibrary.jsx | 6 +- src/pages/PublicProfilePage.jsx | 13 ++-- src/pages/PublicProfilePage.test.jsx | 11 +++ src/test/WalletLoader.jsx | 9 +++ 19 files changed, 317 insertions(+), 35 deletions(-) create mode 100644 src/pages/GameDetail.effects.test.jsx create mode 100644 src/pages/GameLibrary.effects.test.jsx create mode 100644 src/test/WalletLoader.jsx diff --git a/src/App.jsx b/src/App.jsx index 44b556c..1d63ff6 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -29,6 +29,7 @@ import {Toaster} from '@/components/ui/sonner'; import {TooltipProvider} from '@/components/ui/tooltip'; import PageBackground from './components/PageBackground.jsx'; import {usePreferences} from './context/PreferencesContext.jsx'; +import {backgroundEffect} from './utils/rewards.js'; import {clearSession} from './utils/auth.js'; import useSession from './hooks/useSession.js'; import {UnsavedChangesGuard} from './hooks/useUnsavedChanges.js'; @@ -37,7 +38,7 @@ import {logout} from './utils/api.js'; function App() { const {t} = useTranslation(); const {active: isAuthenticated} = useSession(); - const {appearance, syncPreferences} = usePreferences(); + const {appearance, equipped, syncPreferences} = usePreferences(); // Hooks for routing 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. */}
{/* A public profile shows the owner's background instead. */} - {isAuthenticated && !location.pathname.startsWith('/u/') && } + {isAuthenticated && !location.pathname.startsWith('/u/') && ( + + )}
diff --git a/src/components/DashboardWelcomeCard.jsx b/src/components/DashboardWelcomeCard.jsx index a57c647..817a567 100644 --- a/src/components/DashboardWelcomeCard.jsx +++ b/src/components/DashboardWelcomeCard.jsx @@ -11,7 +11,7 @@ import PointsBadge from './PointsBadge.jsx'; import SteamAvatar from './SteamAvatar.jsx'; import SyncStatusBadge from './SyncStatusBadge.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}) { const {t} = useTranslation(); @@ -23,7 +23,7 @@ export default function DashboardWelcomeCard({bannerAppid, avatarUrl, sync, forc {/* A picked game, else the game played most in the last two weeks. */} - +
{/* The slot is there before the avatar loads, so the title does not jump. */} {hasSteamId && ( diff --git a/src/components/DashboardWelcomeCard.test.jsx b/src/components/DashboardWelcomeCard.test.jsx index 1e5c91d..548f625 100644 --- a/src/components/DashboardWelcomeCard.test.jsx +++ b/src/components/DashboardWelcomeCard.test.jsx @@ -12,10 +12,10 @@ afterEach(() => { localStorage.clear(); }); -const renderCard = async (equipped) => { +const renderCard = async (equipped, bannerAppid) => { localStorage.setItem('tbp_session', '1'); fetchRoutes({'GET /api/users/me': json(200, {displayName: 'Lobo', steamId: '1'}), 'GET /api/library/hero-art': json(200, {})}); - renderWithProviders(); + renderWithProviders(); await screen.findByText(/Lobo/); }; @@ -29,6 +29,22 @@ describe('DashboardWelcomeCard looks', () => { 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 () => { await renderCard({}); diff --git a/src/components/GameCover.jsx b/src/components/GameCover.jsx index 70fd8f1..f9a6efa 100644 --- a/src/components/GameCover.jsx +++ b/src/components/GameCover.jsx @@ -1,23 +1,28 @@ -import {useState} from 'react'; +import {useRef, useState} from 'react'; import {Gamepad2} from 'lucide-react'; import {cn} from '@/lib/utils'; import {coverUrl} from '@/utils/games.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. * * @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); // Per URL: a reused cover (e.g. the next suggestion) loads and reports its own picture. const [failedSrc, setFailedSrc] = useState(null); const failed = failedSrc === src; + const ref = useRef(null); + const inView = useInView(ref, !!fx); return ( -
+
{failed ? (
diff --git a/src/components/GameCover.test.jsx b/src/components/GameCover.test.jsx index 6082891..b64f4ac 100644 --- a/src/components/GameCover.test.jsx +++ b/src/components/GameCover.test.jsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom -import {afterEach, describe, expect, it, vi} from 'vitest'; -import {cleanup, fireEvent, render} from '@testing-library/react'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {act, cleanup, fireEvent, render} from '@testing-library/react'; import GameCover from './GameCover.jsx'; import {reportBrokenImage} from '../utils/brokenImages.js'; @@ -8,6 +8,7 @@ vi.mock('../utils/brokenImages.js', () => ({COVER: 'COVER', reportBrokenImage: v afterEach(() => { cleanup(); + vi.unstubAllGlobals(); vi.clearAllMocks(); }); @@ -38,4 +39,69 @@ describe('GameCover', () => { fireEvent.error(container.querySelector('img')); 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(); + 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(<>); + + 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(); + + unmount(); + + expect(observers[0].unobserve).toHaveBeenCalledTimes(1); + expect(observers[0].disconnect).toHaveBeenCalledTimes(1); + }); + + it('does not watch a cover without an effect', () => { + render(); + + expect(observers).toHaveLength(0); + }); + + it('animates where the browser has no IntersectionObserver', () => { + vi.stubGlobal('IntersectionObserver', undefined); + const {container} = render(); + + expect(container.firstChild.classList.contains('fx-paused')).toBe(false); + }); + }); }); diff --git a/src/components/GameHeroCard.jsx b/src/components/GameHeroCard.jsx index 24810be..7e5aaa8 100644 --- a/src/components/GameHeroCard.jsx +++ b/src/components/GameHeroCard.jsx @@ -6,6 +6,7 @@ import {toast} from 'sonner'; import {Badge} from '@/components/ui/badge'; import {Button} from '@/components/ui/button'; import {Card} from '@/components/ui/card'; +import {cn} from '@/lib/utils'; import GameCover from './GameCover.jsx'; import GameStatusBadge from './GameStatusBadge.jsx'; import ProfileArtMenu from './ProfileArtMenu.jsx'; @@ -16,12 +17,15 @@ import {formatPlaytime} from '../utils/games.js'; import {setGameHidden} from '../utils/hiddenGames.js'; import {isSteamKey} from '../utils/manualGames.js'; import {platformLabel} from '../utils/platforms.js'; +import {coverEffect, detailEffect} from '../utils/rewards.js'; 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}) { const {t} = useTranslation(); - const {appearance} = usePreferences(); + const {appearance, equipped} = usePreferences(); const saveAppearance = useSaveAppearance(); const [hiding, setHiding] = useState(false); @@ -31,6 +35,7 @@ export default function GameHeroCard({game, manual, auditor, profileStyle, onCor ? game.speedrunUrl : null; const isBackground = appearance.backgroundAppid === game.appid; const isBanner = appearance.showBanner && appearance.bannerAppid === game.appid; + const detailFx = detailEffect(equipped, game.status); const toggleHidden = async () => { const hidden = !game.hidden; @@ -54,8 +59,14 @@ export default function GameHeroCard({game, manual, auditor, profileStyle, onCor return ( - -
+ {detailFx && ( + + )} + +

{game.name ?? `App ${game.appid}`} diff --git a/src/components/HeroArt.test.jsx b/src/components/HeroArt.test.jsx index 8e43d01..facf2b6 100644 --- a/src/components/HeroArt.test.jsx +++ b/src/components/HeroArt.test.jsx @@ -27,6 +27,13 @@ describe('PageBackground', () => { expect(container.innerHTML).toBe(''); }); + it('shows a worn animated background instead of the art', () => { + const {container} = render(); + + expect(container.querySelector('[data-effect="aurora"]')).not.toBeNull(); + expect(container.querySelector('img')).toBeNull(); + }); + it('starts with the library art again for another game', () => { const {container, rerender} = render(); fireEvent.error(container.querySelector('img')); diff --git a/src/components/HeroBanner.jsx b/src/components/HeroBanner.jsx index 989ecbe..98a72f8 100644 --- a/src/components/HeroBanner.jsx +++ b/src/components/HeroBanner.jsx @@ -1,15 +1,20 @@ import {cn} from '@/lib/utils'; +import EffectBackground from './EffectBackground.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 * `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 ( <> - -
+ : } +
diff --git a/src/components/MostPlayedCard.jsx b/src/components/MostPlayedCard.jsx index 4b72bf4..182d41e 100644 --- a/src/components/MostPlayedCard.jsx +++ b/src/components/MostPlayedCard.jsx @@ -6,8 +6,9 @@ import {Button} from '@/components/ui/button'; import {Card} from '@/components/ui/card'; import GameCover from './GameCover.jsx'; 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 maxMinutes = games[0]?.playtimeMinutes || 1; return ( @@ -37,7 +38,8 @@ export default function MostPlayedCard({games, syncing}) { {index + 1} - +
{g.name ?? `App ${g.appid}`} diff --git a/src/components/PageBackground.jsx b/src/components/PageBackground.jsx index 36479df..af1e8c2 100644 --- a/src/components/PageBackground.jsx +++ b/src/components/PageBackground.jsx @@ -1,9 +1,19 @@ +import EffectBackground from './EffectBackground.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 ( + <> + +