Public Access
feat(library): add game links, auditor queue, audit log and filter
LinkGameDialog (user request or auditor link), EditionsCard on the details page, LinkQueuePage and AuditLogPage for auditors with paging, hideDuplicates filter and editions badge in the library, audit menu entries, de/en texts.
This commit is contained in:
1 parent
80259cb0e9
commit
f71e8f7aff
21 files changed
+992
-11
No files matched your search
@@ -1,7 +1,7 @@
|
||||
import {useEffect, useState} from 'react';
|
||||
import {Link, NavLink, useLocation, useNavigate} from 'react-router-dom';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {ChartColumn, Coins, Dices, Eye, Languages, LayoutDashboard, Library, LogOut, Mail, Megaphone, Moon, Newspaper, Settings, Shield, Sun} from 'lucide-react';
|
||||
import {ChartColumn, Coins, Dices, Eye, Languages, LayoutDashboard, Library, Link2, LogOut, Mail, Megaphone, Moon, Newspaper, ScrollText, Settings, Shield, Sun} from 'lucide-react';
|
||||
import {toast} from 'sonner';
|
||||
|
||||
import {Badge} from '@/components/ui/badge';
|
||||
@@ -24,6 +24,7 @@ import SteamAvatar from './SteamAvatar.jsx';
|
||||
import useCurrentUser from '../hooks/useCurrentUser.js';
|
||||
import useSteamProfile from '../hooks/useSteamProfile.js';
|
||||
import {fetchPendingCount, ROLES, viewAs} from '../utils/admin.js';
|
||||
import {canCorrect} from '../utils/corrections.js';
|
||||
import {expireSession, markSession} from '../utils/auth.js';
|
||||
|
||||
const NAV_ITEMS = [
|
||||
@@ -155,6 +156,16 @@ export default function AppHeader({isAuthenticated, onLogout}) {
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/whats-new"><Newspaper/>{t('NAV.WHATS_NEW')}</Link>
|
||||
</DropdownMenuItem>
|
||||
{canCorrect(currentUser) && (
|
||||
<>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/audit/links"><Link2/>{t('NAV.LINK_QUEUE')}</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/audit/log"><ScrollText/>{t('NAV.AUDIT_LOG')}</Link>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
{seesAdminPage && (
|
||||
<>
|
||||
<DropdownMenuItem asChild>
|
||||
|
||||
@@ -88,6 +88,16 @@ describe('AppHeader', () => {
|
||||
expect(screen.queryByText('NAV.ADMIN_BROADCASTS')).toBeNull();
|
||||
});
|
||||
|
||||
it('offersTheLinkQueueAndAuditLogToAnAuditorOnly', async () => {
|
||||
loggedInAs({role: 'AUDITOR', activeRole: 'AUDITOR'});
|
||||
await vi.waitFor(() => expect(fetch).toHaveBeenCalledWith(expect.stringContaining('/api/users/me'), expect.anything()));
|
||||
openMenu();
|
||||
|
||||
expect(await screen.findByText('NAV.LINK_QUEUE')).toBeTruthy();
|
||||
expect(screen.getByText('NAV.AUDIT_LOG')).toBeTruthy();
|
||||
expect(screen.queryByText('NAV.ADMIN')).toBeNull();
|
||||
});
|
||||
|
||||
it('offers nothing of that to a user', async () => {
|
||||
loggedInAs({role: 'USER', activeRole: 'USER'});
|
||||
await vi.waitFor(() => expect(fetch).toHaveBeenCalledWith(expect.stringContaining('/api/users/me'), expect.anything()));
|
||||
@@ -97,6 +107,7 @@ describe('AppHeader', () => {
|
||||
expect(await screen.findByText('NAV.SETTINGS')).toBeTruthy();
|
||||
expect(screen.getByText('NAV.WHATS_NEW')).toBeTruthy();
|
||||
expect(screen.queryByText('NAV.ADMIN')).toBeNull();
|
||||
expect(screen.queryByText('NAV.LINK_QUEUE')).toBeNull();
|
||||
expect(screen.queryByText('NAV.ADMIN_NEWS')).toBeNull();
|
||||
expect(screen.queryByText('NAV.ADMIN_BROADCASTS')).toBeNull();
|
||||
expect(screen.queryByText('NAV.VIEW_AS.MENU')).toBeNull();
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import {useState} from 'react';
|
||||
import {Link} from 'react-router-dom';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {Layers, Link2Off} from 'lucide-react';
|
||||
import {toast} from 'sonner';
|
||||
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {Card} from '@/components/ui/card';
|
||||
import GameStatusBadge from './GameStatusBadge.jsx';
|
||||
import {unlinkGame} from '../utils/links.js';
|
||||
import {platformLabel} from '../utils/platforms.js';
|
||||
|
||||
/**
|
||||
* The other editions of this game in the own library, and the buttons to ask for a link (everyone) or to unlink
|
||||
* (auditor). Shown on every game: a request must be possible from any game.
|
||||
*/
|
||||
export default function EditionsCard({game, auditor, onRequest, onChanged}) {
|
||||
const {t} = useTranslation();
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const unlink = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const response = await unlinkGame(game.appid);
|
||||
if (response.status === 401) return;
|
||||
if (response.ok) {
|
||||
toast.success(t('LINKS.UNLINKED'));
|
||||
onChanged();
|
||||
} else {
|
||||
toast.error(t('LINKS.ERROR'));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Unlink error:', err);
|
||||
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="gap-3 p-4">
|
||||
{game.editions?.length > 0 && (
|
||||
<div className="space-y-2 text-sm">
|
||||
<p className="font-medium">{t('LINKS.ALSO_IN_LIBRARY')}</p>
|
||||
<ul className="space-y-1">
|
||||
{game.editions.map((edition) => (
|
||||
<li key={edition.appid} className="flex flex-wrap items-center gap-2">
|
||||
<Link to={`/library/${edition.appid}`} className="hover:text-primary hover:underline">
|
||||
{edition.name}
|
||||
</Link>
|
||||
{platformLabel(edition) && (
|
||||
<span className="text-muted-foreground">({platformLabel(edition)})</span>
|
||||
)}
|
||||
<GameStatusBadge status={edition.status} compact/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="button" variant="outline" size="sm" onClick={onRequest}>
|
||||
<Layers/>{t(auditor ? 'LINKS.LINK_WITH' : 'LINKS.SAME_AS')}
|
||||
</Button>
|
||||
{auditor && game.linked && (
|
||||
<Button type="button" variant="outline" size="sm" disabled={busy} onClick={unlink}>
|
||||
<Link2Off/>{t('LINKS.UNLINK')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import {useEffect, useState} from 'react';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {ArrowLeft, Link2, Loader2, Search, Send} from 'lucide-react';
|
||||
import {toast} from 'sonner';
|
||||
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle} from '@/components/ui/dialog';
|
||||
import {Input} from '@/components/ui/input';
|
||||
import GameCover from './GameCover.jsx';
|
||||
import {linkGames, requestLink, searchOwnGames} from '../utils/links.js';
|
||||
import {MAX_LIBRARY_SEARCH_LENGTH} from '../utils/limits.js';
|
||||
import {platformLabel} from '../utils/platforms.js';
|
||||
|
||||
const SEARCH_DEBOUNCE_MS = 300;
|
||||
const KNOWN_ERRORS = new Set(['SAME_GAME', 'ALREADY_LINKED', 'PAIR_DISMISSED', 'TOO_MANY_REQUESTS', 'GAME_NOT_FOUND']);
|
||||
|
||||
/**
|
||||
* Picks a game of the own library that is the same game as this one. A user sends a request to the auditors; an
|
||||
* auditor links at once.
|
||||
*/
|
||||
export default function LinkGameDialog({game, open, onOpenChange, auditor, onDone}) {
|
||||
const {t} = useTranslation();
|
||||
const [query, setQuery] = useState('');
|
||||
const [hits, setHits] = useState([]);
|
||||
const [picked, setPicked] = useState(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setQuery('');
|
||||
setHits([]);
|
||||
setPicked(null);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !query.trim()) {
|
||||
setHits([]);
|
||||
return undefined;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(async () => {
|
||||
const {ok, data} = await searchOwnGames(query.trim(), controller.signal);
|
||||
if (ok && data) setHits(data.content);
|
||||
}, SEARCH_DEBOUNCE_MS);
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
controller.abort();
|
||||
};
|
||||
}, [open, query]);
|
||||
|
||||
const send = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const response = await (auditor ? linkGames : requestLink)(game.appid, picked.appid);
|
||||
if (response.status === 401) return;
|
||||
if (response.ok) {
|
||||
toast.success(t(auditor ? 'LINKS.LINKED' : 'LINKS.REQUESTED'));
|
||||
onOpenChange(false);
|
||||
if (auditor) onDone();
|
||||
return;
|
||||
}
|
||||
const data = await response.json().catch(() => ({}));
|
||||
toast.error(t(KNOWN_ERRORS.has(data.code) ? `LINKS.ERROR_CODES.${data.code}` : 'LINKS.ERROR'));
|
||||
} catch (err) {
|
||||
console.error('Link error:', err);
|
||||
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const offered = hits.filter((hit) => hit.appid !== game.appid);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('LINKS.SAME_AS')}</DialogTitle>
|
||||
<DialogDescription>{t('LINKS.DIALOG_HINT', {name: game.name})}</DialogDescription>
|
||||
</DialogHeader>
|
||||
{picked ? (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm">
|
||||
{t(auditor ? 'LINKS.LINK_CONFIRM' : 'LINKS.REQUEST_CONFIRM', {name: game.name, other: picked.name})}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="button" variant="outline" disabled={busy} onClick={() => setPicked(null)}>
|
||||
<ArrowLeft/>{t('MERGE.BACK')}
|
||||
</Button>
|
||||
<Button type="button" disabled={busy} onClick={send}>
|
||||
{busy ? <Loader2 className="animate-spin"/> : auditor ? <Link2/> : <Send/>}
|
||||
{t(auditor ? 'LINKS.LINK' : 'LINKS.REQUEST')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"/>
|
||||
<Input type="search" autoFocus value={query} maxLength={MAX_LIBRARY_SEARCH_LENGTH} className="pl-8"
|
||||
placeholder={t('LINKS.SEARCH_PLACEHOLDER')} aria-label={t('LINKS.SEARCH_PLACEHOLDER')}
|
||||
onChange={(e) => setQuery(e.target.value)}/>
|
||||
</div>
|
||||
{query.trim() && offered.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">{t('LINKS.NO_HITS')}</p>
|
||||
)}
|
||||
{offered.length > 0 && (
|
||||
<ul className="divide-y rounded-md border">
|
||||
{offered.map((hit) => (
|
||||
<li key={hit.appid}>
|
||||
<button type="button" onClick={() => setPicked(hit)}
|
||||
className="flex w-full items-center gap-3 px-3 py-2 text-left transition-colors hover:bg-muted/50">
|
||||
<GameCover appid={hit.appid} imageUrl={hit.imageUrl} className="w-16 shrink-0"/>
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-medium">{hit.name}</span>
|
||||
{platformLabel(hit) && (
|
||||
<span className="text-xs text-muted-foreground">{platformLabel(hit)}</span>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
// @vitest-environment jsdom
|
||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
|
||||
import {MemoryRouter} from 'react-router-dom';
|
||||
import {toast} from 'sonner';
|
||||
import LinkGameDialog from './LinkGameDialog.jsx';
|
||||
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
||||
|
||||
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
|
||||
|
||||
const hades = {appid: 1145360, name: 'Hades'};
|
||||
const owned = [
|
||||
{appid: 1145360, name: 'Hades', source: 'STEAM'},
|
||||
{appid: -113112, name: 'Hades (Switch)', source: 'MANUAL', platform: 'Switch'},
|
||||
];
|
||||
|
||||
function renderDialog({auditor = false, post}) {
|
||||
fetchRoutes({
|
||||
'GET /api/library/games': json(200, {content: owned}),
|
||||
'POST /api/library/games/1145360/link-requests': post,
|
||||
'POST /api/library/games/1145360/link': post,
|
||||
});
|
||||
const onDone = vi.fn();
|
||||
render(<MemoryRouter><LinkGameDialog game={hades} open onOpenChange={vi.fn()} auditor={auditor} onDone={onDone}/></MemoryRouter>);
|
||||
return onDone;
|
||||
}
|
||||
|
||||
const pickSwitch = async () => {
|
||||
fireEvent.change(screen.getByRole('searchbox'), {target: {value: 'hades'}});
|
||||
// The own game is not offered.
|
||||
fireEvent.click(await screen.findByRole('button', {name: /Hades \(Switch\)/}));
|
||||
expect(screen.queryByRole('button', {name: /^Hades$/})).toBeNull();
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.setItem('tbp_session', '1');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
localStorage.clear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('LinkGameDialog', () => {
|
||||
it('sendsARequestAfterTheConfirmStep', async () => {
|
||||
renderDialog({post: json(204)});
|
||||
await pickSwitch();
|
||||
|
||||
expect(screen.getByText('LINKS.REQUEST_CONFIRM')).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole('button', {name: 'LINKS.REQUEST'}));
|
||||
|
||||
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('LINKS.REQUESTED'));
|
||||
const [url, init] = fetch.mock.calls.at(-1);
|
||||
expect(url).toContain('/api/library/games/1145360/link-requests');
|
||||
expect(JSON.parse(init.body)).toEqual({otherAppid: -113112});
|
||||
});
|
||||
|
||||
it('linksAtOnceForAnAuditorAndCallsOnDone', async () => {
|
||||
const onDone = renderDialog({auditor: true, post: json(204)});
|
||||
await pickSwitch();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', {name: 'LINKS.LINK'}));
|
||||
|
||||
await waitFor(() => expect(onDone).toHaveBeenCalled());
|
||||
expect(fetch.mock.calls.at(-1)[0]).toMatch(/\/games\/1145360\/link$/);
|
||||
expect(toast.success).toHaveBeenCalledWith('LINKS.LINKED');
|
||||
});
|
||||
|
||||
it('showsTheTooManyRequestsText', async () => {
|
||||
renderDialog({post: json(429, {code: 'TOO_MANY_REQUESTS'})});
|
||||
await pickSwitch();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', {name: 'LINKS.REQUEST'}));
|
||||
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('LINKS.ERROR_CODES.TOO_MANY_REQUESTS'));
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user