diff --git a/README.md b/README.md index 138d622..1bf4fc6 100644 --- a/README.md +++ b/README.md @@ -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/`), 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 diff --git a/public/i18n/de.json b/public/i18n/de.json index 7d8847f..2431009 100644 --- a/public/i18n/de.json +++ b/public/i18n/de.json @@ -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...", diff --git a/public/i18n/en.json b/public/i18n/en.json index 449e3b4..026f4c6 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -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...", diff --git a/src/components/ProfileCard.jsx b/src/components/ProfileCard.jsx index 0fa5f36..ab0e0a7 100644 --- a/src/components/ProfileCard.jsx +++ b/src/components/ProfileCard.jsx @@ -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}) { +
+
+ +

{t('SETTINGS.OWNER_LISTS_HINT')}

+
+ +
{publicProfile && profileLink && (
{Icon && ( diff --git a/src/components/people/OwnersCard.jsx b/src/components/people/OwnersCard.jsx new file mode 100644 index 0000000..88e3b91 --- /dev/null +++ b/src/components/people/OwnersCard.jsx @@ -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 ( + + {count > 0 &&

{t('PEOPLE.OTHER_OWNERS', {count})}

} + +
+ ); +} diff --git a/src/components/people/PeopleDialog.jsx b/src/components/people/PeopleDialog.jsx new file mode 100644 index 0000000..e8d9092 --- /dev/null +++ b/src/components/people/PeopleDialog.jsx @@ -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 ( + !open && onClose()}> + + {title} + {title} + + + + ); +} diff --git a/src/components/people/PeopleList.jsx b/src/components/people/PeopleList.jsx new file mode 100644 index 0000000..bc3e0be --- /dev/null +++ b/src/components/people/PeopleList.jsx @@ -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 ( +
+ {state.includesYou && showYou &&

{t('PEOPLE.YOU')}

} + {state.names.length > 0 && ( +
    + {state.names.map((name, index) => ( +
  • + + + +
  • + ))} +
+ )} + {state.privateCount > 0 && ( +

{t('PEOPLE.PRIVATE', {count: state.privateCount})}

+ )} + {status === 'ok' && state.nothing &&

{t(emptyKey)}

} + {status === 'loading' && } + {status === 'error' && ( +
+

{t('STATS.DRILL.ERROR')}

+ +
+ )} + {status === 'ok' && state.page + 1 < state.totalPages && ( + + )} +
+ ); +} diff --git a/src/components/people/PeopleList.test.jsx b/src/components/people/PeopleList.test.jsx new file mode 100644 index 0000000..435e1d4 --- /dev/null +++ b/src/components/people/PeopleList.test.jsx @@ -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(); + +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( answer()} emptyKey="PEOPLE.NO_USERS"/>); + + 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(); + }); +}); diff --git a/src/components/stats/StatGamesDialog.jsx b/src/components/stats/StatGamesDialog.jsx index 5196832..79caf7e 100644 --- a/src/components/stats/StatGamesDialog.jsx +++ b/src/components/stats/StatGamesDialog.jsx @@ -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 ( + <> + + · {formatPlaytime(game.minutes)} + + ); } const moved = game.fromStatus || game.toStatus; return ( @@ -78,6 +93,11 @@ export default function StatGamesDialog({request, onClose, onCloseAutoFocus}) { {state.games.map((game, index) => (
  • + {ownersOf === game.appid && ( +
    + fetchOwners({appid: game.appid, ...request.params, page})}/> +
    + )}
  • ))} diff --git a/src/components/stats/StatGamesDialog.test.jsx b/src/components/stats/StatGamesDialog.test.jsx index 0b59bcd..96195bd 100644 --- a/src/components/stats/StatGamesDialog.test.jsx +++ b/src/components/stats/StatGamesDialog.test.jsx @@ -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}/>); 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'}}); diff --git a/src/i18n.test.js b/src/i18n.test.js index 8d9deab..7fb0eb8 100644 --- a/src/i18n.test.js +++ b/src/i18n.test.js @@ -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'); + }); +}); diff --git a/src/pages/GameDetail.effects.test.jsx b/src/pages/GameDetail.effects.test.jsx index 3067a2d..73ae823 100644 --- a/src/pages/GameDetail.effects.test.jsx +++ b/src/pages/GameDetail.effects.test.jsx @@ -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}), }); diff --git a/src/pages/GameDetail.jsx b/src/pages/GameDetail.jsx index d365a71..a255076 100644 --- a/src/pages/GameDetail.jsx +++ b/src/pages/GameDetail.jsx @@ -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() { setLinking(true)} onChanged={reload}/> + + {manual && ( setData((d) => ({...d, ...changes}))}/> diff --git a/src/pages/GameDetail.reload.test.jsx b/src/pages/GameDetail.reload.test.jsx index b7b532c..d0c8898 100644 --- a/src/pages/GameDetail.reload.test.jsx +++ b/src/pages/GameDetail.reload.test.jsx @@ -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), }); }); diff --git a/src/pages/GameDetail.test.jsx b/src/pages/GameDetail.test.jsx index 1c65640..6608e1d 100644 --- a/src/pages/GameDetail.test.jsx +++ b/src/pages/GameDetail.test.jsx @@ -23,6 +23,7 @@ const echo = (url, init) => json(200, JSON.parse(init.body)); // The user and the game at /library/; `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'}), }); diff --git a/src/pages/LandingPage.jsx b/src/pages/LandingPage.jsx index 9ca85f4..07ee0ac 100644 --- a/src/pages/LandingPage.jsx +++ b/src/pages/LandingPage.jsx @@ -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() {
    {isLoading ? stats.map(([label]) => ) - : stats.map(([label, value, icon, suffix, key]) => ( + : stats.map(([label, value, icon, suffix, key, usersKey]) => ( + drill={key && {params: {key}}} + onClick={active && usersKey && value > 0 + ? () => setPeople({title: label, key: usersKey}) : undefined}/> ))}
    @@ -167,6 +173,10 @@ export default function LandingPage() {
    + {people && ( + setPeople(null)} + load={(page) => fetchUsers({key: people.key, page})}/> + )} ); } diff --git a/src/pages/LandingPage.test.jsx b/src/pages/LandingPage.test.jsx index 482e532..aa9378d 100644 --- a/src/pages/LandingPage.test.jsx +++ b/src/pages/LandingPage.test.jsx @@ -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(); 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'); + }); }); diff --git a/src/pages/SettingsPage.test.jsx b/src/pages/SettingsPage.test.jsx index ae1a525..f64f670 100644 --- a/src/pages/SettingsPage.test.jsx +++ b/src/pages/SettingsPage.test.jsx @@ -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()); diff --git a/src/utils/people.js b/src/utils/people.js new file mode 100644 index 0000000..f74f289 --- /dev/null +++ b/src/utils/people.js @@ -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)}`; diff --git a/src/utils/settings.js b/src/utils/settings.js index 1a16541..fd678ca 100644 --- a/src/utils/settings.js +++ b/src/utils/settings.js @@ -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`, { diff --git a/src/utils/statGames.js b/src/utils/statGames.js index db3db7d..889bbec 100644 --- a/src/utils/statGames.js +++ b/src/utils/statGames.js @@ -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('&');