Public Access
feat(tiles): let users arrange the dashboard and profile tiles
This commit is contained in:
1 parent
b55fcfec92
commit
aa9e83e0cb
24 files changed
+792
-426
No files matched your search
+135
-98
@@ -2,7 +2,7 @@ import React, {useCallback, useEffect, useState} from 'react';
|
||||
import {Link, useNavigate} from 'react-router-dom';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {toast} from 'sonner';
|
||||
import {ArrowRight, Hash, Link2, RefreshCw, Trophy} from 'lucide-react';
|
||||
import {ArrowRight, Hash, LayoutGrid, Link2, RefreshCw, Trophy} from 'lucide-react';
|
||||
|
||||
import {Badge} from '@/components/ui/badge';
|
||||
import {Button} from '@/components/ui/button';
|
||||
@@ -25,18 +25,22 @@ import HeroBanner from '../components/HeroBanner.jsx';
|
||||
import SteamAvatar from '../components/SteamAvatar.jsx';
|
||||
import useSteamProfile from '../hooks/useSteamProfile.js';
|
||||
import useUserStats from '../hooks/useUserStats.js';
|
||||
import useSaveResponse from '../hooks/useSaveResponse.js';
|
||||
import TileGrid from '../components/TileGrid.jsx';
|
||||
import statGroupTile from '../components/stats/statGroupTile.jsx';
|
||||
import {DASHBOARD_TILES, DASHBOARD_TITLES, DEFAULT_DASHBOARD, normalizeLayout, saveTiles, statGroup} from '../utils/tiles.js';
|
||||
import {usePreferences} from '../context/PreferencesContext.jsx';
|
||||
import {apiFetch} from '../utils/api.js';
|
||||
import {hasSession} from '../utils/auth.js';
|
||||
|
||||
const TOP_GAMES = 5;
|
||||
// Games per tile size; the page loads the most for the large tile.
|
||||
const TOP_GAMES = {S: 3, M: 5, L: 10};
|
||||
const PLAYING_GAMES = {S: 2, M: 4, L: 8};
|
||||
// The games endpoint accepts only the page sizes 20, 40 and 80.
|
||||
const TOP_GAMES_URL = '/api/library/games?page=0&size=20&sort=playtimeMinutes%2Cdesc';
|
||||
const PLAYING_GAMES = 4;
|
||||
const PLAYING_URL = '/api/library/games?page=0&size=20&sort=playtimeMinutes%2Cdesc&status=PLAYING';
|
||||
|
||||
const JSON_HEADERS = {'Content-Type': 'application/json'};
|
||||
const TREND_GROUPS = ['TREND'];
|
||||
|
||||
const Dashboard = () => {
|
||||
const {t} = useTranslation();
|
||||
@@ -53,8 +57,18 @@ const Dashboard = () => {
|
||||
const [shuffling, setShuffling] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const pileTrend = useUserStats(TREND_GROUPS, expireSession, hasSession());
|
||||
const reloadTrend = pileTrend.reload;
|
||||
const [editing, setEditing] = useState(false);
|
||||
const runSave = useSaveResponse();
|
||||
const layout = normalizeLayout(userData?.settings?.dashboardTiles, DASHBOARD_TILES, DEFAULT_DASHBOARD);
|
||||
// The stat groups of the shown stat tiles, and TREND for the pile card. Waits for the saved layout in /me.
|
||||
const statGroups = [...new Set([
|
||||
...layout.map((tile) => statGroup(tile.id)).filter(Boolean),
|
||||
...(layout.some((tile) => tile.id === 'PILE_OF_SHAME') ? ['TREND'] : []),
|
||||
])].sort();
|
||||
// No groups would mean "all groups" to the stats endpoint.
|
||||
const hasStatGroups = statGroups.length > 0;
|
||||
const stats = useUserStats(statGroups, expireSession, hasSession() && userData != null && hasStatGroups);
|
||||
const reloadStats = stats.reload;
|
||||
|
||||
const fetchSteamCount = useCallback(async () => {
|
||||
try {
|
||||
@@ -80,7 +94,7 @@ const Dashboard = () => {
|
||||
}
|
||||
if (r.ok) {
|
||||
const page = await r.json();
|
||||
setTopGames((page.content ?? []).slice(0, TOP_GAMES));
|
||||
setTopGames((page.content ?? []).slice(0, TOP_GAMES.L));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching top games:', err);
|
||||
@@ -96,7 +110,7 @@ const Dashboard = () => {
|
||||
}
|
||||
if (r.ok) {
|
||||
const page = await r.json();
|
||||
setPlaying((page.content ?? []).slice(0, PLAYING_GAMES));
|
||||
setPlaying((page.content ?? []).slice(0, PLAYING_GAMES.L));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching playing games:', err);
|
||||
@@ -172,8 +186,8 @@ const Dashboard = () => {
|
||||
fetchSummary();
|
||||
fetchActivity();
|
||||
fetchAchievements();
|
||||
reloadTrend();
|
||||
}, [fetchSteamCount, fetchTopGames, fetchPlaying, fetchSummary, fetchActivity, fetchAchievements, reloadTrend]);
|
||||
if (hasStatGroups) reloadStats();
|
||||
}, [fetchSteamCount, fetchTopGames, fetchPlaying, fetchSummary, fetchActivity, fetchAchievements, hasStatGroups, reloadStats]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchUserData = async () => {
|
||||
@@ -240,7 +254,43 @@ const Dashboard = () => {
|
||||
}
|
||||
|
||||
const syncBusy = forceSync.busy || sync.isRunning;
|
||||
const maxMinutes = topGames[0]?.playtimeMinutes || 1;
|
||||
|
||||
const saveLayout = async (next) => {
|
||||
// The default is saved as "never changed", so a later change of the default reaches this user too.
|
||||
const tiles = JSON.stringify(next) === JSON.stringify(DEFAULT_DASHBOARD) ? null : next;
|
||||
if (!await runSave(() => saveTiles('dashboard-tiles', tiles))) return;
|
||||
setUserData((d) => ({...d, settings: {...d.settings, dashboardTiles: tiles}}));
|
||||
setEditing(false);
|
||||
};
|
||||
|
||||
const renderTile = (id, size) => {
|
||||
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}/>;
|
||||
case 'PLAYING':
|
||||
return showLibrary && (
|
||||
<CurrentlyPlayingCard games={playing.slice(0, PLAYING_GAMES[size])} syncing={sync.isRunning} wide={size === 'L'}/>
|
||||
);
|
||||
case 'PILE_OF_SHAME':
|
||||
if (!showLibrary) return null;
|
||||
if (summary) {
|
||||
return <PileOfShameCard summary={summary} trend={stats.stats?.trend} syncing={sync.isRunning}
|
||||
onShuffle={shuffleSuggestion} shuffling={shuffling}/>;
|
||||
}
|
||||
return summaryFailed && (
|
||||
<Card className="items-center justify-center gap-3 p-6 text-center text-sm text-muted-foreground">
|
||||
{t('DASHBOARD.SUMMARY_FAILED')}
|
||||
<Button variant="outline" size="sm" onClick={() => fetchSummary()}>
|
||||
<RefreshCw/>{t('DASHBOARD.RETRY')}
|
||||
</Button>
|
||||
</Card>
|
||||
);
|
||||
case 'ACTIVITY': return hasSteamId && activity && <ActivityCard activity={activity}/>;
|
||||
case 'ACHIEVEMENTS': return hasSteamId && achievements && <AchievementsCard summary={achievements}/>;
|
||||
default: return statGroupTile(id, stats.stats);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<PageContainer className="p-4 md:p-8 space-y-6">
|
||||
@@ -272,101 +322,88 @@ const Dashboard = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasSteamId && (
|
||||
<div className="relative flex flex-col items-start md:items-end gap-3 shrink-0">
|
||||
<SyncStatusBadge
|
||||
status={sync.status}
|
||||
lastSyncedAt={sync.lastSyncedAt}
|
||||
isRunning={sync.isRunning}
|
||||
/>
|
||||
<Button onClick={forceSync.trigger} disabled={syncBusy}>
|
||||
<RefreshCw className={syncBusy ? 'animate-spin' : ''}/>
|
||||
{t('LIBRARY.RESYNC')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="relative flex flex-col items-start md:items-end gap-3 shrink-0">
|
||||
{hasSteamId && (
|
||||
<>
|
||||
<SyncStatusBadge
|
||||
status={sync.status}
|
||||
lastSyncedAt={sync.lastSyncedAt}
|
||||
isRunning={sync.isRunning}
|
||||
/>
|
||||
<Button onClick={forceSync.trigger} disabled={syncBusy}>
|
||||
<RefreshCw className={syncBusy ? 'animate-spin' : ''}/>
|
||||
{t('LIBRARY.RESYNC')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button variant="ghost" size="sm" disabled={editing} onClick={() => setEditing(true)}>
|
||||
<LayoutGrid/>{t('TILES.CUSTOMIZE')}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{showLibrary && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<SteamGameCount targetCount={steamData.count}/>
|
||||
|
||||
<Card className="md:col-span-2 gap-0 py-0">
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b">
|
||||
<h2 className="flex items-center gap-2 font-semibold">
|
||||
<Trophy className="size-4 text-amber-500"/>
|
||||
{t('DASHBOARD.MOST_PLAYED')}
|
||||
</h2>
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link to="/library">{t('DASHBOARD.SHOW_ALL')}<ArrowRight/></Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{topGames.length === 0 ? (
|
||||
<p className="px-5 py-10 text-center text-sm text-muted-foreground">
|
||||
{sync.isRunning ? t('LIBRARY.SYNCING_FIRST_TIME') : t('LIBRARY.EMPTY')}
|
||||
</p>
|
||||
) : (
|
||||
<ol className="divide-y">
|
||||
{topGames.map((g, index) => (
|
||||
<li key={g.appid}>
|
||||
<Link
|
||||
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"
|
||||
>
|
||||
<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"/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-medium group-hover:text-primary transition-colors">
|
||||
{g.name ?? `App ${g.appid}`}
|
||||
</div>
|
||||
<div className="mt-2 h-1.5 rounded-full bg-muted">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary"
|
||||
style={{width: `${Math.max(2, (g.playtimeMinutes / maxMinutes) * 100)}%`}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<span className="w-12 sm:w-14 text-right text-sm font-medium text-muted-foreground tabular-nums">
|
||||
{formatPlaytime(g.playtimeMinutes)}
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showLibrary && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<CurrentlyPlayingCard games={playing} syncing={sync.isRunning}/>
|
||||
{summary && (
|
||||
<PileOfShameCard summary={summary} trend={pileTrend.stats?.trend} syncing={sync.isRunning}
|
||||
onShuffle={shuffleSuggestion} shuffling={shuffling}/>
|
||||
)}
|
||||
{!summary && summaryFailed && (
|
||||
<Card className="items-center justify-center gap-3 p-6 text-center text-sm text-muted-foreground">
|
||||
{t('DASHBOARD.SUMMARY_FAILED')}
|
||||
<Button variant="outline" size="sm" onClick={() => fetchSummary()}>
|
||||
<RefreshCw/>{t('DASHBOARD.RETRY')}
|
||||
</Button>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasSteamId && activity && <ActivityCard activity={activity}/>}
|
||||
{hasSteamId && achievements && <AchievementsCard summary={achievements}/>}
|
||||
<TileGrid tiles={DASHBOARD_TILES} titles={DASHBOARD_TITLES} layout={layout} renderTile={renderTile} editing={editing}
|
||||
defaultLayout={DEFAULT_DASHBOARD} onSave={saveLayout} onCancel={() => setEditing(false)}/>
|
||||
{/* Last child: space-y gives every other child a bottom margin, which would move the fixed card. */}
|
||||
<DashboardHint/>
|
||||
</PageContainer>
|
||||
);
|
||||
};
|
||||
|
||||
function MostPlayedCard({games, syncing}) {
|
||||
const {t} = useTranslation();
|
||||
const maxMinutes = games[0]?.playtimeMinutes || 1;
|
||||
return (
|
||||
<Card className="gap-0 py-0">
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b">
|
||||
<h2 className="flex items-center gap-2 font-semibold">
|
||||
<Trophy className="size-4 text-amber-500"/>
|
||||
{t('DASHBOARD.MOST_PLAYED')}
|
||||
</h2>
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link to="/library">{t('DASHBOARD.SHOW_ALL')}<ArrowRight/></Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{games.length === 0 ? (
|
||||
<p className="px-5 py-10 text-center text-sm text-muted-foreground">
|
||||
{syncing ? t('LIBRARY.SYNCING_FIRST_TIME') : t('LIBRARY.EMPTY')}
|
||||
</p>
|
||||
) : (
|
||||
<ol className="divide-y">
|
||||
{games.map((g, index) => (
|
||||
<li key={g.appid}>
|
||||
<Link
|
||||
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"
|
||||
>
|
||||
<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"/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-medium group-hover:text-primary transition-colors">
|
||||
{g.name ?? `App ${g.appid}`}
|
||||
</div>
|
||||
<div className="mt-2 h-1.5 rounded-full bg-muted">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary"
|
||||
style={{width: `${Math.max(2, (g.playtimeMinutes / maxMinutes) * 100)}%`}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<span className="w-12 sm:w-14 text-right text-sm font-medium text-muted-foreground tabular-nums">
|
||||
{formatPlaytime(g.playtimeMinutes)}
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function DashboardSkeleton() {
|
||||
return (
|
||||
<PageContainer className="p-4 md:p-8 space-y-6">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
import {cleanup, render, screen, waitFor} from '@testing-library/react';
|
||||
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
|
||||
import {MemoryRouter, Route, Routes, useLocation} from 'react-router-dom';
|
||||
import Dashboard from './Dashboard.jsx';
|
||||
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
|
||||
@@ -49,9 +49,13 @@ describe('Dashboard', () => {
|
||||
});
|
||||
|
||||
const avatar = 'https://avatars.steamstatic.com/abc_full.jpg';
|
||||
const mockDashboard = () => {
|
||||
globalThis.fetch = vi.fn(async (url) => {
|
||||
if (url.includes('/api/users/me')) return ok({email: 'a@b.de', displayName: 'Lobo', steamId: '76561198000000000'});
|
||||
const games = Array.from({length: 12}, (_, i) => ({appid: 100 + i, name: `Game ${i}`, playtimeMinutes: 1000 - i}));
|
||||
const mockDashboard = (settings) => {
|
||||
globalThis.fetch = vi.fn(async (url, init) => {
|
||||
if (init?.method === 'PUT') return {ok: true, status: 204, json: async () => ({})};
|
||||
if (url.includes('/api/users/me')) return ok({email: 'a@b.de', displayName: 'Lobo', steamId: '76561198000000000', settings});
|
||||
if (url.includes('sort=playtimeMinutes') && !url.includes('status=')) return ok({content: games, total: 12});
|
||||
if (url.includes('/api/library/stats')) return ok({trend: null, habits: {moonTrips: 1, longestDay: null, underOneHourGames: 0, underOneHourShare: null}});
|
||||
if (url.includes('/api/library/steam-profile')) return ok({personaName: 'Lobo', avatarUrl: avatar});
|
||||
if (url.includes('/api/library/activity')) return ok({totalMinutes: 90, days: [{day: '2026-09-23', minutes: 0}, {day: '2026-09-24', minutes: 90}], recentGames: [
|
||||
{appid: 1086940, name: "Baldur's Gate 3", playtimeMinutes: 900, playtime2Weeks: 90, status: 'PLAYING'}]});
|
||||
@@ -74,6 +78,55 @@ describe('Dashboard', () => {
|
||||
expect(container.querySelector('img[src$="/1086940/library_hero.jpg"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
const order = (container) => [...container.querySelectorAll('[data-tile]')].map((el) => el.dataset.tile);
|
||||
|
||||
it('shows today\'s tiles without a saved layout', async () => {
|
||||
const container = mockDashboard();
|
||||
|
||||
await screen.findByText('DASHBOARD.ACHIEVEMENTS.TITLE');
|
||||
expect(order(container)).toEqual(['GAME_COUNT', 'MOST_PLAYED', 'PLAYING', 'PILE_OF_SHAME', 'ACTIVITY', 'ACHIEVEMENTS']);
|
||||
expect(screen.getByText('Game 4')).toBeTruthy();
|
||||
expect(screen.queryByText('Game 5')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows a saved layout with its sizes and loads the stats of its stat tiles', async () => {
|
||||
const container = mockDashboard({dashboardTiles: [{id: 'STATS_HABITS', size: 'L'}, {id: 'MOST_PLAYED', size: 'S'}]});
|
||||
|
||||
await screen.findByText('STATS.HABITS.TITLE');
|
||||
expect(order(container)).toEqual(['STATS_HABITS', 'MOST_PLAYED']);
|
||||
expect(screen.getByText('Game 2')).toBeTruthy();
|
||||
expect(screen.queryByText('Game 3')).toBeNull();
|
||||
// No pile tile, so no TREND.
|
||||
expect(fetch).toHaveBeenCalledWith(expect.stringMatching(/\/api\/library\/stats\?groups=HABITS$/), expect.anything());
|
||||
});
|
||||
|
||||
it('leaves out a stat tile whose data did not come', async () => {
|
||||
const container = mockDashboard({dashboardTiles: [{id: 'STATS_PILE', size: 'M'}, {id: 'MOST_PLAYED', size: 'M'}]});
|
||||
|
||||
await screen.findByText('Game 0');
|
||||
expect(order(container)).toEqual(['MOST_PLAYED']);
|
||||
});
|
||||
|
||||
it('saves the default layout as "never changed"', async () => {
|
||||
mockDashboard({dashboardTiles: [{id: 'MOST_PLAYED', size: 'S'}]});
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', {name: 'TILES.CUSTOMIZE'}));
|
||||
fireEvent.click(screen.getByRole('button', {name: 'TILES.RESET'}));
|
||||
fireEvent.click(screen.getByRole('button', {name: 'TILES.DONE'}));
|
||||
|
||||
await waitFor(() => expect(fetch.mock.calls.some(([, init]) => init?.method === 'PUT')).toBe(true));
|
||||
const [url, init] = fetch.mock.calls.find(([, i]) => i?.method === 'PUT');
|
||||
expect(url).toMatch(/\/api\/users\/settings\/dashboard-tiles$/);
|
||||
expect(JSON.parse(init.body)).toEqual({tiles: null});
|
||||
expect(await screen.findByRole('button', {name: 'TILES.CUSTOMIZE'})).toBeTruthy();
|
||||
});
|
||||
it('puts "Customize" into the welcome card', async () => {
|
||||
mockDashboard();
|
||||
|
||||
const button = await screen.findByRole('button', {name: 'TILES.CUSTOMIZE'});
|
||||
expect(button.closest('[data-slot="card"]')?.textContent ?? 'not in a card').toContain('DASHBOARD.WELCOME_BACK');
|
||||
});
|
||||
|
||||
it('shows the achievement summary', async () => {
|
||||
mockDashboard();
|
||||
|
||||
|
||||
@@ -242,30 +242,30 @@ describe('GameDetail of a game added by hand', () => {
|
||||
expect(screen.queryByText('MANUAL_GAME.TITLE')).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps a section hidden after the page loaded when it sets the profile art', async () => {
|
||||
let sections = ['PLAYING'];
|
||||
it('keeps a background set in another tab when it sets the profile banner', async () => {
|
||||
let backgroundAppid = null;
|
||||
globalThis.fetch = vi.fn(async (url, init) => ({ok: true, status: init?.method === 'PUT' ? 204 : 200,
|
||||
json: async () => (url.includes('/api/users/me')
|
||||
? {role: 'USER', settings: {profileStyle: {accent: 'red', corners: 'round', sections}}}
|
||||
? {role: 'USER', settings: {profileStyle: {accent: 'red', corners: 'round', backgroundAppid}}}
|
||||
: {...game, source: 'STEAM'})}));
|
||||
renderDetail();
|
||||
const menu = await screen.findByRole('button', {name: 'GAME_DETAIL.PROFILE_ART'});
|
||||
|
||||
sections = []; // the owner hides PLAYING in Settings, e.g. in a second tab
|
||||
backgroundAppid = 620; // set in a second tab
|
||||
fireEvent.keyDown(menu, {key: 'Enter'});
|
||||
fireEvent.click(await screen.findByRole('menuitemcheckbox', {name: 'GAME_DETAIL.PROFILE_BANNER'}));
|
||||
|
||||
await waitFor(() => expect(fetch.mock.calls.some(([u, init]) => init?.method === 'PUT' && u.endsWith('/profile-style'))).toBe(true));
|
||||
const sent = fetch.mock.calls.filter(([u, init]) => init?.method === 'PUT' && u.endsWith('/profile-style'))
|
||||
.map(([, init]) => JSON.parse(init.body));
|
||||
expect(sent.filter((body) => body.sections?.includes('PLAYING'))).toEqual([]);
|
||||
expect(sent).toEqual([{accent: 'red', corners: 'round', bannerAppid: 220, backgroundAppid: 620}]);
|
||||
});
|
||||
|
||||
it('keeps a banner picked in another tab when it sets this game as banner', async () => {
|
||||
let bannerAppid = 220;
|
||||
globalThis.fetch = vi.fn(async (url, init) => ({ok: true, status: init?.method === 'PUT' ? 204 : 200,
|
||||
json: async () => (url.includes('/api/users/me')
|
||||
? {role: 'USER', settings: {profileStyle: {bannerAppid, sections: ['PLAYING']}}}
|
||||
? {role: 'USER', settings: {profileStyle: {bannerAppid}}}
|
||||
: {...game, source: 'STEAM'})}));
|
||||
renderDetail();
|
||||
const menu = await screen.findByRole('button', {name: 'GAME_DETAIL.PROFILE_ART'});
|
||||
@@ -280,7 +280,7 @@ describe('GameDetail of a game added by hand', () => {
|
||||
});
|
||||
|
||||
it('uses the art as profile banner and removes it on the second click', async () => {
|
||||
let stored = {accent: 'red', corners: 'round', sections: ['PLAYING']};
|
||||
let stored = {accent: 'red', corners: 'round'};
|
||||
globalThis.fetch = vi.fn(async (url, init) => {
|
||||
if (init?.method === 'PUT') stored = JSON.parse(init.body);
|
||||
return {ok: true, status: init?.method === 'PUT' ? 204 : 200,
|
||||
@@ -296,7 +296,7 @@ describe('GameDetail of a game added by hand', () => {
|
||||
await openMenu();
|
||||
fireEvent.click(await screen.findByRole('menuitemcheckbox', {name: 'GAME_DETAIL.PROFILE_BANNER'}));
|
||||
await waitFor(() => expect(styleBodies()).toEqual([{accent: 'red', corners: 'round', bannerAppid: 220,
|
||||
backgroundAppid: null, sections: ['PLAYING']}]));
|
||||
backgroundAppid: null}]));
|
||||
|
||||
await openMenu();
|
||||
const banner = await screen.findByRole('menuitemcheckbox', {name: 'GAME_DETAIL.PROFILE_BANNER'});
|
||||
|
||||
@@ -1,24 +1,30 @@
|
||||
import {useEffect, useState} from 'react';
|
||||
import {Link, useParams} from 'react-router-dom';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {CircleCheck, Clock, Gamepad2, Library, Percent, Star, Trophy, UserRoundX} from 'lucide-react';
|
||||
import {CircleCheck, Clock, Gamepad2, LayoutGrid, Library, Percent, Star, Trophy, UserRoundX} from 'lucide-react';
|
||||
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {Card} from '@/components/ui/card';
|
||||
import {Skeleton} from '@/components/ui/skeleton';
|
||||
import {cn} from '@/lib/utils';
|
||||
import GameCover from '../components/GameCover.jsx';
|
||||
import GameStatusBadge, {STATUS_META} from '../components/GameStatusBadge.jsx';
|
||||
import {GAME_STATUSES} from '../utils/backlog.js';
|
||||
import {formatPlaytime} from '../utils/games.js';
|
||||
import {hasSession} from '../utils/auth.js';
|
||||
import {normalizeProfileStyle} from '../utils/profileStyle.js';
|
||||
import {DEFAULT_PROFILE, PROFILE_TILES, PROFILE_TITLES, normalizeLayout, saveTiles} from '../utils/tiles.js';
|
||||
import useCurrentUser from '../hooks/useCurrentUser.js';
|
||||
import useSaveResponse from '../hooks/useSaveResponse.js';
|
||||
import HeroBanner from '../components/HeroBanner.jsx';
|
||||
import PageBackground from '../components/PageBackground.jsx';
|
||||
import PageContainer from '../components/PageContainer.jsx';
|
||||
import PileStats from '../components/stats/PileStats.jsx';
|
||||
import TrendStats from '../components/stats/TrendStats.jsx';
|
||||
import TopGamesStats from '../components/stats/TopGamesStats.jsx';
|
||||
import TasteStats from '../components/stats/TasteStats.jsx';
|
||||
import HabitsStats from '../components/stats/HabitsStats.jsx';
|
||||
import FinishedStats from '../components/stats/FinishedStats.jsx';
|
||||
import TileGrid from '../components/TileGrid.jsx';
|
||||
import statGroupTile from '../components/stats/statGroupTile.jsx';
|
||||
|
||||
// Games per tile size; Core sends at most 8 playing and 12 finished games.
|
||||
const PLAYING_GAMES = {S: 4, M: 8};
|
||||
const FINISHED_GAMES = {S: 6, M: 12};
|
||||
|
||||
/**
|
||||
* Public, no login. Auth returns a short-lived ticket for a public profile; Core returns the data for it.
|
||||
@@ -29,6 +35,10 @@ export default function PublicProfilePage() {
|
||||
const [profile, setProfile] = useState(null);
|
||||
const [library, setLibrary] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [reloads, setReloads] = useState(0);
|
||||
const viewer = useCurrentUser(hasSession());
|
||||
const runSave = useSaveResponse();
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -70,7 +80,7 @@ export default function PublicProfilePage() {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [name, t]);
|
||||
}, [name, t, reloads]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
@@ -97,13 +107,31 @@ export default function PublicProfilePage() {
|
||||
const memberSince = profile.memberSince
|
||||
? new Date(`${profile.memberSince}T12:00:00`).toLocaleDateString(i18n.language, {year: 'numeric', month: 'long'})
|
||||
: null;
|
||||
const sections = {
|
||||
SUMMARY: library.totalGames != null && <Summary library={library}/>,
|
||||
STATUS: library.byStatus && Object.values(library.byStatus).some((count) => count > 0)
|
||||
&& <StatusBar byStatus={library.byStatus}/>,
|
||||
PLAYING: library.playing?.length > 0 && <Playing games={library.playing}/>,
|
||||
FINISHED: library.finished?.length > 0 && <Finished games={library.finished}/>,
|
||||
STATS: library.stats && <SharedStats stats={library.stats}/>,
|
||||
const layout = normalizeLayout(profile.style?.tiles, PROFILE_TILES, DEFAULT_PROFILE);
|
||||
const owner = viewer?.displayName?.toLowerCase() === profile.displayName.toLowerCase();
|
||||
const renderTile = (id, size) => {
|
||||
switch (id) {
|
||||
case 'SUMMARY': return library.totalGames != null && <Summary library={library} compact={size === 'S'}/>;
|
||||
case 'STATUS':
|
||||
return library.byStatus && Object.values(library.byStatus).some((count) => count > 0)
|
||||
&& <StatusBar byStatus={library.byStatus}/>;
|
||||
case 'PLAYING':
|
||||
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])}/>;
|
||||
default: return statGroupTile(id, library.stats, false);
|
||||
}
|
||||
};
|
||||
// Core sends only the data of the shown tiles (the ticket says which), so a new tile needs a new load.
|
||||
const saveLayout = async (next) => {
|
||||
if (!await runSave(() => saveTiles('profile-tiles', next))) return;
|
||||
setEditing(false);
|
||||
if (next.some((tile) => !layout.some((shown) => shown.id === tile.id))) {
|
||||
setReloads((count) => count + 1);
|
||||
} else {
|
||||
setProfile((p) => ({...p, style: {...p.style, tiles: next}}));
|
||||
}
|
||||
};
|
||||
|
||||
// The owner's accent and corners apply to this page only; light or dark mode stays the viewer's.
|
||||
@@ -120,16 +148,20 @@ export default function PublicProfilePage() {
|
||||
<p className="mt-2 text-sm text-muted-foreground">{t('PROFILE.MEMBER_SINCE', {date: memberSince})}</p>
|
||||
)}
|
||||
</div>
|
||||
{owner && !editing && (
|
||||
<Button variant="outline" size="sm" className="relative mt-4" onClick={() => setEditing(true)}>
|
||||
<LayoutGrid/>{t('TILES.CUSTOMIZE')}
|
||||
</Button>
|
||||
)}
|
||||
</Card>
|
||||
{style.sections.map((section) => sections[section] && (
|
||||
<section key={section} data-section={section}>{sections[section]}</section>
|
||||
))}
|
||||
<TileGrid tiles={PROFILE_TILES} titles={PROFILE_TITLES} layout={layout} renderTile={renderTile} editing={editing}
|
||||
defaultLayout={DEFAULT_PROFILE} onSave={saveLayout} onCancel={() => setEditing(false)}/>
|
||||
</PageContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Summary({library}) {
|
||||
function Summary({library, compact}) {
|
||||
const {t, i18n} = useTranslation();
|
||||
const stats = [
|
||||
[Library, library.totalGames.toLocaleString(i18n.language), t('PROFILE.GAMES')],
|
||||
@@ -143,7 +175,7 @@ function Summary({library}) {
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Card className={`grid gap-3 p-5 ${stats.length > 3 ? 'grid-cols-2 sm:grid-cols-5' : 'grid-cols-3'}`}>
|
||||
<Card className={cn('grid gap-3 p-5', stats.length > 3 && !compact ? 'grid-cols-2 sm:grid-cols-5' : 'grid-cols-3')}>
|
||||
{stats.map(([Icon, value, label]) => (
|
||||
<div key={label} className="rounded-lg bg-muted/50 p-3 text-center">
|
||||
<Icon className="mx-auto mb-1 size-4 text-primary"/>
|
||||
@@ -178,14 +210,14 @@ function StatusBar({byStatus}) {
|
||||
);
|
||||
}
|
||||
|
||||
function Playing({games}) {
|
||||
function Playing({games, compact}) {
|
||||
const {t} = useTranslation();
|
||||
return (
|
||||
<Card className="gap-4 p-5">
|
||||
<h2 className="flex items-center gap-2 font-semibold">
|
||||
<Gamepad2 className="size-4 text-primary"/>{t('PROFILE.PLAYING')}
|
||||
</h2>
|
||||
<ul className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<ul className={cn('grid grid-cols-2 gap-3', !compact && 'sm:grid-cols-4')}>
|
||||
{games.map((g) => (
|
||||
<li key={g.appid} className="space-y-1.5">
|
||||
<GameCover appid={g.appid} imageUrl={g.imageUrl} className="w-full"/>
|
||||
@@ -224,17 +256,3 @@ function Finished({games}) {
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Only the groups the user shares come back; the game rows do not link, the library pages are private.
|
||||
function SharedStats({stats}) {
|
||||
return (
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{stats.pile && <PileStats pile={stats.pile} linked={false}/>}
|
||||
{stats.trend && <TrendStats trend={stats.trend}/>}
|
||||
{stats.topGames && <TopGamesStats topGames={stats.topGames} linked={false}/>}
|
||||
{stats.taste && <TasteStats taste={stats.taste}/>}
|
||||
{stats.habits && <HabitsStats habits={stats.habits}/>}
|
||||
{stats.finished && <FinishedStats finished={stats.finished}/>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
import {afterEach, describe, expect, it, vi} from 'vitest';
|
||||
import {cleanup, render, screen, waitFor} from '@testing-library/react';
|
||||
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
|
||||
import {MemoryRouter, Route, Routes} from 'react-router-dom';
|
||||
import PublicProfilePage from './PublicProfilePage.jsx';
|
||||
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
|
||||
@@ -14,8 +14,10 @@ const library = {
|
||||
finished: [{appid: 620, name: 'Portal 2', playtimeMinutes: 900, status: 'COMPLETED', rating: 5}],
|
||||
};
|
||||
|
||||
function mockBackends({profileStatus = 200, libraryStatus = 200, style, answer = library} = {}) {
|
||||
globalThis.fetch = vi.fn(async (url) => {
|
||||
function mockBackends({profileStatus = 200, libraryStatus = 200, style, answer = library, me} = {}) {
|
||||
globalThis.fetch = vi.fn(async (url, init) => {
|
||||
if (init?.method === 'PUT') return {ok: true, status: 204, json: async () => ({})};
|
||||
if (url.includes('/api/users/me')) return {ok: !!me, status: me ? 200 : 401, json: async () => me};
|
||||
if (url.includes('/api/public/profiles/library')) {
|
||||
return {ok: libraryStatus === 200, status: libraryStatus, json: async () => answer};
|
||||
}
|
||||
@@ -65,7 +67,7 @@ describe('PublicProfilePage', () => {
|
||||
it('shows only the stat groups the user shares, without links', async () => {
|
||||
library.stats = {pile: null, trend: null, topGames: null, taste: null, finished: null,
|
||||
habits: {moonTrips: 2.5, longestDay: null, underOneHourGames: 0, underOneHourShare: null}};
|
||||
mockBackends();
|
||||
mockBackends({style: {tiles: [{id: 'STATS_HABITS', size: 'M'}, {id: 'STATS_PILE', size: 'M'}]}});
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByText('STATS.HABITS.TITLE')).toBeTruthy();
|
||||
@@ -75,7 +77,7 @@ describe('PublicProfilePage', () => {
|
||||
});
|
||||
|
||||
it('applies the owner\'s accent and corners to the page only, with banner and background', async () => {
|
||||
mockBackends({style: {accent: 'red', corners: 'round', bannerAppid: 620, backgroundAppid: 220, sections: ['PLAYING']}});
|
||||
mockBackends({style: {accent: 'red', corners: 'round', bannerAppid: 620, backgroundAppid: 220, tiles: [{id: 'PLAYING', size: 'M'}]}});
|
||||
const {container} = renderPage();
|
||||
|
||||
await screen.findByText('Half-Life 2');
|
||||
@@ -88,13 +90,13 @@ describe('PublicProfilePage', () => {
|
||||
|
||||
it('shows the sections in the owner\'s order and leaves out hidden ones', async () => {
|
||||
mockBackends({
|
||||
style: {sections: ['FINISHED', 'PLAYING', 'SUMMARY']},
|
||||
style: {tiles: [{id: 'FINISHED', size: 'M'}, {id: 'PLAYING', size: 'S'}, {id: 'SUMMARY', size: 'M'}]},
|
||||
answer: {...library, byStatus: null, finishedGames: 60},
|
||||
});
|
||||
const {container} = renderPage();
|
||||
|
||||
await screen.findByText('Portal 2');
|
||||
expect([...container.querySelectorAll('[data-section]')].map((el) => el.dataset.section))
|
||||
expect([...container.querySelectorAll('[data-tile]')].map((el) => el.dataset.tile))
|
||||
.toEqual(['FINISHED', 'PLAYING', 'SUMMARY']);
|
||||
expect(screen.getByText('60')).toBeTruthy();
|
||||
expect(screen.queryByText('BACKLOG.STATUS.BACKLOG')).toBeNull();
|
||||
@@ -105,7 +107,7 @@ describe('PublicProfilePage', () => {
|
||||
const {container} = renderPage();
|
||||
|
||||
await screen.findByText('PROFILE.GAMES');
|
||||
expect([...container.querySelectorAll('[data-section]')].map((el) => el.dataset.section)).toEqual(['SUMMARY']);
|
||||
expect([...container.querySelectorAll('[data-tile]')].map((el) => el.dataset.tile)).toEqual(['SUMMARY']);
|
||||
});
|
||||
|
||||
it('shows every section for an Auth without a style', async () => {
|
||||
@@ -113,11 +115,84 @@ describe('PublicProfilePage', () => {
|
||||
const {container} = renderPage();
|
||||
|
||||
await screen.findByText('Portal 2');
|
||||
expect([...container.querySelectorAll('[data-section]')].map((el) => el.dataset.section))
|
||||
expect([...container.querySelectorAll('[data-tile]')].map((el) => el.dataset.tile))
|
||||
.toEqual(['SUMMARY', 'STATUS', 'PLAYING', 'FINISHED']);
|
||||
expect(container.querySelector('[data-accent="violet"]')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows the small playing tile with half the games and at half width', async () => {
|
||||
const games = Array.from({length: 8}, (_, i) => ({appid: 100 + i, name: `Game ${i}`, playtimeMinutes: 60, status: 'PLAYING'}));
|
||||
mockBackends({style: {tiles: [{id: 'PLAYING', size: 'S'}]}, answer: {...library, playing: games}});
|
||||
const {container} = renderPage();
|
||||
|
||||
await screen.findByText('Game 0');
|
||||
expect(screen.queryByText('Game 4')).toBeNull();
|
||||
expect(container.querySelector('[data-tile="PLAYING"]').className).toContain('md:col-span-6');
|
||||
});
|
||||
|
||||
const libraryLoads = () => fetch.mock.calls.filter(([url]) => url.includes('/api/public/profiles/library')).length;
|
||||
const putBody = () => JSON.parse(fetch.mock.calls.find(([, init]) => init?.method === 'PUT')[1].body);
|
||||
|
||||
it('offers "Customize" to the owner and hides a tile without a new load', async () => {
|
||||
localStorage.setItem('tbp_session', '1');
|
||||
try {
|
||||
mockBackends({me: {displayName: 'lobo', role: 'USER'}});
|
||||
const {container} = renderPage();
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', {name: 'TILES.CUSTOMIZE'}));
|
||||
// The test texts carry no tile name; the first tile is SUMMARY.
|
||||
fireEvent.click(screen.getAllByRole('button', {name: 'TILES.HIDE'})[0]);
|
||||
fireEvent.click(screen.getByRole('button', {name: 'TILES.DONE'}));
|
||||
|
||||
expect(await screen.findByRole('button', {name: 'TILES.CUSTOMIZE'})).toBeTruthy();
|
||||
expect(fetch.mock.calls.find(([, init]) => init?.method === 'PUT')[0]).toMatch(/\/api\/users\/settings\/profile-tiles$/);
|
||||
expect(putBody().tiles.map((tile) => tile.id)).toEqual(['STATUS', 'PLAYING', 'FINISHED']);
|
||||
expect([...container.querySelectorAll('[data-tile]')].map((el) => el.dataset.tile)).not.toContain('SUMMARY');
|
||||
expect(libraryLoads()).toBe(1);
|
||||
} finally {
|
||||
localStorage.clear();
|
||||
}
|
||||
});
|
||||
|
||||
it('loads again after adding a tile, because Core sent no data for it', async () => {
|
||||
localStorage.setItem('tbp_session', '1');
|
||||
try {
|
||||
mockBackends({me: {displayName: 'Lobo', role: 'USER'}});
|
||||
renderPage();
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', {name: 'TILES.CUSTOMIZE'}));
|
||||
fireEvent.click(screen.getByRole('button', {name: 'STATS.PILE.TITLE'}));
|
||||
fireEvent.click(screen.getByRole('button', {name: 'TILES.DONE'}));
|
||||
|
||||
await waitFor(() => expect(libraryLoads()).toBe(2));
|
||||
expect(putBody().tiles.at(-1)).toEqual({id: 'STATS_PILE', size: 'M'});
|
||||
} finally {
|
||||
localStorage.clear();
|
||||
}
|
||||
});
|
||||
|
||||
it('shows no "Customize" to a visitor', async () => {
|
||||
localStorage.setItem('tbp_session', '1');
|
||||
try {
|
||||
mockBackends({me: {displayName: 'Someone', role: 'USER'}});
|
||||
renderPage();
|
||||
|
||||
await screen.findByText('Portal 2');
|
||||
await waitFor(() => expect(fetch).toHaveBeenCalledWith(expect.stringContaining('/api/users/me'), expect.anything()));
|
||||
expect(screen.queryByRole('button', {name: 'TILES.CUSTOMIZE'})).toBeNull();
|
||||
} finally {
|
||||
localStorage.clear();
|
||||
}
|
||||
});
|
||||
it('keeps three numbers in a row on a phone', async () => {
|
||||
mockBackends();
|
||||
const {container} = renderPage();
|
||||
|
||||
await screen.findByText('PROFILE.GAMES');
|
||||
const summary = container.querySelector('[data-tile="SUMMARY"] [data-slot="card"]');
|
||||
expect(summary.className.split(' ')).toContain('grid-cols-3');
|
||||
});
|
||||
|
||||
it('says the profile is unknown or private on a 404', async () => {
|
||||
mockBackends({profileStatus: 404});
|
||||
renderPage();
|
||||
|
||||
@@ -18,7 +18,6 @@ import BuildVersionCard from '../components/BuildVersionCard.jsx';
|
||||
import DeleteAccountCard from '../components/DeleteAccountCard.jsx';
|
||||
import FormField from '../components/FormField.jsx';
|
||||
import SteamAccountCard from '../components/SteamAccountCard.jsx';
|
||||
import ProfileStatsSettings from '../components/stats/ProfileStatsSettings.jsx';
|
||||
import {settingsSchema} from '../utils/validation.js';
|
||||
import {usePreferences} from '../context/PreferencesContext.jsx';
|
||||
import useSessionExpired from '../hooks/useSessionExpired.js';
|
||||
@@ -28,7 +27,6 @@ import ProfileStyleSettings from '../components/ProfileStyleSettings.jsx';
|
||||
import {normalizeProfileStyle} from '../utils/profileStyle.js';
|
||||
import useSaveProfileStyle from '../hooks/useSaveProfileStyle.js';
|
||||
import {coverUrl} from '../utils/games.js';
|
||||
import {saveProfileStats} from '../utils/stats.js';
|
||||
import {apiFetch, refreshSession} from '../utils/api.js';
|
||||
import {hasSession, markSession} from '../utils/auth.js';
|
||||
|
||||
@@ -66,7 +64,6 @@ export default function SettingsPage() {
|
||||
const [profile, setProfile] = useState(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState(null);
|
||||
const [savingProfileStats, setSavingProfileStats] = useState(false);
|
||||
const [savingProfileStyle, setSavingProfileStyle] = useState(false);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
// Read once: removing the parameter below must not load the profile again.
|
||||
@@ -206,31 +203,7 @@ export default function SettingsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// Shown at once; the switches wait for the answer, so the saves reach Auth one after the other.
|
||||
const handleProfileStatsChange = async (groups) => {
|
||||
const previous = profile?.settings?.profileStats ?? [];
|
||||
const showProfileStats = (next) => setProfile((p) => ({...p, settings: {...p.settings, profileStats: next}}));
|
||||
showProfileStats(groups);
|
||||
setSavingProfileStats(true);
|
||||
try {
|
||||
const response = await saveProfileStats(groups);
|
||||
if (response.status === 401) {
|
||||
expireSession();
|
||||
return;
|
||||
}
|
||||
if (!response.ok) {
|
||||
showProfileStats(previous);
|
||||
toast.error(t('SETTINGS.ERROR_SAVE'));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Profile stats save error:', err);
|
||||
showProfileStats(previous);
|
||||
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
|
||||
} finally {
|
||||
setSavingProfileStats(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Shown at once; the pickers wait for the answer, so the saves reach Auth one after the other.
|
||||
const handleProfileStyleChange = async (changes) => {
|
||||
const previous = normalizeProfileStyle(profile?.settings?.profileStyle);
|
||||
const showProfileStyle = (next) => setProfile((p) => ({...p, settings: {...p.settings, profileStyle: next}}));
|
||||
@@ -447,9 +420,9 @@ export default function SettingsPage() {
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<ProfileStatsSettings selected={profile?.settings?.profileStats ?? []}
|
||||
disabled={!publicProfile} saving={savingProfileStats}
|
||||
onChange={handleProfileStatsChange}/>
|
||||
{publicProfile && profileLink && (
|
||||
<p className="text-xs text-muted-foreground">{t('SETTINGS.PROFILE_TILES_HINT')}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 border-t pt-6">
|
||||
|
||||
@@ -57,80 +57,27 @@ describe('SettingsPage public profile', () => {
|
||||
await waitFor(() => expect(screen.queryByText(/\/u\/Lobo$/)).toBeNull());
|
||||
});
|
||||
|
||||
it('saves the stat groups the public profile shows, in a fixed order', async () => {
|
||||
renderPage(profile({settings: {useDarkTheme: true, language: 'de', publicProfile: true, profileStats: ['TREND']}}));
|
||||
|
||||
fireEvent.click(await screen.findByRole('switch', {name: 'STATS.PILE.TITLE'}));
|
||||
|
||||
await waitFor(() => expect(puts()).toHaveLength(1));
|
||||
expect(puts()[0]).toEqual({groups: ['PILE', 'TREND']});
|
||||
const [url] = fetch.mock.calls.find(([, init]) => init?.method === 'PUT');
|
||||
expect(url).toMatch(/\/api\/users\/settings\/profile-stats$/);
|
||||
await waitFor(() => expect(screen.getByRole('switch', {name: 'STATS.PILE.TITLE'}).getAttribute('aria-checked'))
|
||||
.toBe('true'));
|
||||
});
|
||||
|
||||
it('waits for a save before the next switch click', async () => {
|
||||
const answers = [];
|
||||
const me = profile({settings: {useDarkTheme: true, language: 'de', publicProfile: true, profileStats: []}});
|
||||
globalThis.fetch = vi.fn((url, init) => (init?.method === 'PUT'
|
||||
? new Promise((resolve) => answers.push(() => resolve({ok: true, status: 204, json: async () => ({})})))
|
||||
: Promise.resolve({ok: true, status: 200, json: async () => me})));
|
||||
render(<MemoryRouter><PreferencesProvider><SettingsPage/></PreferencesProvider></MemoryRouter>);
|
||||
|
||||
fireEvent.click(await screen.findByRole('switch', {name: 'STATS.PILE.TITLE'}));
|
||||
await waitFor(() => expect(screen.getByRole('switch', {name: 'STATS.TASTE.TITLE'}).disabled).toBe(true));
|
||||
answers.forEach((answer) => answer());
|
||||
await waitFor(() => expect(screen.getByRole('switch', {name: 'STATS.TASTE.TITLE'}).disabled).toBe(false));
|
||||
fireEvent.click(screen.getByRole('switch', {name: 'STATS.TASTE.TITLE'}));
|
||||
|
||||
await waitFor(() => expect(puts()).toHaveLength(2));
|
||||
expect(puts()[1]).toEqual({groups: ['PILE', 'TASTE']});
|
||||
});
|
||||
|
||||
it('switches the group back when the save fails', async () => {
|
||||
const me = profile({settings: {useDarkTheme: true, language: 'de', publicProfile: true, profileStats: []}});
|
||||
globalThis.fetch = vi.fn(async (url, init) => (init?.method === 'PUT'
|
||||
? {ok: false, status: 500, json: async () => ({})}
|
||||
: {ok: true, status: 200, json: async () => me}));
|
||||
render(<MemoryRouter><PreferencesProvider><SettingsPage/></PreferencesProvider></MemoryRouter>);
|
||||
|
||||
fireEvent.click(await screen.findByRole('switch', {name: 'STATS.PILE.TITLE'}));
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('switch', {name: 'STATS.PILE.TITLE'}).getAttribute('aria-checked'))
|
||||
.toBe('false'));
|
||||
});
|
||||
|
||||
const styled = (profileStyle) => profile({settings: {useDarkTheme: true, language: 'de', publicProfile: true, profileStyle}});
|
||||
const styleGroup = (label) => within(screen.getAllByRole('group', {name: label})[1]);
|
||||
const stylePuts = () => fetch.mock.calls.filter(([url, init]) => init?.method === 'PUT' && url.endsWith('/profile-style'))
|
||||
.map(([, init]) => JSON.parse(init.body));
|
||||
|
||||
it('saves the profile accent apart from the app accent', async () => {
|
||||
renderPage(styled({accent: 'blue', corners: 'sharp', bannerAppid: 620, backgroundAppid: null,
|
||||
sections: ['PLAYING', 'SUMMARY']}));
|
||||
renderPage(styled({accent: 'blue', corners: 'sharp', bannerAppid: 620, backgroundAppid: null}));
|
||||
|
||||
await screen.findByText('SETTINGS.PROFILE_STYLE');
|
||||
fireEvent.click(styleGroup('SETTINGS.ACCENT').getByRole('button', {name: 'SETTINGS.ACCENT_OPTIONS.RED'}));
|
||||
|
||||
await waitFor(() => expect(stylePuts()).toEqual([{accent: 'red', corners: 'sharp', bannerAppid: 620,
|
||||
backgroundAppid: null, sections: ['PLAYING', 'SUMMARY']}]));
|
||||
backgroundAppid: null}]));
|
||||
expect(styleGroup('SETTINGS.ACCENT').getByRole('button', {name: 'SETTINGS.ACCENT_OPTIONS.RED'})
|
||||
.getAttribute('aria-pressed')).toBe('true');
|
||||
});
|
||||
|
||||
it('moves, hides and shows profile sections', async () => {
|
||||
renderPage(styled({sections: ['PLAYING', 'SUMMARY']}));
|
||||
it('points to the profile page for the tiles', async () => {
|
||||
renderPage(profile());
|
||||
|
||||
await screen.findByText('SETTINGS.PROFILE_STYLE');
|
||||
// The first row is the first shown section, PLAYING.
|
||||
fireEvent.click(screen.getAllByRole('button', {name: 'SETTINGS.PROFILE_SECTION_DOWN'})[0]);
|
||||
await waitFor(() => expect(stylePuts()).toHaveLength(1));
|
||||
expect(stylePuts()[0].sections).toEqual(['SUMMARY', 'PLAYING']);
|
||||
|
||||
fireEvent.click(screen.getByRole('switch', {name: 'SETTINGS.PROFILE_SECTION.STATS'}));
|
||||
await waitFor(() => expect(stylePuts()).toHaveLength(2));
|
||||
expect(stylePuts()[1].sections).toEqual(['SUMMARY', 'PLAYING', 'STATS']);
|
||||
expect(await screen.findByText('SETTINGS.PROFILE_TILES_HINT')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('removes the profile banner', async () => {
|
||||
@@ -145,17 +92,11 @@ describe('SettingsPage public profile', () => {
|
||||
it('offers the profile look only while the profile is public', async () => {
|
||||
renderPage(profile({settings: {useDarkTheme: true, language: 'de', publicProfile: false}}));
|
||||
|
||||
expect((await screen.findByRole('switch', {name: 'SETTINGS.PROFILE_SECTION.STATS'})).disabled).toBe(true);
|
||||
await screen.findByText('SETTINGS.PROFILE_STYLE');
|
||||
expect(screen.queryByText('SETTINGS.PROFILE_TILES_HINT')).toBeNull();
|
||||
expect(styleGroup('SETTINGS.ACCENT').getByRole('button', {name: 'SETTINGS.ACCENT_OPTIONS.RED'}).disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('offers the stat groups only while the profile is public', async () => {
|
||||
renderPage(profile({settings: {useDarkTheme: true, language: 'de', publicProfile: false}}));
|
||||
|
||||
expect((await screen.findByRole('switch', {name: 'STATS.PILE.TITLE'})).disabled).toBe(true);
|
||||
expect(screen.getByText('SETTINGS.PROFILE_STATS_NEEDS_PUBLIC')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('needs a display name first', async () => {
|
||||
renderPage(profile({displayName: null, settings: {useDarkTheme: true, language: 'de', publicProfile: false}}));
|
||||
|
||||
|
||||
Reference in new issue
Block a user