Public Access
Display and game names shrink to 0.875rem, then end with an ellipsis (FitText + fit-text utility, CSS container units, full text in title). Rows and buttons wrap at large fonts, header points chip can shrink. Guard: scripts/check-overflow.js.
146 lines
6.4 KiB
React
146 lines
6.4 KiB
React
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 FitText from '../components/FitText.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="@container min-w-0 flex-1 text-sm font-medium"><FitText text={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>
|
|
);
|
|
}
|