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'}});