Files
ToBePlayed-Frontend/src/pages/PlayNextPage.jsx
T
lobo d7810fbf94
Build, Test & Push Frontend / quality-check (push) Successful in 31s
Build, Test & Push Frontend / docker (push) Successful in 24s
fix(layout): fit names on phones without sideways scroll
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.
2026-10-03 12:18:31 +02:00

245 lines
9.9 KiB
React

import {useCallback, useEffect, useState} from 'react';
import {Link} 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 FitText from '../components/FitText.jsx';
import PageContainer from '../components/PageContainer.jsx';
import {saveGameStatus} from '../utils/backlog.js';
import {formatPlaytime} from '../utils/games.js';
import {request} from '../utils/api.js';
import useAbortPrevious from '../hooks/useAbortPrevious.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},
];
// Core derives the moods from IGDB's genres, themes and game modes, and SPEEDRUN from speedrun.com; a mood leaves out
// games without that data.
const MOODS = ['ANY', 'STORY', 'COOP', 'CHILL', 'ACTION', 'SCARY', 'SPEEDRUN'];
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, mood, exclude) => {
const params = new URLSearchParams({count: String(PICK_COUNT)});
if (maxMinutes) params.set('maxMinutes', String(maxMinutes));
if (mood !== 'ANY') params.set('mood', mood);
exclude.forEach((appid) => params.append('exclude', String(appid)));
return params.toString();
};
export default function PlayNextPage() {
const {t} = useTranslation();
const [budget, setBudget] = useState(BUDGETS[3]);
const [mood, setMood] = useState('ANY');
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 or mood must not replace the newer one.
const nextSignal = useAbortPrevious();
const load = useCallback(async (maxMinutes, moodKey, exclude) => {
const signal = nextSignal();
setLoading(true);
setError(null);
const {ok, status, data} = await request(
`${import.meta.env.VITE_CORE_API_URL}/api/library/picker?${pickerQuery(maxMinutes, moodKey, exclude)}`, {signal});
if (signal.aborted) return;
setLoading(false);
if (status === 401) return;
if (!ok || !data) {
setError(status === 0 ? 'INFO.ERROR.NETWORK_FAILURE' : 'PLAY_NEXT.ERROR_LOAD');
return;
}
const picked = data.games ?? [];
setGames(picked);
setSeen([...exclude, ...picked.map((g) => g.appid)].slice(-MAX_EXCLUDES));
// No `t` here: a language change gives a new `t`, and a reload would replace the random picks.
}, [nextSignal]);
useEffect(() => {
load(budget.maxMinutes, mood, []);
}, [budget, mood, load]);
const chooseBudget = (next) => {
if (next.key === budget.key) return;
setGames(null);
setSeen([]);
setBudget(next);
};
const chooseMood = (next) => {
if (next === mood) return;
setGames(null);
setSeen([]);
setMood(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>
<div className="space-y-2">
<div className="flex flex-wrap gap-2" role="group" aria-label={t('PLAY_NEXT.MOOD_LABEL')}>
{MOODS.map((key) => (
<Button
key={key}
size="sm"
variant={mood === key ? 'default' : 'outline'}
aria-pressed={mood === key}
onClick={() => chooseMood(key)}
>
{t(`PLAY_NEXT.MOOD.${key}`)}
</Button>
))}
</div>
{mood !== 'ANY' && (
<p className="text-xs text-muted-foreground">
{t(mood === 'SPEEDRUN' ? 'PLAY_NEXT.MOOD_HINT_SPEEDRUN' : 'PLAY_NEXT.MOOD_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}/>)}
</div>
)}
{(games?.length > 0 || error) && (
<Button variant="outline" onClick={() => load(budget.maxMinutes, mood, 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, mood, [])} disabled={loading}>
<Shuffle/>{t('PLAY_NEXT.START_OVER')}
</Button>
</div>
)}
{games?.length === 0 && seen.length === 0 && (
<p className="text-muted-foreground">
{t(emptyKey(budget, mood))}
</p>
)}
</PageContainer>
);
}
const emptyKey = (budget, mood) => {
// With a length limit, waiting for the background check may never help.
if (mood === 'SPEEDRUN' && !budget.maxMinutes) return 'PLAY_NEXT.EMPTY_SPEEDRUN';
if (mood !== 'ANY') return 'PLAY_NEXT.EMPTY_MOOD';
return budget.maxMinutes ? 'PLAY_NEXT.EMPTY_BUDGET' : 'PLAY_NEXT.EMPTY';
};
function PickCard({game}) {
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) 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} imageUrl={game.imageUrl} className="w-full"/>
</Link>
<div className="flex flex-1 flex-col gap-3 px-4 pb-4">
<Link to={`/library/${game.appid}`} className="@container block min-w-0 font-semibold hover:text-primary transition-colors">
<FitText text={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>
);
}