Public Access
feat(points): apply worn effects on covers, details and backgrounds
This commit is contained in:
1 parent
dd45c0b5f6
commit
249973dd69
19 files changed
+317
-35
No files matched your search
@@ -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 && (
|
||||
|
||||
@@ -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({});
|
||||
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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}`}
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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')}/>
|
||||
</>
|
||||
|
||||
@@ -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}`}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in new issue
Block a user