Public Access
feat(play-next): page that picks the next backlog game
This commit is contained in:
1 parent
8dcd03f219
commit
2efdf64675
10 files changed
+502
-7
No files matched your search
@@ -0,0 +1,223 @@
|
||||
import {useCallback, useEffect, useRef, useState} from 'react';
|
||||
import {Link, useNavigate} from 'react-router-dom';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {toast} from 'sonner';
|
||||
import {Check, Loader2, Play, Shuffle, Timer} from 'lucide-react';
|
||||
|
||||
import {Badge} from '@/components/ui/badge';
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {Card} from '@/components/ui/card';
|
||||
import {Skeleton} from '@/components/ui/skeleton';
|
||||
import GameCover from '../components/GameCover.jsx';
|
||||
import PageContainer from '../components/PageContainer.jsx';
|
||||
import useSessionExpired from '../hooks/useSessionExpired.js';
|
||||
import {saveGameStatus} from '../utils/backlog.js';
|
||||
import {formatPlaytime} from '../utils/games.js';
|
||||
|
||||
// maxMinutes is IGDB's normal time to beat; a limit leaves out games without IGDB data.
|
||||
const BUDGETS = [
|
||||
{key: 'SHORT', maxMinutes: 300},
|
||||
{key: 'MEDIUM', maxMinutes: 900},
|
||||
{key: 'LONG', maxMinutes: 2400},
|
||||
{key: 'ANY', maxMinutes: null},
|
||||
];
|
||||
const PICK_COUNT = 3;
|
||||
// The backend reads only this many excludes. With a bigger backlog the oldest seen games can come back.
|
||||
const MAX_EXCLUDES = 100;
|
||||
|
||||
const pickerQuery = (maxMinutes, exclude) => {
|
||||
const params = new URLSearchParams({count: String(PICK_COUNT)});
|
||||
if (maxMinutes) params.set('maxMinutes', String(maxMinutes));
|
||||
exclude.forEach((appid) => params.append('exclude', String(appid)));
|
||||
return params.toString();
|
||||
};
|
||||
|
||||
export default function PlayNextPage() {
|
||||
const {t} = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const expireSession = useSessionExpired();
|
||||
const [budget, setBudget] = useState(BUDGETS[3]);
|
||||
const [games, setGames] = useState(null);
|
||||
const [seen, setSeen] = useState([]);
|
||||
const [error, setError] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
// A slow answer for an older budget must not replace the newer one.
|
||||
const latestRequest = useRef(0);
|
||||
|
||||
const load = useCallback(async (maxMinutes, exclude) => {
|
||||
const token = localStorage.getItem('tbp_jwt');
|
||||
if (!token) {
|
||||
navigate('/login');
|
||||
return;
|
||||
}
|
||||
const request = ++latestRequest.current;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${import.meta.env.VITE_CORE_API_URL}/api/library/picker?${pickerQuery(maxMinutes, exclude)}`,
|
||||
{headers: {'Authorization': `Bearer ${token}`}});
|
||||
if (request !== latestRequest.current) return;
|
||||
if (response.status === 401) {
|
||||
expireSession();
|
||||
return;
|
||||
}
|
||||
if (!response.ok) {
|
||||
setError('PLAY_NEXT.ERROR_LOAD');
|
||||
return;
|
||||
}
|
||||
const picked = (await response.json()).games ?? [];
|
||||
if (request !== latestRequest.current) return;
|
||||
setGames(picked);
|
||||
setSeen([...exclude, ...picked.map((g) => g.appid)].slice(-MAX_EXCLUDES));
|
||||
} catch (err) {
|
||||
console.error('Picker load error:', err);
|
||||
if (request === latestRequest.current) setError('INFO.ERROR.NETWORK_FAILURE');
|
||||
} finally {
|
||||
if (request === latestRequest.current) setLoading(false);
|
||||
}
|
||||
// No `t` here: a language change gives a new `t`, and a reload would replace the random picks.
|
||||
}, [navigate, expireSession]);
|
||||
|
||||
useEffect(() => {
|
||||
load(budget.maxMinutes, []);
|
||||
}, [budget, load]);
|
||||
|
||||
const chooseBudget = (next) => {
|
||||
if (next.key === budget.key) return;
|
||||
setGames(null);
|
||||
setSeen([]);
|
||||
setBudget(next);
|
||||
};
|
||||
|
||||
const allShown = games?.length === 0 && seen.length > 0;
|
||||
|
||||
return (
|
||||
<PageContainer className="p-4 md:p-8 space-y-6">
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-3xl font-bold tracking-tight">{t('PLAY_NEXT.TITLE')}</h1>
|
||||
<p className="text-muted-foreground">{t('PLAY_NEXT.SUBTITLE')}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap gap-2" role="group" aria-label={t('PLAY_NEXT.BUDGET_LABEL')}>
|
||||
{BUDGETS.map((b) => (
|
||||
<Button
|
||||
key={b.key}
|
||||
size="sm"
|
||||
variant={budget.key === b.key ? 'default' : 'outline'}
|
||||
aria-pressed={budget.key === b.key}
|
||||
onClick={() => chooseBudget(b)}
|
||||
>
|
||||
{t(`PLAY_NEXT.BUDGET.${b.key}`)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
{budget.maxMinutes && (
|
||||
<p className="text-xs text-muted-foreground">{t('PLAY_NEXT.BUDGET_HINT')}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-destructive">{t(error)}</p>}
|
||||
|
||||
{games === null && !error && (
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
{Array.from({length: PICK_COUNT}, (_, i) => <Skeleton key={i} className="h-56 rounded-xl"/>)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{games?.length > 0 && (
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
{games.map((game) => <PickCard key={game.appid} game={game} expireSession={expireSession}/>)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(games?.length > 0 || error) && (
|
||||
<Button variant="outline" onClick={() => load(budget.maxMinutes, seen)} disabled={loading}>
|
||||
{loading ? <Loader2 className="animate-spin"/> : <Shuffle/>}
|
||||
{t('PLAY_NEXT.REROLL')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{allShown && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-muted-foreground">{t('PLAY_NEXT.ALL_SHOWN')}</p>
|
||||
<Button variant="outline" onClick={() => load(budget.maxMinutes, [])} disabled={loading}>
|
||||
<Shuffle/>{t('PLAY_NEXT.START_OVER')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{games?.length === 0 && seen.length === 0 && (
|
||||
<p className="text-muted-foreground">
|
||||
{t(budget.maxMinutes ? 'PLAY_NEXT.EMPTY_BUDGET' : 'PLAY_NEXT.EMPTY')}
|
||||
</p>
|
||||
)}
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function PickCard({game, expireSession}) {
|
||||
const {t} = useTranslation();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [started, setStarted] = useState(false);
|
||||
|
||||
const startPlaying = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const response = await saveGameStatus(game.appid, 'PLAYING');
|
||||
if (response.status === 401) {
|
||||
expireSession();
|
||||
return;
|
||||
}
|
||||
if (!response.ok) {
|
||||
toast.error(t('BACKLOG.SAVE_FAILED'));
|
||||
return;
|
||||
}
|
||||
setStarted(true);
|
||||
toast.success(t('BACKLOG.SAVED'));
|
||||
} catch (err) {
|
||||
console.error('Start playing error:', err);
|
||||
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="gap-3 overflow-hidden p-0">
|
||||
<Link to={`/library/${game.appid}`} className="block">
|
||||
<GameCover appid={game.appid} className="w-full"/>
|
||||
</Link>
|
||||
<div className="flex flex-1 flex-col gap-3 px-4 pb-4">
|
||||
<Link to={`/library/${game.appid}`} className="font-semibold hover:text-primary transition-colors">
|
||||
{game.name ?? `App ${game.appid}`}
|
||||
</Link>
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
||||
{game.timeToBeatMinutes > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Timer className="size-4"/>{formatPlaytime(game.timeToBeatMinutes)}
|
||||
</span>
|
||||
)}
|
||||
{game.priority && (
|
||||
<Badge variant="secondary">
|
||||
{t('BACKLOG.PRIORITY_LABEL')}: {t(`BACKLOG.PRIORITY.${game.priority}`)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-auto">
|
||||
{started ? (
|
||||
<p className="flex items-center gap-1.5 text-sm font-medium text-primary">
|
||||
<Check className="size-4"/>{t('PLAY_NEXT.STARTED')}
|
||||
</p>
|
||||
) : (
|
||||
<Button size="sm" className="w-full" onClick={startPlaying} disabled={busy}>
|
||||
{busy ? <Loader2 className="animate-spin"/> : <Play/>}
|
||||
{t('PLAY_NEXT.START_PLAYING')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user