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
@@ -233,7 +233,12 @@ flowchart LR
|
|||||||
Dialog -->|"profile + ticket"| Profile["Core /api/public/profiles/library/stats/games"]
|
Dialog -->|"profile + ticket"| Profile["Core /api/public/profiles/library/stats/games"]
|
||||||
Dialog -->|community| Community["Core /api/stats/public/games"]
|
Dialog -->|community| Community["Core /api/stats/public/games"]
|
||||||
Dialog -->|points| Points["Core /api/rewards/.../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.
|
- `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 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
|
The import page reads it once and removes it from the URL. `RequireSession` keeps the fragment on the way
|
||||||
|
|||||||
@@ -93,6 +93,8 @@
|
|||||||
"ERROR_DISPLAY_NAME_IN_USE": "Dieser Anzeigename ist schon vergeben.",
|
"ERROR_DISPLAY_NAME_IN_USE": "Dieser Anzeigename ist schon vergeben.",
|
||||||
"PUBLIC_PROFILE": "Öffentliches Profil",
|
"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.",
|
"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_NEEDS_NAME": "Lege zuerst einen Anzeigenamen fest; er wird Teil des Links.",
|
||||||
"PUBLIC_PROFILE_COPY": "Link kopieren",
|
"PUBLIC_PROFILE_COPY": "Link kopieren",
|
||||||
"PUBLIC_PROFILE_COPIED": "Link kopiert",
|
"PUBLIC_PROFILE_COPIED": "Link kopiert",
|
||||||
@@ -732,6 +734,15 @@
|
|||||||
"OTHER": "Die Spiele konnten nicht zusammengeführt werden."
|
"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": {
|
"LINKS": {
|
||||||
"ALSO_IN_LIBRARY": "Auch in deiner Bibliothek:",
|
"ALSO_IN_LIBRARY": "Auch in deiner Bibliothek:",
|
||||||
"SAME_AS": "Dasselbe Spiel wie...",
|
"SAME_AS": "Dasselbe Spiel wie...",
|
||||||
|
|||||||
@@ -93,6 +93,8 @@
|
|||||||
"ERROR_DISPLAY_NAME_IN_USE": "This display name is already taken.",
|
"ERROR_DISPLAY_NAME_IN_USE": "This display name is already taken.",
|
||||||
"PUBLIC_PROFILE": "Public profile",
|
"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.",
|
"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_NEEDS_NAME": "Set a display name first; it becomes part of the link.",
|
||||||
"PUBLIC_PROFILE_COPY": "Copy link",
|
"PUBLIC_PROFILE_COPY": "Copy link",
|
||||||
"PUBLIC_PROFILE_COPIED": "Link copied",
|
"PUBLIC_PROFILE_COPIED": "Link copied",
|
||||||
@@ -732,6 +734,15 @@
|
|||||||
"OTHER": "The games could not be merged."
|
"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": {
|
"LINKS": {
|
||||||
"ALSO_IN_LIBRARY": "Also in your library:",
|
"ALSO_IN_LIBRARY": "Also in your library:",
|
||||||
"SAME_AS": "Same game as...",
|
"SAME_AS": "Same game as...",
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import {useCurrentUserContext} from '../context/CurrentUserContext.jsx';
|
|||||||
import usePersistSettings from '../hooks/usePersistSettings.js';
|
import usePersistSettings from '../hooks/usePersistSettings.js';
|
||||||
import useSaveProfileStyle from '../hooks/useSaveProfileStyle.js';
|
import useSaveProfileStyle from '../hooks/useSaveProfileStyle.js';
|
||||||
import {normalizeProfileStyle} from '../utils/profileStyle.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 {settingsSchema} from '../utils/validation.js';
|
||||||
import {MAX_DISPLAY_NAME_LENGTH} from '../utils/limits.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.
|
// Shown at once; the pickers wait for the answer, so the saves reach Auth one after the other.
|
||||||
const handleProfileStyleChange = async (changes) => {
|
const handleProfileStyleChange = async (changes) => {
|
||||||
const previous = normalizeProfileStyle(profile?.settings?.profileStyle);
|
const previous = normalizeProfileStyle(profile?.settings?.profileStyle);
|
||||||
@@ -113,6 +121,14 @@ export default function ProfileCard({displayName}) {
|
|||||||
<Switch id="publicProfile" checked={publicProfile} disabled={!profileLink}
|
<Switch id="publicProfile" checked={publicProfile} disabled={!profileLink}
|
||||||
onCheckedChange={handlePublicProfileToggle}/>
|
onCheckedChange={handlePublicProfileToggle}/>
|
||||||
</div>
|
</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 && (
|
{publicProfile && profileLink && (
|
||||||
<div className="flex items-center gap-2 rounded-md bg-muted px-3 py-2 text-sm">
|
<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"
|
<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 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 {i18n} = useTranslation();
|
||||||
const animated = useCountUp(value);
|
const animated = useCountUp(value);
|
||||||
const target = useDrillTarget({title: label, count: value, ...drill});
|
const target = useDrillTarget({title: label, count: value, ...drill}) ?? (onClick && {onClick});
|
||||||
const card = (
|
const card = (
|
||||||
<Card className="gap-3 p-5">
|
<Card className="gap-3 p-5">
|
||||||
{Icon && (
|
{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 GameRow from './GameRow.jsx';
|
||||||
import ComingBadge from '../ComingBadge.jsx';
|
import ComingBadge from '../ComingBadge.jsx';
|
||||||
import GameStatusBadge from '../GameStatusBadge.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 {formatDate} from '../../utils/format.js';
|
||||||
import {formatPlaytime} from '../../utils/games.js';
|
import {formatPlaytime} from '../../utils/games.js';
|
||||||
|
import {fetchOwners} from '../../utils/people.js';
|
||||||
import {fetchStatGames} from '../../utils/statGames.js';
|
import {fetchStatGames} from '../../utils/statGames.js';
|
||||||
|
|
||||||
const ERRORS = {403: 'STATS.DRILL.NOT_SHARED', 401: 'STATS.DRILL.EXPIRED'};
|
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 [state, setState] = useState({games: [], page: 0, totalPages: 0, total: 0, untracked: 0});
|
||||||
const [status, setStatus] = useState('loading');
|
const [status, setStatus] = useState('loading');
|
||||||
const openRequest = useRef(null);
|
const openRequest = useRef(null);
|
||||||
|
const {active} = useSession();
|
||||||
|
const [ownersOf, setOwnersOf] = useState(null);
|
||||||
|
|
||||||
const load = useCallback(async (page, current) => {
|
const load = useCallback(async (page, current) => {
|
||||||
setStatus('loading');
|
setStatus('loading');
|
||||||
@@ -43,12 +48,22 @@ export default function StatGamesDialog({request, onClose, onCloseAutoFocus}) {
|
|||||||
openRequest.current = request;
|
openRequest.current = request;
|
||||||
if (!request) return;
|
if (!request) return;
|
||||||
setState({games: [], page: 0, totalPages: 0, total: 0, untracked: 0});
|
setState({games: [], page: 0, totalPages: 0, total: 0, untracked: 0});
|
||||||
|
setOwnersOf(null);
|
||||||
load(0, []);
|
load(0, []);
|
||||||
}, [request, load]);
|
}, [request, load]);
|
||||||
|
|
||||||
const value = (game) => {
|
const value = (game) => {
|
||||||
if (request.source === 'community') {
|
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;
|
const moved = game.fromStatus || game.toStatus;
|
||||||
return (
|
return (
|
||||||
@@ -78,6 +93,11 @@ export default function StatGamesDialog({request, onClose, onCloseAutoFocus}) {
|
|||||||
{state.games.map((game, index) => (
|
{state.games.map((game, index) => (
|
||||||
<li key={`${game.appid}-${index}`}>
|
<li key={`${game.appid}-${index}`}>
|
||||||
<GameRow game={game} value={value(game)} linked={request.source === 'own' || request.linked === true}/>
|
<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>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import {StatCount, StatDrillProvider} from './StatDrill.jsx';
|
|||||||
import {fetchRoutes, json} from '../../test/fetchRoutes.js';
|
import {fetchRoutes, json} from '../../test/fetchRoutes.js';
|
||||||
|
|
||||||
vi.mock('react-i18next', () => import('../../test/i18n-mock.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 GAMES = '/api/library/stats/games';
|
||||||
const rows = (from, count, extra = {}) => Array.from({length: count}, (_, i) => (
|
const rows = (from, count, extra = {}) => Array.from({length: count}, (_, i) => (
|
||||||
@@ -19,7 +21,10 @@ const renderDialog = (request, onClose = () => {}) => render(
|
|||||||
onClose={onClose}/></MemoryRouter>);
|
onClose={onClose}/></MemoryRouter>);
|
||||||
|
|
||||||
describe('StatGamesDialog', () => {
|
describe('StatGamesDialog', () => {
|
||||||
afterEach(cleanup);
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
session.active = false;
|
||||||
|
});
|
||||||
|
|
||||||
it('loadsPageZeroAndAppendsTheNextOne', async () => {
|
it('loadsPageZeroAndAppendsTheNextOne', async () => {
|
||||||
fetchRoutes({[`GET ${GAMES}`]: (url) => url.includes('page=1')
|
fetchRoutes({[`GET ${GAMES}`]: (url) => url.includes('page=1')
|
||||||
@@ -100,6 +105,32 @@ describe('StatGamesDialog', () => {
|
|||||||
expect(screen.getByText(/BACKLOG\.STATUS\.BACKLOG → BACKLOG\.STATUS\.PLAYING/)).toBeTruthy();
|
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 () => {
|
it('showsTheLibraryCountOfACommunityRow', async () => {
|
||||||
fetchRoutes({'GET /api/stats/public/games': page([{appid: 7, name: 'Shared', libraries: 3, minutes: 120}], 0, 1, 1)});
|
fetchRoutes({'GET /api/stats/public/games': page([{appid: 7, name: 'Shared', libraries: 3, minutes: 120}], 0, 1, 1)});
|
||||||
renderDialog({source: 'community', params: {key: 'OWNED'}});
|
renderDialog({source: 'community', params: {key: 'OWNED'}});
|
||||||
|
|||||||
@@ -41,3 +41,16 @@ describe('translation keys', () => {
|
|||||||
expect(read('de.json')).toEqual(read('en.json'));
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -17,6 +17,7 @@ const game = {
|
|||||||
const renderDetail = (status) => {
|
const renderDetail = (status) => {
|
||||||
fetchRoutes({
|
fetchRoutes({
|
||||||
'GET /api/users/me': json(200, {settings: {}}),
|
'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/rewards/wallet': json(200, {balance: 0, catalog: [], owned: [], equipped: {COMPLETED_EFFECT: 'FX_GOLD_SHINE'}, history: []}),
|
||||||
'GET /api/library/games/220': json(200, {...game, status}),
|
'GET /api/library/games/220': json(200, {...game, status}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import CompletedHint from '../components/CompletedHint.jsx';
|
|||||||
import FinishedHint from '../components/FinishedHint.jsx';
|
import FinishedHint from '../components/FinishedHint.jsx';
|
||||||
import GameCorrectionDialog from '../components/GameCorrectionDialog.jsx';
|
import GameCorrectionDialog from '../components/GameCorrectionDialog.jsx';
|
||||||
import EditionsCard from '../components/EditionsCard.jsx';
|
import EditionsCard from '../components/EditionsCard.jsx';
|
||||||
|
import OwnersCard from '../components/people/OwnersCard.jsx';
|
||||||
import GameMergeDialog from '../components/GameMergeDialog.jsx';
|
import GameMergeDialog from '../components/GameMergeDialog.jsx';
|
||||||
import LinkGameDialog from '../components/LinkGameDialog.jsx';
|
import LinkGameDialog from '../components/LinkGameDialog.jsx';
|
||||||
import PlayingHint from '../components/PlayingHint.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}/>
|
<EditionsCard game={data} auditor={auditor} onRequest={() => setLinking(true)} onChanged={reload}/>
|
||||||
|
|
||||||
|
<OwnersCard key={`owners-${data.appid}`} appid={data.appid}/>
|
||||||
|
|
||||||
{manual && (
|
{manual && (
|
||||||
<ManualGameCard key={`manual-${data.appid}`} game={data}
|
<ManualGameCard key={`manual-${data.appid}`} game={data}
|
||||||
onSaved={(changes) => setData((d) => ({...d, ...changes}))}/>
|
onSaved={(changes) => setData((d) => ({...d, ...changes}))}/>
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ beforeEach(() => {
|
|||||||
localStorage.setItem('tbp_session', '1');
|
localStorage.setItem('tbp_session', '1');
|
||||||
fetchRoutes({
|
fetchRoutes({
|
||||||
'GET /api/users/me': json(200, {role: 'AUDITOR', activeRole: 'AUDITOR'}),
|
'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),
|
'GET /api/library/games/220': json(200, game),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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.
|
// The user and the game at /library/<shown.appid>; `routes` adds or replaces answers.
|
||||||
const answer = (shown, routes = {}, me = {settings: {}}) => fetchRoutes({
|
const answer = (shown, routes = {}, me = {settings: {}}) => fetchRoutes({
|
||||||
'GET /api/users/me': json(200, me),
|
'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),
|
[`GET /api/library/games/${shown.appid}`]: json(200, shown),
|
||||||
...routes,
|
...routes,
|
||||||
});
|
});
|
||||||
@@ -38,6 +39,27 @@ afterEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('GameDetail', () => {
|
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 () => {
|
it('shows the coming badge of a game that is not released yet', async () => {
|
||||||
answer({...game, source: 'STEAM', coming: true, comingOn: '2026-12-04'});
|
answer({...game, source: 'STEAM', coming: true, comingOn: '2026-12-04'});
|
||||||
renderDetail();
|
renderDetail();
|
||||||
@@ -447,6 +469,7 @@ describe('GameDetail session', () => {
|
|||||||
it('replacesTheUrlOfAMergedKeyWithTheKeptGame', async () => {
|
it('replacesTheUrlOfAMergedKeyWithTheKeptGame', async () => {
|
||||||
fetchRoutes({
|
fetchRoutes({
|
||||||
'GET /api/users/me': json(200, {settings: {}}),
|
'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/-7': json(200, {...game, appid: 220, source: 'STEAM'}),
|
||||||
'GET /api/library/games/220': json(200, {...game, appid: 220, source: 'STEAM'}),
|
'GET /api/library/games/220': json(200, {...game, appid: 220, source: 'STEAM'}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import {useState} from 'react';
|
||||||
import {useTranslation} from 'react-i18next';
|
import {useTranslation} from 'react-i18next';
|
||||||
import {Link} from 'react-router-dom';
|
import {Link} from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
@@ -12,10 +13,12 @@ import GameCover from '../components/GameCover.jsx';
|
|||||||
import FitText from '../components/FitText.jsx';
|
import FitText from '../components/FitText.jsx';
|
||||||
import Logo from '../components/Logo.jsx';
|
import Logo from '../components/Logo.jsx';
|
||||||
import StatCard from '../components/StatCard.jsx';
|
import StatCard from '../components/StatCard.jsx';
|
||||||
|
import PeopleDialog from '../components/people/PeopleDialog.jsx';
|
||||||
import {YearBars} from '../components/stats/FinishedStats.jsx';
|
import {YearBars} from '../components/stats/FinishedStats.jsx';
|
||||||
import {TagBars} from '../components/stats/TasteStats.jsx';
|
import {TagBars} from '../components/stats/TasteStats.jsx';
|
||||||
import {StatDrillProvider, useStatDrill, useTagDrill} from '../components/stats/StatDrill.jsx';
|
import {StatDrillProvider, useStatDrill, useTagDrill} from '../components/stats/StatDrill.jsx';
|
||||||
import useSession from '../hooks/useSession.js';
|
import useSession from '../hooks/useSession.js';
|
||||||
|
import {fetchUsers} from '../utils/people.js';
|
||||||
import {genreKey} from '../utils/stats.js';
|
import {genreKey} from '../utils/stats.js';
|
||||||
|
|
||||||
import usePublicStats from '../hooks/usePublicStats.js';
|
import usePublicStats from '../hooks/usePublicStats.js';
|
||||||
@@ -97,13 +100,14 @@ export default function LandingPage() {
|
|||||||
const {t, i18n} = useTranslation();
|
const {t, i18n} = useTranslation();
|
||||||
const {data, isLoading} = usePublicStats();
|
const {data, isLoading} = usePublicStats();
|
||||||
const {active} = useSession();
|
const {active} = useSession();
|
||||||
|
const [people, setPeople] = useState(null);
|
||||||
|
|
||||||
const auth = data.auth;
|
const auth = data.auth;
|
||||||
const core = data.core;
|
const core = data.core;
|
||||||
|
|
||||||
const stats = [
|
const stats = [
|
||||||
[t('LANDING.TOTAL_USERS'), auth?.totalUsers, Users],
|
[t('LANDING.TOTAL_USERS'), auth?.totalUsers, Users, undefined, undefined, 'TOTAL_USERS'],
|
||||||
[t('LANDING.STEAM_LINKED'), auth?.usersWithSteamLinked, Link2],
|
[t('LANDING.STEAM_LINKED'), auth?.usersWithSteamLinked, Link2, undefined, undefined, 'STEAM_LINKED'],
|
||||||
[t('LANDING.TOTAL_GAMES'), core?.totalLibraryEntries, Gamepad2, undefined, 'ENTRIES'],
|
[t('LANDING.TOTAL_GAMES'), core?.totalLibraryEntries, Gamepad2, undefined, 'ENTRIES'],
|
||||||
[t('LANDING.GAMES_PLAYED'), core?.totalGamesPlayed, Sparkles, undefined, 'PLAYED'],
|
[t('LANDING.GAMES_PLAYED'), core?.totalGamesPlayed, Sparkles, undefined, 'PLAYED'],
|
||||||
[t('LANDING.DISTINCT_GAMES'), core?.totalDistinctGames, Blocks, undefined, 'OWNED'],
|
[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">
|
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||||
{isLoading
|
{isLoading
|
||||||
? stats.map(([label]) => <Skeleton key={label} className="h-36 rounded-xl"/>)
|
? 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}
|
<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>
|
</div>
|
||||||
|
|
||||||
@@ -167,6 +173,10 @@ export default function LandingPage() {
|
|||||||
<CommunityBars core={core}/>
|
<CommunityBars core={core}/>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
{people && (
|
||||||
|
<PeopleDialog title={people.title} onClose={() => setPeople(null)}
|
||||||
|
load={(page) => fetchUsers({key: people.key, page})}/>
|
||||||
|
)}
|
||||||
</StatDrillProvider>
|
</StatDrillProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -6,7 +6,8 @@ import LandingPage from './LandingPage.jsx';
|
|||||||
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
||||||
|
|
||||||
vi.mock('react-i18next', () => import('../test/i18n-mock.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.
|
// 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})});
|
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>);
|
const renderPage = () => render(<MemoryRouter><LandingPage/></MemoryRouter>);
|
||||||
|
|
||||||
describe('LandingPage', () => {
|
describe('LandingPage', () => {
|
||||||
afterEach(cleanup);
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
session.active = false;
|
||||||
|
});
|
||||||
|
|
||||||
it('shows games per platform, top genres and finished per year', async () => {
|
it('shows games per platform, top genres and finished per year', async () => {
|
||||||
answer({
|
answer({
|
||||||
@@ -76,4 +80,24 @@ describe('LandingPage', () => {
|
|||||||
|
|
||||||
expect(screen.queryByRole('button', {name: /LANDING\.TOTAL_GAMES/})).toBeNull();
|
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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -31,6 +31,7 @@ const authRoutes = (me) => ({
|
|||||||
'PUT /api/users/settings/appearance': noBody,
|
'PUT /api/users/settings/appearance': noBody,
|
||||||
'PUT /api/users/settings/profile-style': noBody,
|
'PUT /api/users/settings/profile-style': noBody,
|
||||||
'PUT /api/users/settings/favorite-platform': noBody,
|
'PUT /api/users/settings/favorite-platform': noBody,
|
||||||
|
'PUT /api/users/settings/owner-lists': noBody,
|
||||||
'GET /api/version': json(404, {}),
|
'GET /api/version': json(404, {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -65,6 +66,27 @@ describe('SettingsPage public profile', () => {
|
|||||||
localStorage.clear();
|
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 () => {
|
it('keeps the public profile when another setting is saved', async () => {
|
||||||
renderPage(profile());
|
renderPage(profile());
|
||||||
|
|
||||||
|
|||||||
@@ -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)}`;
|
||||||
@@ -52,6 +52,15 @@ export const saveSettings = (changes) => {
|
|||||||
return result;
|
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. */
|
/** PUT on Auth, 204: the welcome page was seen. The caller handles the response status. */
|
||||||
export const markWelcomeSeen = () =>
|
export const markWelcomeSeen = () =>
|
||||||
apiFetch(`${import.meta.env.VITE_API_URL}/api/users/settings/welcome-seen`, {
|
apiFetch(`${import.meta.env.VITE_API_URL}/api/users/settings/welcome-seen`, {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import {request} from './api.js';
|
|||||||
|
|
||||||
const CORE = () => import.meta.env.VITE_CORE_API_URL;
|
const CORE = () => import.meta.env.VITE_CORE_API_URL;
|
||||||
|
|
||||||
const query = (params) => params
|
export const query = (params) => params
|
||||||
.filter(([, value]) => value != null && value !== '')
|
.filter(([, value]) => value != null && value !== '')
|
||||||
.map(([name, value]) => `${name}=${encodeURIComponent(value)}`)
|
.map(([name, value]) => `${name}=${encodeURIComponent(value)}`)
|
||||||
.join('&');
|
.join('&');
|
||||||
|
|||||||
Reference in new issue
Block a user