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

+5
View File
@@ -233,7 +233,12 @@ flowchart LR
Dialog -->|"profile + ticket"| Profile["Core /api/public/profiles/library/stats/games"]
Dialog -->|community| Community["Core /api/stats/public/games"]
Dialog -->|points| Points["Core /api/rewards/.../games"]
Dialog -->|"community row, login"| Owners["Core /api/stats/owners"]
Landing["Landing user tiles, login"] --> Users["Auth /api/stats/users"]
```
- Owner and user lists (`src/components/people/`, `src/utils/people.js`) need a login and show listed users by name
(a link to `/u/<name>`), everyone else as "+N private", the viewer as "You". The setting "show me in owner lists" is
saved on its own (`PUT /api/users/settings/owner-lists`), never through `saveSettings`.
- `useLibrarySync` polls Core's `/api/library/sync-status` while a Steam sync runs, then loads the data again.
- The bookmarklet hands its list over in the URL fragment (base64url JSON), which the browser sends to no server.
The import page reads it once and removes it from the URL. `RequireSession` keeps the fragment on the way
+11
View File
@@ -93,6 +93,8 @@
"ERROR_DISPLAY_NAME_IN_USE": "Dieser Anzeigename ist schon vergeben.",
"PUBLIC_PROFILE": "Öffentliches Profil",
"PUBLIC_PROFILE_HINT": "Jeder mit dem Link sieht deine Statistik, was du gerade spielst und deine durchgespielten Spiele mit Bewertung. Notizen, E-Mail und Steam-ID bleiben privat.",
"OWNER_LISTS": "In Besitzerlisten zeigen",
"OWNER_LISTS_HINT": "Andere angemeldete Nutzer sehen deinen Anzeigenamen mit Link zu deinem öffentlichen Profil, wenn sie nachsehen, wer ein Spiel besitzt oder wer hinter einer Community-Zahl steht. Aus: Du wirst nur als privat mitgezählt. Braucht das öffentliche Profil.",
"PUBLIC_PROFILE_NEEDS_NAME": "Lege zuerst einen Anzeigenamen fest; er wird Teil des Links.",
"PUBLIC_PROFILE_COPY": "Link kopieren",
"PUBLIC_PROFILE_COPIED": "Link kopiert",
@@ -732,6 +734,15 @@
"OTHER": "Die Spiele konnten nicht zusammengeführt werden."
}
},
"PEOPLE": {
"YOU": "Du",
"PRIVATE": "+{{count}} privat",
"NONE": "Keine anderen Besitzer",
"NO_USERS": "Keine anderen Nutzer",
"OTHER_OWNERS_one": "{{count}} anderer Besitzer",
"OTHER_OWNERS_other": "{{count}} andere Besitzer",
"SHOW_OWNERS": "Zeigen, wer es besitzt"
},
"LINKS": {
"ALSO_IN_LIBRARY": "Auch in deiner Bibliothek:",
"SAME_AS": "Dasselbe Spiel wie...",
+11
View File
@@ -93,6 +93,8 @@
"ERROR_DISPLAY_NAME_IN_USE": "This display name is already taken.",
"PUBLIC_PROFILE": "Public profile",
"PUBLIC_PROFILE_HINT": "Anyone with the link sees your stats, what you play right now and your finished games with ratings. Notes, email and Steam ID stay private.",
"OWNER_LISTS": "Show me in owner lists",
"OWNER_LISTS_HINT": "Other logged-in users see your display name with a link to your public profile when they look at who owns a game or who stands behind a community number. Off: you are only counted as private. Needs the public profile.",
"PUBLIC_PROFILE_NEEDS_NAME": "Set a display name first; it becomes part of the link.",
"PUBLIC_PROFILE_COPY": "Copy link",
"PUBLIC_PROFILE_COPIED": "Link copied",
@@ -732,6 +734,15 @@
"OTHER": "The games could not be merged."
}
},
"PEOPLE": {
"YOU": "You",
"PRIVATE": "+{{count}} private",
"NONE": "No other owners",
"NO_USERS": "No other users",
"OTHER_OWNERS_one": "{{count}} other owner",
"OTHER_OWNERS_other": "{{count}} other owners",
"SHOW_OWNERS": "Show who owns it"
},
"LINKS": {
"ALSO_IN_LIBRARY": "Also in your library:",
"SAME_AS": "Same game as...",
+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('&');