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

+2 -2
View File
@@ -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
<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'))}>
{/* 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">
{/* The slot is there before the avatar loads, so the title does not jump. */}
{hasSteamId && (
+18 -2
View File
@@ -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(<DashboardWelcomeCard sync={{}} forceSync={{}} equipped={equipped}/>);
renderWithProviders(<DashboardWelcomeCard bannerAppid={bannerAppid} sync={{}} forceSync={{}} equipped={equipped}/>);
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({});
+8 -3
View File
@@ -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 (
<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 ? (
<div className="flex size-full items-center justify-center text-muted-foreground">
<Gamepad2 className="size-1/3 max-h-8"/>
+68 -2
View File
@@ -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(<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 {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 (
<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"/>
<div className="flex flex-1 flex-col justify-between gap-4 p-6">
{detailFx && (
<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>
<h1 className="text-2xl md:text-3xl font-bold tracking-tight">
{game.name ?? `App ${game.appid}`}
+7
View File
@@ -27,6 +27,13 @@ describe('PageBackground', () => {
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', () => {
const {container, rerender} = render(<PageBackground appid={620}/>);
fireEvent.error(container.querySelector('img'));
+8 -3
View File
@@ -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 (
<>
<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', appid
{effect
? <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-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 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}) {
<span className="w-4 text-right text-sm font-semibold text-muted-foreground tabular-nums">
{index + 1}
</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="truncate font-medium group-hover:text-primary transition-colors">
{g.name ?? `App ${g.appid}`}
+12 -2
View File
@@ -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 (
<>
<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;
return (