feat(library): add game links, auditor queue, audit log and filter
Build, Test & Push Frontend / quality-check (push) Successful in 25s
Build, Test & Push Frontend / docker (push) Successful in 23s

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:
lobo committed 2026-10-02 09:10:56 +02:00
1 parent 80259cb0e9
commit f71e8f7aff
21 files changed
+992 -11

No files matched your search

+129
View File
@@ -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>
);
}