Public Access
feat(audit): game catalog search and time to beat correction
New page /audit/games: search every game and open the correction dialog without owning it. Time to beat as hours + minutes. Audit log game links open /audit/games?appid=. Menu entry next to the link queue.
This commit is contained in:
1 parent
6627a8db66
commit
0c300c455d
14 files changed
+439
-19
No files matched your search
@@ -11,12 +11,13 @@ import {fetchAuditLog} from '../utils/links.js';
|
||||
import {formatDateTime} from '../utils/format.js';
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
const ACTIONS = ['GAME_MERGE', 'GAME_LINK', 'GAME_UNLINK', 'GAME_LINK_DISMISS'];
|
||||
const ACTIONS = ['GAME_MERGE', 'GAME_LINK', 'GAME_UNLINK', 'GAME_LINK_DISMISS', 'GAME_CORRECT',
|
||||
'GAME_CORRECTION_RESET'];
|
||||
const ALL = 'ALL';
|
||||
|
||||
function GameRef({appid, name, fallback}) {
|
||||
if (appid == null) return null;
|
||||
return <Link to={`/library/${appid}`} className="font-medium hover:text-primary hover:underline">{name ?? fallback}</Link>;
|
||||
return <Link to={`/audit/games?appid=${appid}`} className="font-medium hover:text-primary hover:underline">{name ?? fallback}</Link>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -33,7 +33,7 @@ describe('AuditLogPage', () => {
|
||||
|
||||
expect(await screen.findByText('lobo')).toBeTruthy();
|
||||
expect(screen.getByText('AUDIT.ACTION.GAME_LINK')).toBeTruthy();
|
||||
expect(screen.getByRole('link', {name: 'Dark Souls'}).getAttribute('href')).toBe('/library/10');
|
||||
expect(screen.getByRole('link', {name: 'Dark Souls'}).getAttribute('href')).toBe('/audit/games?appid=10');
|
||||
});
|
||||
|
||||
it('fallsBackToTheUserIdAndTheDetailForAMergedKey', async () => {
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import {useCallback, useEffect, useRef, useState} from 'react';
|
||||
import {useSearchParams} from 'react-router-dom';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {ChevronLeft, ChevronRight, Loader2, Search} from 'lucide-react';
|
||||
import {toast} from 'sonner';
|
||||
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
|
||||
import {Input} from '@/components/ui/input';
|
||||
import GameCorrectionDialog from '../components/GameCorrectionDialog.jsx';
|
||||
import GameCover from '../components/GameCover.jsx';
|
||||
import PageContainer from '../components/PageContainer.jsx';
|
||||
import {fetchCatalogGame, searchCatalog} from '../utils/corrections.js';
|
||||
import {MAX_LIBRARY_SEARCH_LENGTH} from '../utils/limits.js';
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
const SEARCH_DEBOUNCE_MS = 300;
|
||||
|
||||
/**
|
||||
* AUDITOR or higher: finds any game some user has and opens the correction dialog for it. ?appid= opens a game
|
||||
* directly (the audit log links here). Core checks the role.
|
||||
*/
|
||||
export default function GameCatalogPage() {
|
||||
const {t} = useTranslation();
|
||||
const [params, setParams] = useSearchParams();
|
||||
const openAppid = params.get('appid');
|
||||
const [query, setQuery] = useState('');
|
||||
const [term, setTerm] = useState('');
|
||||
const [page, setPage] = useState(0);
|
||||
const [result, setResult] = useState({content: [], totalPages: 0});
|
||||
const [status, setStatus] = useState('loading');
|
||||
const [detail, setDetail] = useState(null);
|
||||
const [reloads, setReloads] = useState(0);
|
||||
// The answer for a game that is no longer the open one (Esc before it came) is dropped.
|
||||
const openRef = useRef(openAppid);
|
||||
useEffect(() => {
|
||||
openRef.current = openAppid;
|
||||
}, [openAppid]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
if (query.trim() !== term) {
|
||||
setTerm(query.trim());
|
||||
setPage(0);
|
||||
}
|
||||
}, SEARCH_DEBOUNCE_MS);
|
||||
return () => clearTimeout(timer);
|
||||
}, [query, term]);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
(async () => {
|
||||
try {
|
||||
const response = await searchCatalog({q: term, page, size: PAGE_SIZE}, controller.signal);
|
||||
if (response.status === 401) return;
|
||||
if (response.status === 403) {
|
||||
setStatus('forbidden');
|
||||
return;
|
||||
}
|
||||
if (!response.ok) {
|
||||
setStatus('error');
|
||||
return;
|
||||
}
|
||||
setResult(await response.json());
|
||||
setStatus('ready');
|
||||
} catch (err) {
|
||||
if (err.name === 'AbortError') return;
|
||||
console.error('Catalog error:', err);
|
||||
setStatus('error');
|
||||
}
|
||||
})();
|
||||
return () => controller.abort();
|
||||
}, [term, page, reloads]);
|
||||
|
||||
const close = useCallback(() => {
|
||||
setDetail(null);
|
||||
setParams({}, {replace: true});
|
||||
}, [setParams]);
|
||||
|
||||
const loadDetail = useCallback(async (appid) => {
|
||||
try {
|
||||
const response = await fetchCatalogGame(appid);
|
||||
if (response.status === 401 || String(appid) !== openRef.current) return;
|
||||
if (response.ok) {
|
||||
setDetail(await response.json());
|
||||
return;
|
||||
}
|
||||
// 404: the game was merged meanwhile.
|
||||
toast.error(t(response.status === 404 ? 'CATALOG.GONE' : 'CATALOG.LOAD_ERROR'));
|
||||
close();
|
||||
setReloads((n) => n + 1);
|
||||
} catch (err) {
|
||||
console.error('Catalog game error:', err);
|
||||
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
|
||||
close();
|
||||
}
|
||||
}, [close, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (openAppid) loadDetail(openAppid);
|
||||
else setDetail(null);
|
||||
}, [openAppid, loadDetail]);
|
||||
|
||||
const changed = () => {
|
||||
loadDetail(detail.appid);
|
||||
setReloads((n) => n + 1);
|
||||
};
|
||||
|
||||
if (status === 'forbidden') {
|
||||
return (
|
||||
<PageContainer className="px-4 py-6 md:px-8">
|
||||
<p className="text-sm text-muted-foreground">{t('CATALOG.NO_ACCESS')}</p>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageContainer className="space-y-4 px-4 py-6 md:px-8">
|
||||
<Card>
|
||||
<CardHeader className="space-y-3">
|
||||
<CardTitle>{t('CATALOG.TITLE')}</CardTitle>
|
||||
<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" value={query} maxLength={MAX_LIBRARY_SEARCH_LENGTH} className="pl-8"
|
||||
placeholder={t('CATALOG.SEARCH')} aria-label={t('CATALOG.SEARCH')}
|
||||
onChange={(e) => setQuery(e.target.value)}/>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{status === 'loading' && <Loader2 className="size-5 animate-spin text-muted-foreground"/>}
|
||||
{status === 'error' && <p className="text-sm text-destructive">{t('CATALOG.LOAD_ERROR')}</p>}
|
||||
{status === 'ready' && result.content.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">{t('CATALOG.EMPTY')}</p>
|
||||
)}
|
||||
{status === 'ready' && result.content.length > 0 && (
|
||||
<ul className="divide-y">
|
||||
{result.content.map((game) => (
|
||||
<li key={game.appid} className="flex items-center gap-3 py-2">
|
||||
<GameCover appid={game.appid} imageUrl={game.imageUrl} className="w-20 shrink-0"/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">{game.name}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{game.platforms.join(', ')} · {t('CATALOG.KEY', {appid: game.appid})}
|
||||
</p>
|
||||
</div>
|
||||
<Button type="button" size="sm" variant="outline"
|
||||
onClick={() => setParams({appid: String(game.appid)})}>
|
||||
{t('CATALOG.CORRECT')}
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{status === 'ready' && result.totalPages > 1 && (
|
||||
<div className="flex items-center justify-between gap-2 text-sm">
|
||||
<Button type="button" variant="outline" size="sm" disabled={page === 0}
|
||||
onClick={() => setPage(page - 1)}>
|
||||
<ChevronLeft/>{t('ADMIN.PREVIOUS')}
|
||||
</Button>
|
||||
<span>{t('AUDIT.PAGE_OF', {current: page + 1, total: result.totalPages})}</span>
|
||||
<Button type="button" variant="outline" size="sm" disabled={page + 1 >= result.totalPages}
|
||||
onClick={() => setPage(page + 1)}>
|
||||
{t('ADMIN.NEXT')}<ChevronRight/>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{detail && (
|
||||
<GameCorrectionDialog game={detail} open onOpenChange={(open) => !open && close()} onChanged={changed}/>
|
||||
)}
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
// @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 GameCatalogPage from './GameCatalogPage.jsx';
|
||||
import {PreferencesProvider} from '../context/PreferencesContext.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 hit = {appid: 10, name: 'Hades', imageUrl: null, platforms: ['PS5', 'Steam']};
|
||||
const detail = {appid: 10, name: 'Hades', imageUrl: null, speedrunUrl: null, moods: [], endless: null, genres: [],
|
||||
timeToBeat: null, corrections: []};
|
||||
const catalog = (content, totalPages = 1) => json(200, {content, page: 0, size: 20, totalElements: content.length, totalPages});
|
||||
|
||||
const renderPage = (routes, url = '/audit/games') => {
|
||||
fetchRoutes(routes);
|
||||
render(<MemoryRouter initialEntries={[url]}><PreferencesProvider><GameCatalogPage/></PreferencesProvider></MemoryRouter>);
|
||||
};
|
||||
|
||||
const urls = () => fetch.mock.calls.map(([url]) => url);
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.setItem('tbp_session', '1');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
localStorage.clear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('GameCatalogPage', () => {
|
||||
it('listsHitsWithPlatformsAndKey', async () => {
|
||||
renderPage({'GET /api/library/catalog': catalog([hit])});
|
||||
|
||||
expect(await screen.findByText('Hades')).toBeTruthy();
|
||||
expect(screen.getByText(/PS5, Steam/)).toBeTruthy();
|
||||
expect(screen.getByText(/CATALOG.KEY/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('searchesWithTheTypedName', async () => {
|
||||
renderPage({'GET /api/library/catalog': catalog([hit])});
|
||||
await screen.findByText('Hades');
|
||||
|
||||
fireEvent.change(screen.getByLabelText('CATALOG.SEARCH'), {target: {value: ' had '}});
|
||||
|
||||
await waitFor(() => expect(urls().some((u) => u.includes('q=had'))).toBe(true));
|
||||
});
|
||||
|
||||
it('opensTheCorrectionDialogForTheChosenGame', async () => {
|
||||
renderPage({'GET /api/library/catalog': catalog([hit]), 'GET /api/library/catalog/10': json(200, detail)});
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', {name: 'CATALOG.CORRECT'}));
|
||||
|
||||
expect(await screen.findByText('CORRECTION.TITLE')).toBeTruthy();
|
||||
expect(urls().some((u) => u.endsWith('/api/library/catalog/10'))).toBe(true);
|
||||
});
|
||||
|
||||
it('opensTheGameOfTheAppidInTheUrl', async () => {
|
||||
renderPage({'GET /api/library/catalog': catalog([]), 'GET /api/library/catalog/10': json(200, detail)},
|
||||
'/audit/games?appid=10');
|
||||
|
||||
expect(await screen.findByText('CORRECTION.TITLE')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('saysSoWhenTheGameIsGone', async () => {
|
||||
const {toast} = await import('sonner');
|
||||
renderPage({'GET /api/library/catalog': catalog([]), 'GET /api/library/catalog/10': json(404, {})},
|
||||
'/audit/games?appid=10');
|
||||
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('CATALOG.GONE'));
|
||||
expect(screen.queryByText('CORRECTION.TITLE')).toBeNull();
|
||||
});
|
||||
|
||||
it('showsNoAccessOn403', async () => {
|
||||
renderPage({'GET /api/library/catalog': json(403, {})});
|
||||
|
||||
expect(await screen.findByText('CATALOG.NO_ACCESS')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('pagesThroughTheCatalog', async () => {
|
||||
renderPage({'GET /api/library/catalog': () => catalog([hit], 2)});
|
||||
await screen.findByText('Hades');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', {name: /ADMIN.NEXT/}));
|
||||
|
||||
await waitFor(() => expect(urls().at(-1)).toContain('page=1'));
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user