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
+5
-2
@@ -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">
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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({});
|
||||||
|
|
||||||
|
|||||||
@@ -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"/>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -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}`}
|
||||||
|
|||||||
@@ -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'));
|
||||||
|
|||||||
@@ -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')}/>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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}`}
|
||||||
|
|||||||
@@ -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,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,
|
||||||
|
|||||||
@@ -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({});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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})}>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in new issue
Block a user