Public Access
feat(ui): add people lists for game owners and community stats
This commit is contained in:
1 parent
b213b067dc
commit
3aed243e35
22 files changed
+425
-12
No files matched your search
@@ -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"
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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'}});
|
||||
|
||||
Reference in new issue
Block a user