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
@@ -0,0 +1,144 @@
|
||||
import {useCallback, useEffect, useState} from 'react';
|
||||
import {Link} from 'react-router-dom';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {ChevronLeft, ChevronRight, Link2, Loader2, X} from 'lucide-react';
|
||||
import {toast} from 'sonner';
|
||||
|
||||
import {Badge} from '@/components/ui/badge';
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
|
||||
import GameCover from '../components/GameCover.jsx';
|
||||
import PageContainer from '../components/PageContainer.jsx';
|
||||
import {dismissPair, fetchLinkQueue, linkGames} from '../utils/links.js';
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
function PairGame({game}) {
|
||||
return (
|
||||
<Link to={`/library/${game.appid}`} className="flex min-w-0 flex-1 items-center gap-2 hover:text-primary">
|
||||
<GameCover appid={game.appid} imageUrl={game.imageUrl} className="w-16 shrink-0"/>
|
||||
<span className="min-w-0 truncate text-sm font-medium">{game.name ?? `App ${game.appid}`}</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* AUDITOR or higher: the pairs IGDB suggests and users asked for. Core checks the role.
|
||||
*/
|
||||
export default function LinkQueuePage() {
|
||||
const {t} = useTranslation();
|
||||
const [page, setPage] = useState(0);
|
||||
const [result, setResult] = useState({content: [], totalPages: 0});
|
||||
const [status, setStatus] = useState('loading');
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetchLinkQueue({page, size: PAGE_SIZE});
|
||||
if (response.status === 401) return;
|
||||
if (response.status === 403) {
|
||||
setStatus('forbidden');
|
||||
return;
|
||||
}
|
||||
if (!response.ok) {
|
||||
setStatus('error');
|
||||
return;
|
||||
}
|
||||
const data = await response.json();
|
||||
// The last pair of the last page was decided: show the page before.
|
||||
if (data.content.length === 0 && page > 0) {
|
||||
setPage(page - 1);
|
||||
return;
|
||||
}
|
||||
setResult(data);
|
||||
setStatus('ready');
|
||||
} catch (err) {
|
||||
console.error('Link queue error:', err);
|
||||
setStatus('error');
|
||||
}
|
||||
}, [page]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const run = async (request, doneKey) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const response = await request();
|
||||
if (response.status === 401) return;
|
||||
if (response.ok) {
|
||||
toast.success(t(doneKey));
|
||||
} else {
|
||||
const data = await response.json().catch(() => ({}));
|
||||
toast.error(t(data.code === 'ALREADY_LINKED' ? 'LINKS.ERROR_CODES.ALREADY_LINKED' : 'LINKS.ERROR'));
|
||||
}
|
||||
await load();
|
||||
} catch (err) {
|
||||
console.error('Link queue action error:', err);
|
||||
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (status === 'forbidden') {
|
||||
return (
|
||||
<PageContainer className="px-4 py-6 md:px-8">
|
||||
<p className="text-sm text-muted-foreground">{t('LINKS.NO_ACCESS')}</p>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageContainer className="space-y-4 px-4 py-6 md:px-8">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('LINKS.QUEUE_TITLE')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{status === 'loading' && <Loader2 className="size-5 animate-spin text-muted-foreground"/>}
|
||||
{status === 'error' && <p className="text-sm text-destructive">{t('LINKS.ERROR')}</p>}
|
||||
{status === 'ready' && result.content.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">{t('LINKS.QUEUE_EMPTY')}</p>
|
||||
)}
|
||||
{status === 'ready' && result.content.length > 0 && (
|
||||
<ul className="divide-y">
|
||||
{result.content.map(({a, b, igdb, requests}) => (
|
||||
<li key={`${a.appid}-${b.appid}`} className="flex flex-wrap items-center gap-3 py-3">
|
||||
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-3">
|
||||
<PairGame game={a}/>
|
||||
<PairGame game={b}/>
|
||||
</div>
|
||||
{igdb && <Badge variant="secondary">{t('LINKS.FROM_IGDB')}</Badge>}
|
||||
{requests > 0 && <Badge variant="outline">{t('LINKS.REQUESTED_BY', {count: requests})}</Badge>}
|
||||
<Button type="button" size="sm" disabled={busy}
|
||||
onClick={() => run(() => linkGames(a.appid, b.appid), 'LINKS.LINKED')}>
|
||||
<Link2/>{t('LINKS.LINK')}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="sm" disabled={busy}
|
||||
onClick={() => run(() => dismissPair(a.appid, b.appid), 'LINKS.DISMISSED')}>
|
||||
<X/>{t('LINKS.DISMISS')}
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{status === 'ready' && result.totalPages > 1 && (
|
||||
<div className="flex items-center justify-between gap-2 pt-3 text-sm">
|
||||
<Button type="button" variant="outline" size="sm" disabled={page === 0}
|
||||
onClick={() => setPage(page - 1)}>
|
||||
<ChevronLeft/>{t('ADMIN.PREVIOUS')}
|
||||
</Button>
|
||||
<span>{t('LINKS.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>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user