feat(ui): add people lists for game owners and community stats
Build, Test & Push Frontend / quality-check (push) Successful in 47s
Build, Test & Push Frontend / docker (push) Successful in 22s

This commit is contained in:
lobo committed 2026-10-06 13:19:58 +02:00
1 parent b213b067dc
commit 3aed243e35
22 files changed
+425 -12

No files matched your search

+17 -1
View File
@@ -16,7 +16,7 @@ import {useCurrentUserContext} from '../context/CurrentUserContext.jsx';
import usePersistSettings from '../hooks/usePersistSettings.js';
import useSaveProfileStyle from '../hooks/useSaveProfileStyle.js';
import {normalizeProfileStyle} from '../utils/profileStyle.js';
import {saveSettings} from '../utils/settings.js';
import {saveOwnerLists, saveSettings} from '../utils/settings.js';
import {settingsSchema} from '../utils/validation.js';
import {MAX_DISPLAY_NAME_LENGTH} from '../utils/limits.js';
@@ -45,6 +45,14 @@ export default function ProfileCard({displayName}) {
}
};
const handleOwnerListsToggle = async () => {
const next = !profile?.settings?.showInOwnerLists;
const result = await persistSettings(() => saveOwnerLists(next));
if (result.ok) {
updateProfile((p) => ({...p, settings: {...p.settings, showInOwnerLists: next}}));
}
};
// 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);
@@ -113,6 +121,14 @@ export default function ProfileCard({displayName}) {
<Switch id="publicProfile" checked={publicProfile} disabled={!profileLink}
onCheckedChange={handlePublicProfileToggle}/>
</div>
<div className="flex items-center justify-between gap-4">
<div className="space-y-1">
<Label htmlFor="ownerLists">{t('SETTINGS.OWNER_LISTS')}</Label>
<p className="text-xs text-muted-foreground">{t('SETTINGS.OWNER_LISTS_HINT')}</p>
</div>
<Switch id="ownerLists" checked={publicProfile && !!profile?.settings?.showInOwnerLists}
disabled={!publicProfile || !profileLink} onCheckedChange={handleOwnerListsToggle}/>
</div>
{publicProfile && profileLink && (
<div className="flex items-center gap-2 rounded-md bg-muted px-3 py-2 text-sm">
<a href={profileLink} target="_blank" rel="noopener noreferrer"
+3 -2
View File
@@ -28,11 +28,12 @@ function useCountUp(target, durationMs = 1600) {
/**
* @param drill `{params}` of StatCount: the card is a button that opens the games behind the number
* @param onClick used when there is no drill target
*/
export default function StatCard({label, value, icon: Icon, suffix, drill}) {
export default function StatCard({label, value, icon: Icon, suffix, drill, onClick}) {
const {i18n} = useTranslation();
const animated = useCountUp(value);
const target = useDrillTarget({title: label, count: value, ...drill});
const target = useDrillTarget({title: label, count: value, ...drill}) ?? (onClick && {onClick});
const card = (
<Card className="gap-3 p-5">
{Icon && (
+25
View File
@@ -0,0 +1,25 @@
import {useState} from 'react';
import {useTranslation} from 'react-i18next';
import {Card} from '@/components/ui/card';
import PeopleList from './PeopleList.jsx';
import {fetchOwners} from '../../utils/people.js';
/** Who else owns this game: names of users who opted in, the rest as a count. */
export default function OwnersCard({appid}) {
const {t} = useTranslation();
const [count, setCount] = useState(null);
const load = async (page) => {
const result = await fetchOwners({appid, page});
if (result.ok && page === 0) setCount(result.data.listedTotal + result.data.privateCount);
return result;
};
return (
<Card className="gap-3 p-4">
{count > 0 && <p className="font-medium">{t('PEOPLE.OTHER_OWNERS', {count})}</p>}
<PeopleList load={load} showYou={false}/>
</Card>
);
}
+16
View File
@@ -0,0 +1,16 @@
import {Dialog, DialogContent, DialogDescription, DialogTitle} from '@/components/ui/dialog';
import PeopleList from './PeopleList.jsx';
/** The people behind a number of the landing page. */
export default function PeopleDialog({title, load, onClose}) {
return (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent
className="max-h-[85vh] overflow-y-auto overflow-x-hidden max-sm:top-auto max-sm:bottom-0 max-sm:max-w-full max-sm:translate-y-0 max-sm:rounded-b-none sm:max-w-xl">
<DialogTitle className="pr-6">{title}</DialogTitle>
<DialogDescription className="sr-only">{title}</DialogDescription>
<PeopleList load={load} emptyKey="PEOPLE.NO_USERS"/>
</DialogContent>
</Dialog>
);
}
+79
View File
@@ -0,0 +1,79 @@
import {useCallback, useEffect, useRef, useState} from 'react';
import {Link} from 'react-router-dom';
import {useTranslation} from 'react-i18next';
import {Button} from '@/components/ui/button';
import {Skeleton} from '@/components/ui/skeleton';
import FitText from '../FitText.jsx';
import {profileHref} from '../../utils/people.js';
const EMPTY = {names: [], privateCount: 0, includesYou: false, nothing: false, page: 0, totalPages: 0};
/**
* The people behind a number: listed users by name (a link to their public profile), the rest as one count.
*
* @param load `(page) => {ok, status, data}` with the PeopleDto
* @param showYou show the "You" line when the viewer is among them
* @param emptyKey text key when nobody is there
*/
export default function PeopleList({load, showYou = true, emptyKey = 'PEOPLE.NONE'}) {
const {t} = useTranslation();
const [state, setState] = useState(EMPTY);
const [status, setStatus] = useState('loading');
const latestLoad = useRef(load);
latestLoad.current = load;
const fetchPage = useCallback(async (page, names) => {
setStatus('loading');
const {ok, data} = await latestLoad.current(page);
if (!ok) {
setStatus('error');
return;
}
setState({
names: [...names, ...data.listed.map((person) => person.displayName)],
privateCount: data.privateCount, includesYou: data.includesYou, page: data.page, totalPages: data.totalPages,
nothing: data.listedTotal + data.privateCount === 0,
});
setStatus('ok');
}, []);
useEffect(() => {
fetchPage(0, []);
}, [fetchPage]);
return (
<div className="space-y-2 text-sm">
{state.includesYou && showYou && <p className="font-medium">{t('PEOPLE.YOU')}</p>}
{state.names.length > 0 && (
<ul className="space-y-1">
{state.names.map((name, index) => (
<li key={`${name}-${index}`} className="@container">
<Link to={profileHref(name)} className="min-w-0 max-w-full hover:text-primary hover:underline">
<FitText text={name}/>
</Link>
</li>
))}
</ul>
)}
{state.privateCount > 0 && (
<p className="text-muted-foreground">{t('PEOPLE.PRIVATE', {count: state.privateCount})}</p>
)}
{status === 'ok' && state.nothing && <p className="text-muted-foreground">{t(emptyKey)}</p>}
{status === 'loading' && <Skeleton className="h-10 rounded-xl"/>}
{status === 'error' && (
<div className="flex flex-col items-start gap-2 text-muted-foreground">
<p>{t('STATS.DRILL.ERROR')}</p>
<Button variant="outline" size="sm" className="min-h-11"
onClick={() => fetchPage(state.names.length > 0 ? state.page + 1 : 0, state.names)}>
{t('DASHBOARD.RETRY')}</Button>
</div>
)}
{status === 'ok' && state.page + 1 < state.totalPages && (
<Button variant="outline" className="min-h-11" onClick={() => fetchPage(state.page + 1, state.names)}>
{t('STATS.DRILL.LOAD_MORE')}
</Button>
)}
</div>
);
}
+81
View File
@@ -0,0 +1,81 @@
// @vitest-environment jsdom
import {afterEach, describe, expect, it, vi} from 'vitest';
import {cleanup, fireEvent, render, screen} from '@testing-library/react';
import {MemoryRouter} from 'react-router-dom';
import PeopleList from './PeopleList.jsx';
vi.mock('react-i18next', () => import('../../test/i18n-mock.js'));
const answer = (data) => ({ok: true, status: 200, data: {listed: [], listedTotal: 0, privateCount: 0, includesYou: false, page: 0, totalPages: 1, ...data}});
const renderList = (load, props) => render(<MemoryRouter><PeopleList load={load} {...props}/></MemoryRouter>);
describe('PeopleList', () => {
afterEach(cleanup);
it('linksEachNameToItsPublicProfile', async () => {
renderList(async () => answer({listed: [{displayName: 'Lobo Z'}], listedTotal: 1}));
expect((await screen.findByRole('link', {name: 'Lobo Z'})).getAttribute('href')).toBe('/u/Lobo%20Z');
});
it('showsPrivateUsersOnlyAsACount', async () => {
const {container} = renderList(async () => answer({privateCount: 3}));
expect(await screen.findByText('PEOPLE.PRIVATE:3')).toBeTruthy();
expect(screen.queryByText('PEOPLE.NONE')).toBeNull();
expect(container.querySelectorAll('a')).toHaveLength(0);
});
it('showsNoOtherOwnersWhenThereAreNone', async () => {
renderList(async () => answer());
expect(await screen.findByText('PEOPLE.NONE')).toBeTruthy();
});
it('showsTheGivenTextWhenThereAreNone', async () => {
render(<MemoryRouter><PeopleList load={async () => answer()} emptyKey="PEOPLE.NO_USERS"/></MemoryRouter>);
expect(await screen.findByText('PEOPLE.NO_USERS')).toBeTruthy();
expect(screen.queryByText('PEOPLE.NONE')).toBeNull();
});
it('showsYouWithoutAName', async () => {
renderList(async () => answer({includesYou: true}));
expect(await screen.findByText('PEOPLE.YOU')).toBeTruthy();
});
it('hidesYouWhenAsked', async () => {
renderList(async () => answer({includesYou: true, privateCount: 1}), {showYou: false});
await screen.findByText('PEOPLE.PRIVATE:1');
expect(screen.queryByText('PEOPLE.YOU')).toBeNull();
});
it('loadMoreAppendsThePageAndKeepsOneCount', async () => {
const load = vi.fn(async (page) => answer(page === 0
? {listed: [{displayName: 'a'}], listedTotal: 2, privateCount: 1, page: 0, totalPages: 2}
: {listed: [{displayName: 'b'}], listedTotal: 2, privateCount: 1, page: 1, totalPages: 2}));
renderList(load);
await screen.findByText('a');
fireEvent.click(screen.getByRole('button', {name: 'STATS.DRILL.LOAD_MORE'}));
expect(await screen.findByText('b')).toBeTruthy();
expect(screen.getByText('a')).toBeTruthy();
expect(load).toHaveBeenLastCalledWith(1);
expect(screen.queryByRole('button', {name: 'STATS.DRILL.LOAD_MORE'})).toBeNull();
expect(screen.getAllByText('PEOPLE.PRIVATE:1')).toHaveLength(1);
});
it('offersARetryAfterAnError', async () => {
const load = vi.fn()
.mockResolvedValueOnce({ok: false, status: 500, data: null})
.mockResolvedValueOnce(answer({privateCount: 2}));
renderList(load);
fireEvent.click(await screen.findByRole('button', {name: 'DASHBOARD.RETRY'}));
expect(await screen.findByText('PEOPLE.PRIVATE:2')).toBeTruthy();
});
});
+21 -1
View File
@@ -7,8 +7,11 @@ import {Skeleton} from '@/components/ui/skeleton';
import GameRow from './GameRow.jsx';
import ComingBadge from '../ComingBadge.jsx';
import GameStatusBadge from '../GameStatusBadge.jsx';
import PeopleList from '../people/PeopleList.jsx';
import useSession from '../../hooks/useSession.js';
import {formatDate} from '../../utils/format.js';
import {formatPlaytime} from '../../utils/games.js';
import {fetchOwners} from '../../utils/people.js';
import {fetchStatGames} from '../../utils/statGames.js';
const ERRORS = {403: 'STATS.DRILL.NOT_SHARED', 401: 'STATS.DRILL.EXPIRED'};
@@ -23,6 +26,8 @@ export default function StatGamesDialog({request, onClose, onCloseAutoFocus}) {
const [state, setState] = useState({games: [], page: 0, totalPages: 0, total: 0, untracked: 0});
const [status, setStatus] = useState('loading');
const openRequest = useRef(null);
const {active} = useSession();
const [ownersOf, setOwnersOf] = useState(null);
const load = useCallback(async (page, current) => {
setStatus('loading');
@@ -43,12 +48,22 @@ export default function StatGamesDialog({request, onClose, onCloseAutoFocus}) {
openRequest.current = request;
if (!request) return;
setState({games: [], page: 0, totalPages: 0, total: 0, untracked: 0});
setOwnersOf(null);
load(0, []);
}, [request, load]);
const value = (game) => {
if (request.source === 'community') {
return `${t('STATS.DRILL.IN_LIBRARIES', {count: game.libraries})} · ${formatPlaytime(game.minutes)}`;
const libraries = t('STATS.DRILL.IN_LIBRARIES', {count: game.libraries});
if (!active) return `${libraries} · ${formatPlaytime(game.minutes)}`;
return (
<>
<button type="button" aria-expanded={ownersOf === game.appid} title={t('PEOPLE.SHOW_OWNERS')}
className="min-h-11 underline decoration-dotted underline-offset-4 hover:text-primary"
onClick={() => setOwnersOf(ownersOf === game.appid ? null : game.appid)}>{libraries}</button>
<span>· {formatPlaytime(game.minutes)}</span>
</>
);
}
const moved = game.fromStatus || game.toStatus;
return (
@@ -78,6 +93,11 @@ export default function StatGamesDialog({request, onClose, onCloseAutoFocus}) {
{state.games.map((game, index) => (
<li key={`${game.appid}-${index}`}>
<GameRow game={game} value={value(game)} linked={request.source === 'own' || request.linked === true}/>
{ownersOf === game.appid && (
<div className="px-2 pt-2">
<PeopleList load={(page) => fetchOwners({appid: game.appid, ...request.params, page})}/>
</div>
)}
</li>
))}
</ul>
+32 -1
View File
@@ -7,6 +7,8 @@ import {StatCount, StatDrillProvider} from './StatDrill.jsx';
import {fetchRoutes, json} from '../../test/fetchRoutes.js';
vi.mock('react-i18next', () => import('../../test/i18n-mock.js'));
const session = vi.hoisted(() => ({active: false}));
vi.mock('../../hooks/useSession.js', () => ({default: () => session}));
const GAMES = '/api/library/stats/games';
const rows = (from, count, extra = {}) => Array.from({length: count}, (_, i) => (
@@ -19,7 +21,10 @@ const renderDialog = (request, onClose = () => {}) => render(
onClose={onClose}/></MemoryRouter>);
describe('StatGamesDialog', () => {
afterEach(cleanup);
afterEach(() => {
cleanup();
session.active = false;
});
it('loadsPageZeroAndAppendsTheNextOne', async () => {
fetchRoutes({[`GET ${GAMES}`]: (url) => url.includes('page=1')
@@ -100,6 +105,32 @@ describe('StatGamesDialog', () => {
expect(screen.getByText(/BACKLOG\.STATUS\.BACKLOG → BACKLOG\.STATUS\.PLAYING/)).toBeTruthy();
});
it('offersNoOwnersWithoutASession', async () => {
fetchRoutes({'GET /api/stats/public/games': page([{appid: 7, name: 'Shared', libraries: 3, minutes: 120}], 0, 1, 1)});
renderDialog({source: 'community', params: {key: 'OWNED'}});
await screen.findByText('Shared');
expect(screen.queryByRole('button', {name: /IN_LIBRARIES/})).toBeNull();
});
it('opensTheOwnersOfACommunityRowWithTheRowsKeyAndArg', async () => {
session.active = true;
fetchRoutes({
'GET /api/stats/public/games': page([{appid: 7, name: 'Shared', libraries: 3, minutes: 120}], 0, 1, 1),
'GET /api/stats/owners': json(200, {listed: [{displayName: 'Lobo'}], listedTotal: 1, privateCount: 1,
includesYou: true, page: 0, totalPages: 1}),
});
renderDialog({source: 'community', params: {key: 'GENRE', arg: 'Shooter'}});
await screen.findByText('Shared');
fireEvent.click(screen.getByRole('button', {name: 'STATS.DRILL.IN_LIBRARIES:3'}));
expect(await screen.findByRole('link', {name: 'Lobo'})).toBeTruthy();
expect(screen.getByText('PEOPLE.YOU')).toBeTruthy();
expect(fetch.mock.calls.at(-1)[0]).toContain('/api/stats/owners?appid=7&key=GENRE&arg=Shooter&page=0');
});
it('showsTheLibraryCountOfACommunityRow', async () => {
fetchRoutes({'GET /api/stats/public/games': page([{appid: 7, name: 'Shared', libraries: 3, minutes: 120}], 0, 1, 1)});
renderDialog({source: 'community', params: {key: 'OWNED'}});
+13
View File
@@ -41,3 +41,16 @@ describe('translation keys', () => {
expect(read('de.json')).toEqual(read('en.json'));
});
});
describe('people texts', () => {
it('otherOwnersUsesTheSingularForOne', async () => {
const {createInstance} = await import('i18next');
const dir = path.resolve(import.meta.dirname, '../public/i18n');
const read = (file) => JSON.parse(readFileSync(path.join(dir, file), 'utf8'));
const i18n = createInstance();
await i18n.init({lng: 'en', resources: {en: {translation: read('en.json')}, de: {translation: read('de.json')}}});
expect(i18n.t('PEOPLE.OTHER_OWNERS', {count: 1})).toBe('1 other owner');
expect(i18n.t('PEOPLE.OTHER_OWNERS', {count: 1, lng: 'de'})).toBe('1 anderer Besitzer');
});
});
+1
View File
@@ -17,6 +17,7 @@ const game = {
const renderDetail = (status) => {
fetchRoutes({
'GET /api/users/me': json(200, {settings: {}}),
'GET /api/stats/owners': json(200, {listed: [], listedTotal: 0, privateCount: 0, includesYou: false, page: 0, totalPages: 1}),
'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}),
});
+3
View File
@@ -15,6 +15,7 @@ import CompletedHint from '../components/CompletedHint.jsx';
import FinishedHint from '../components/FinishedHint.jsx';
import GameCorrectionDialog from '../components/GameCorrectionDialog.jsx';
import EditionsCard from '../components/EditionsCard.jsx';
import OwnersCard from '../components/people/OwnersCard.jsx';
import GameMergeDialog from '../components/GameMergeDialog.jsx';
import LinkGameDialog from '../components/LinkGameDialog.jsx';
import PlayingHint from '../components/PlayingHint.jsx';
@@ -100,6 +101,8 @@ export default function GameDetail() {
<EditionsCard game={data} auditor={auditor} onRequest={() => setLinking(true)} onChanged={reload}/>
<OwnersCard key={`owners-${data.appid}`} appid={data.appid}/>
{manual && (
<ManualGameCard key={`manual-${data.appid}`} game={data}
onSaved={(changes) => setData((d) => ({...d, ...changes}))}/>
+1
View File
@@ -22,6 +22,7 @@ beforeEach(() => {
localStorage.setItem('tbp_session', '1');
fetchRoutes({
'GET /api/users/me': json(200, {role: 'AUDITOR', activeRole: 'AUDITOR'}),
'GET /api/stats/owners': json(200, {listed: [], listedTotal: 0, privateCount: 0, includesYou: false, page: 0, totalPages: 1}),
'GET /api/library/games/220': json(200, game),
});
});
+23
View File
@@ -23,6 +23,7 @@ const echo = (url, init) => json(200, JSON.parse(init.body));
// The user and the game at /library/<shown.appid>; `routes` adds or replaces answers.
const answer = (shown, routes = {}, me = {settings: {}}) => fetchRoutes({
'GET /api/users/me': json(200, me),
'GET /api/stats/owners': json(200, {listed: [], listedTotal: 0, privateCount: 0, includesYou: false, page: 0, totalPages: 1}),
[`GET /api/library/games/${shown.appid}`]: json(200, shown),
...routes,
});
@@ -38,6 +39,27 @@ afterEach(() => {
});
describe('GameDetail', () => {
it('showsHowManyOthersOwnTheGameAndOnlyNamesTheListedOnes', async () => {
answer(game, {'GET /api/stats/owners': json(200, {listed: [{displayName: 'Lobo'}], listedTotal: 1, privateCount: 1,
includesYou: true, page: 0, totalPages: 1})});
renderDetail();
expect(await screen.findByText('PEOPLE.OTHER_OWNERS:2')).toBeTruthy();
expect(screen.getByRole('link', {name: 'Lobo'}).getAttribute('href')).toBe('/u/Lobo');
expect(screen.getByText('PEOPLE.PRIVATE:1')).toBeTruthy();
expect(screen.queryByText('PEOPLE.YOU')).toBeNull();
expect(fetch.mock.calls.find(([url]) => url.includes('/api/stats/owners'))[0]).toContain('appid=220&page=0');
});
it('showsNoOwnersTitleWhenNobodyElseOwnsTheGame', async () => {
answer(game, {'GET /api/stats/owners': json(200, {listed: [], listedTotal: 0, privateCount: 0,
includesYou: true, page: 0, totalPages: 0})});
renderDetail();
expect(await screen.findByText('PEOPLE.NONE')).toBeTruthy();
expect(screen.queryByText(/PEOPLE.OTHER_OWNERS/)).toBeNull();
});
it('shows the coming badge of a game that is not released yet', async () => {
answer({...game, source: 'STEAM', coming: true, comingOn: '2026-12-04'});
renderDetail();
@@ -447,6 +469,7 @@ describe('GameDetail session', () => {
it('replacesTheUrlOfAMergedKeyWithTheKeptGame', async () => {
fetchRoutes({
'GET /api/users/me': json(200, {settings: {}}),
'GET /api/stats/owners': json(200, {listed: [], listedTotal: 0, privateCount: 0, includesYou: false, page: 0, totalPages: 1}),
'GET /api/library/games/-7': json(200, {...game, appid: 220, source: 'STEAM'}),
'GET /api/library/games/220': json(200, {...game, appid: 220, source: 'STEAM'}),
});
+14 -4
View File
@@ -1,3 +1,4 @@
import {useState} from 'react';
import {useTranslation} from 'react-i18next';
import {Link} from 'react-router-dom';
import {
@@ -12,10 +13,12 @@ import GameCover from '../components/GameCover.jsx';
import FitText from '../components/FitText.jsx';
import Logo from '../components/Logo.jsx';
import StatCard from '../components/StatCard.jsx';
import PeopleDialog from '../components/people/PeopleDialog.jsx';
import {YearBars} from '../components/stats/FinishedStats.jsx';
import {TagBars} from '../components/stats/TasteStats.jsx';
import {StatDrillProvider, useStatDrill, useTagDrill} from '../components/stats/StatDrill.jsx';
import useSession from '../hooks/useSession.js';
import {fetchUsers} from '../utils/people.js';
import {genreKey} from '../utils/stats.js';
import usePublicStats from '../hooks/usePublicStats.js';
@@ -97,13 +100,14 @@ export default function LandingPage() {
const {t, i18n} = useTranslation();
const {data, isLoading} = usePublicStats();
const {active} = useSession();
const [people, setPeople] = useState(null);
const auth = data.auth;
const core = data.core;
const stats = [
[t('LANDING.TOTAL_USERS'), auth?.totalUsers, Users],
[t('LANDING.STEAM_LINKED'), auth?.usersWithSteamLinked, Link2],
[t('LANDING.TOTAL_USERS'), auth?.totalUsers, Users, undefined, undefined, 'TOTAL_USERS'],
[t('LANDING.STEAM_LINKED'), auth?.usersWithSteamLinked, Link2, undefined, undefined, 'STEAM_LINKED'],
[t('LANDING.TOTAL_GAMES'), core?.totalLibraryEntries, Gamepad2, undefined, 'ENTRIES'],
[t('LANDING.GAMES_PLAYED'), core?.totalGamesPlayed, Sparkles, undefined, 'PLAYED'],
[t('LANDING.DISTINCT_GAMES'), core?.totalDistinctGames, Blocks, undefined, 'OWNED'],
@@ -139,9 +143,11 @@ export default function LandingPage() {
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
{isLoading
? stats.map(([label]) => <Skeleton key={label} className="h-36 rounded-xl"/>)
: stats.map(([label, value, icon, suffix, key]) => (
: stats.map(([label, value, icon, suffix, key, usersKey]) => (
<StatCard key={label} label={label} value={value ?? 0} icon={icon} suffix={suffix}
drill={key && {params: {key}}}/>
drill={key && {params: {key}}}
onClick={active && usersKey && value > 0
? () => setPeople({title: label, key: usersKey}) : undefined}/>
))}
</div>
@@ -167,6 +173,10 @@ export default function LandingPage() {
<CommunityBars core={core}/>
</section>
</div>
{people && (
<PeopleDialog title={people.title} onClose={() => setPeople(null)}
load={(page) => fetchUsers({key: people.key, page})}/>
)}
</StatDrillProvider>
);
}
+26 -2
View File
@@ -6,7 +6,8 @@ import LandingPage from './LandingPage.jsx';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
vi.mock('../hooks/useSession.js', () => ({default: () => ({active: false})}));
const session = vi.hoisted(() => ({active: false}));
vi.mock('../hooks/useSession.js', () => ({default: () => session}));
// Auth and Core answer the same path; one object serves both shapes.
const answer = (core) => fetchRoutes({'GET /api/stats/public': json(200, {topPlayedGames: [], topOwnedGames: [], ...core})});
@@ -14,7 +15,10 @@ const answer = (core) => fetchRoutes({'GET /api/stats/public': json(200, {topPla
const renderPage = () => render(<MemoryRouter><LandingPage/></MemoryRouter>);
describe('LandingPage', () => {
afterEach(cleanup);
afterEach(() => {
cleanup();
session.active = false;
});
it('shows games per platform, top genres and finished per year', async () => {
answer({
@@ -76,4 +80,24 @@ describe('LandingPage', () => {
expect(screen.queryByRole('button', {name: /LANDING\.TOTAL_GAMES/})).toBeNull();
});
it('opensTheUserListOnlyWithASession', async () => {
fetchRoutes({
'GET /api/stats/public': json(200, {topPlayedGames: [], topOwnedGames: [], totalUsers: 4, usersWithSteamLinked: 2}),
'GET /api/stats/users': json(200, {listed: [{displayName: 'Lobo'}], listedTotal: 1, privateCount: 3,
includesYou: false, page: 0, totalPages: 1}),
});
const {unmount} = renderPage();
await screen.findByText('LANDING.TOTAL_USERS');
expect(screen.queryByRole('button', {name: /LANDING\.TOTAL_USERS/})).toBeNull();
unmount();
session.active = true;
renderPage();
fireEvent.click(await screen.findByRole('button', {name: /LANDING\.TOTAL_USERS/}));
expect(await screen.findByRole('link', {name: 'Lobo'})).toBeTruthy();
expect(screen.getByText('PEOPLE.PRIVATE:3')).toBeTruthy();
expect(fetch.mock.calls.at(-1)[0]).toContain('/api/stats/users?key=TOTAL_USERS&page=0');
});
});
+22
View File
@@ -31,6 +31,7 @@ const authRoutes = (me) => ({
'PUT /api/users/settings/appearance': noBody,
'PUT /api/users/settings/profile-style': noBody,
'PUT /api/users/settings/favorite-platform': noBody,
'PUT /api/users/settings/owner-lists': noBody,
'GET /api/version': json(404, {}),
});
@@ -65,6 +66,27 @@ describe('SettingsPage public profile', () => {
localStorage.clear();
});
it('disablesTheOwnerListSwitchWhileTheProfileIsPrivate', async () => {
renderPage(profile({settings: {useDarkTheme: true, language: 'de', publicProfile: false, showInOwnerLists: true}}));
const toggle = await screen.findByRole('switch', {name: 'SETTINGS.OWNER_LISTS'});
expect(toggle.disabled).toBe(true);
expect(toggle.getAttribute('aria-checked')).toBe('false');
});
it('savesTheOwnerListSwitchThroughItsOwnEndpoint', async () => {
renderPage(profile());
fireEvent.click(await screen.findByRole('switch', {name: 'SETTINGS.OWNER_LISTS'}));
await waitFor(() => expect(screen.getByRole('switch', {name: 'SETTINGS.OWNER_LISTS'}).getAttribute('aria-checked')).toBe('true'));
const call = fetch.mock.calls.find(([url]) => url.endsWith('/settings/owner-lists'));
expect(call[1].method).toBe('PUT');
expect(JSON.parse(call[1].body)).toEqual({enabled: true});
expect(puts().filter((body) => 'useDarkTheme' in body)).toHaveLength(0);
});
it('keeps the public profile when another setting is saved', async () => {
renderPage(profile());
+11
View File
@@ -0,0 +1,11 @@
import {request} from './api.js';
import {query} from './statGames.js';
/** One page of the people behind a number: `{listed: [{displayName}], listedTotal, privateCount, includesYou, page, totalPages}`. */
export const fetchOwners = ({appid, key, arg, page = 0}) =>
request(`${import.meta.env.VITE_CORE_API_URL}/api/stats/owners?${query([['appid', appid], ['key', key], ['arg', arg], ['page', page]])}`);
export const fetchUsers = ({key, page = 0}) =>
request(`${import.meta.env.VITE_API_URL}/api/stats/users?${query([['key', key], ['page', page]])}`);
export const profileHref = (displayName) => `/u/${encodeURIComponent(displayName)}`;
+9
View File
@@ -52,6 +52,15 @@ export const saveSettings = (changes) => {
return result;
};
/** PUT on Auth, 204: show (or hide) the user by name in owner lists. The caller handles the response status. */
export const saveOwnerLists = (enabled) =>
apiFetch(`${import.meta.env.VITE_API_URL}/api/users/settings/owner-lists`, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({enabled}),
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
/** PUT on Auth, 204: the welcome page was seen. The caller handles the response status. */
export const markWelcomeSeen = () =>
apiFetch(`${import.meta.env.VITE_API_URL}/api/users/settings/welcome-seen`, {
+1 -1
View File
@@ -2,7 +2,7 @@ import {request} from './api.js';
const CORE = () => import.meta.env.VITE_CORE_API_URL;
const query = (params) => params
export const query = (params) => params
.filter(([, value]) => value != null && value !== '')
.map(([name, value]) => `${name}=${encodeURIComponent(value)}`)
.join('&');