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 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. */}
|
||||
<div className="isolate min-h-screen flex flex-col bg-background text-foreground font-sans">
|
||||
{/* 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}/>
|
||||
|
||||
<main className="grow flex flex-col">
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
import {hasSession} from '../utils/auth.js';
|
||||
import useSession from '../hooks/useSession.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';
|
||||
|
||||
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.
|
||||
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.
|
||||
useLayoutEffect(() => {
|
||||
applyAppearance(appearance, equippedAccent(wallet?.equipped));
|
||||
applyAppearance(appearance, equippedAccent(equipped));
|
||||
cacheAppearance(appearance);
|
||||
}, [appearance, wallet]);
|
||||
cacheEquipped(equipped);
|
||||
}, [appearance, equipped]);
|
||||
|
||||
const reloadWallet = useCallback(async () => {
|
||||
const started = generation.current;
|
||||
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.
|
||||
@@ -136,6 +142,7 @@ export function PreferencesProvider({children}) {
|
||||
savedAppearance.current = DEFAULT_APPEARANCE;
|
||||
setAppearanceState(DEFAULT_APPEARANCE);
|
||||
setWallet(null);
|
||||
setEquipped({});
|
||||
}, []);
|
||||
|
||||
// Without a session the next user must not get this look.
|
||||
@@ -166,6 +173,7 @@ export function PreferencesProvider({children}) {
|
||||
appearance,
|
||||
changeAppearance,
|
||||
wallet,
|
||||
equipped,
|
||||
reloadWallet,
|
||||
syncPreferences,
|
||||
resetAppearance,
|
||||
|
||||
@@ -109,3 +109,32 @@ describe('PreferencesProvider theme color', () => {
|
||||
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 showLibrary = hasSteamId || steamData.count > 0;
|
||||
const {appearance, wallet} = usePreferences();
|
||||
const {appearance, equipped} = usePreferences();
|
||||
const steamProfile = useSteamProfile(hasSteamId);
|
||||
const bannerAppid = appearance.showBanner
|
||||
? appearance.bannerAppid ?? activity?.recentGames?.[0]?.appid ?? null
|
||||
@@ -99,7 +99,8 @@ const Dashboard = () => {
|
||||
switch (id) {
|
||||
case 'GAME_COUNT': return showLibrary && <SteamGameCount targetCount={steamData.count}/>;
|
||||
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':
|
||||
return showLibrary && (
|
||||
<CurrentlyPlayingCard games={playing.slice(0, PLAYING_GAMES[size])} syncing={sync.isRunning} wide={size === 'L'}/>
|
||||
@@ -127,7 +128,7 @@ const Dashboard = () => {
|
||||
return (
|
||||
<PageContainer className="p-4 md:p-8 space-y-6">
|
||||
<DashboardWelcomeCard bannerAppid={bannerAppid} avatarUrl={steamProfile?.avatarUrl}
|
||||
equipped={wallet?.equipped}
|
||||
equipped={equipped}
|
||||
sync={sync} forceSync={forceSync} editing={editing} onCustomize={() => setEditing(true)}/>
|
||||
|
||||
<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 GameStatusBadge from '../components/GameStatusBadge.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 useAbortPrevious from '../hooks/useAbortPrevious.js';
|
||||
import {platformLabel} from '../utils/platforms.js';
|
||||
@@ -41,6 +43,7 @@ const SORT_OPTIONS = [
|
||||
|
||||
export default function GameLibrary() {
|
||||
const {t} = useTranslation();
|
||||
const {equipped} = usePreferences();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const page = Math.max(0, parseInt(searchParams.get('page') ?? '0', 10) || 0);
|
||||
@@ -286,7 +289,8 @@ export default function GameLibrary() {
|
||||
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"
|
||||
>
|
||||
<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">
|
||||
{g.name ?? `App ${g.appid}`}
|
||||
</span>
|
||||
|
||||
@@ -10,7 +10,7 @@ import {cn} from '@/lib/utils';
|
||||
import GameCover from '../components/GameCover.jsx';
|
||||
import GameStatusBadge, {STATUS_META} from '../components/GameStatusBadge.jsx';
|
||||
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 {GAME_STATUSES} from '../utils/limits.js';
|
||||
import {formatPlaytime} from '../utils/games.js';
|
||||
@@ -135,7 +135,7 @@ export default function PublicProfilePage() {
|
||||
return library.playing?.length > 0
|
||||
&& <Playing games={library.playing.slice(0, PLAYING_GAMES[size])} compact={size === 'S'}/>;
|
||||
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);
|
||||
}
|
||||
};
|
||||
@@ -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.
|
||||
return (
|
||||
<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">
|
||||
<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">
|
||||
<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>
|
||||
@@ -247,7 +247,7 @@ function Playing({games, compact}) {
|
||||
);
|
||||
}
|
||||
|
||||
function Finished({games}) {
|
||||
function Finished({games, equipped}) {
|
||||
const {t} = useTranslation();
|
||||
return (
|
||||
<Card className="gap-0 py-0">
|
||||
@@ -257,7 +257,8 @@ function Finished({games}) {
|
||||
<ul className="divide-y">
|
||||
{games.map((g) => (
|
||||
<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>
|
||||
{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');
|
||||
});
|
||||
|
||||
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 () => {
|
||||
mockBackends({style: {accent: 'red'}, rewardsStatus: 500});
|
||||
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