feat(play-next): show picks as category rows with reroll per row

Four rows: up next, because you played, longest on list, mood.
Each row rerolls alone and tracks its own loading state.
Mood change reloads only the mood row. Phones scroll each row
sideways; wider screens show 3 columns.
This commit is contained in:
lobo committed 2026-10-06 19:41:24 +02:00
1 parent 43c4862606
commit df3d6d2537
6 files changed
+332 -181

No files matched your search

+130 -61
View File
@@ -1,4 +1,4 @@
import {useCallback, useEffect, useState} from 'react';
import {useCallback, useEffect, useRef, useState} from 'react';
import {Link} from 'react-router-dom';
import {useTranslation} from 'react-i18next';
import {toast} from 'sonner';
@@ -15,7 +15,6 @@ import {saveGameStatus} from '../utils/backlog.js';
import {formatDate} from '../utils/format.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 = [
@@ -27,67 +26,126 @@ const BUDGETS = [
// 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 = 4;
// The rows in the order Core sends them; RANDOM is the mood row.
const ROW_ORDER = ['NEXT_UP', 'SIMILAR', 'LONGEST', 'RANDOM'];
// The backend reads only this many excludes. With a bigger backlog the oldest seen games can come back.
const MAX_EXCLUDES = 100;
const ROW_CLASS = 'flex gap-4 overflow-x-auto snap-x snap-mandatory pb-2 sm:grid sm:grid-cols-3 sm:overflow-visible';
const CARD_CLASS = 'w-64 shrink-0 snap-start sm:w-auto';
const pickerQuery = (maxMinutes, mood, exclude) => {
const params = new URLSearchParams({count: String(PICK_COUNT)});
// The mood only filters the RANDOM row.
const pickerQuery = (maxMinutes, mood, exclude, category) => {
const params = new URLSearchParams();
if (category) params.set('category', category);
if (maxMinutes) params.set('maxMinutes', String(maxMinutes));
if (mood !== 'ANY') params.set('mood', mood);
if (mood !== 'ANY' && (!category || category === 'RANDOM')) params.set('mood', mood);
exclude.forEach((appid) => params.append('exclude', String(appid)));
return params.toString();
};
const fetchPicker = (maxMinutes, mood, exclude, category, signal) => request(
`${import.meta.env.VITE_CORE_API_URL}/api/library/picker?${pickerQuery(maxMinutes, mood, exclude, category)}`,
{signal});
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 [rows, setRows] = 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();
// Keys ('ALL' or a row) with a request in flight.
const [loading, setLoading] = useState(() => new Set());
// One controller per request key ('ALL' or a row). A slow answer for an older budget, mood or reroll must not
// replace the newer one.
const controllers = useRef(new Map());
const load = useCallback(async (maxMinutes, moodKey, exclude) => {
const signal = nextSignal();
setLoading(true);
useEffect(() => {
const current = controllers.current;
return () => current.forEach((controller) => controller.abort());
}, []);
const startLoading = useCallback((key) => setLoading((old) => new Set(old).add(key)), []);
const finishLoading = useCallback((key) => setLoading((old) => {
const next = new Set(old);
next.delete(key);
return next;
}), []);
const nextSignal = useCallback((key) => {
controllers.current.get(key)?.abort();
const controller = new AbortController();
controllers.current.set(key, controller);
return controller.signal;
}, []);
const loadAll = useCallback(async (maxMinutes, moodKey) => {
controllers.current.forEach((controller) => controller.abort());
const signal = nextSignal('ALL');
setError(null);
const {ok, status, data} = await request(
`${import.meta.env.VITE_CORE_API_URL}/api/library/picker?${pickerQuery(maxMinutes, moodKey, exclude)}`, {signal});
setLoading(new Set(['ALL']));
const {ok, status, data} = await fetchPicker(maxMinutes, moodKey, [], null, signal);
if (signal.aborted) return;
setLoading(false);
finishLoading('ALL');
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]);
const categories = data.categories ?? [];
setRows(categories);
setSeen(Object.fromEntries(categories.map((c) => [c.source, c.games.map((g) => g.appid)])));
}, [nextSignal, finishLoading]);
const loadRow = useCallback(async (source, exclude, maxMinutes, moodKey) => {
const signal = nextSignal(source);
startLoading(source);
const {ok, status, data} = await fetchPicker(maxMinutes, moodKey, exclude, source, signal);
if (signal.aborted) return;
finishLoading(source);
if (!ok || !data) {
if (status !== 401) toast.error(t(status === 0 ? 'INFO.ERROR.NETWORK_FAILURE' : 'PLAY_NEXT.ERROR_LOAD'));
return;
}
const row = (data.categories ?? []).find((c) => c.source === source);
if (!row && exclude.length > 0) {
// Every game that fits was shown: start the row over.
await loadRow(source, [], maxMinutes, moodKey);
return;
}
setRows((old) => ROW_ORDER
.map((key) => (key === source ? row : old.find((r) => r.source === key)))
.filter(Boolean));
setSeen((old) => ({
...old,
[source]: [...exclude, ...(row?.games ?? []).map((g) => g.appid)].slice(-MAX_EXCLUDES),
}));
}, [nextSignal, startLoading, finishLoading, t]);
useEffect(() => {
load(budget.maxMinutes, mood, []);
}, [budget, mood, load]);
loadAll(BUDGETS[3].maxMinutes, 'ANY');
}, [loadAll]);
const chooseBudget = (next) => {
if (next.key === budget.key) return;
setGames(null);
setSeen([]);
setRows(null);
setSeen({});
setBudget(next);
loadAll(next.maxMinutes, mood);
};
const chooseMood = (next) => {
if (next === mood) return;
setGames(null);
setSeen([]);
setMood(next);
if (rows === null) loadAll(budget.maxMinutes, next);
else loadRow('RANDOM', [], budget.maxMinutes, next);
};
const allShown = games?.length === 0 && seen.length > 0;
const headingFor = (source) => (source === 'RANDOM' && mood !== 'ANY'
? t('PLAY_NEXT.CATEGORY.MOOD', {mood: t(`PLAY_NEXT.MOOD.${mood}`)})
: t(`PLAY_NEXT.CATEGORY.${source}`));
const moodRowMissing = rows !== null && mood !== 'ANY' && !loading.has('RANDOM')
&& !rows.some((r) => r.source === 'RANDOM');
return (
<PageContainer className="p-4 md:p-8 space-y-6">
@@ -134,60 +192,71 @@ export default function PlayNextPage() {
{t(mood === 'SPEEDRUN' ? 'PLAY_NEXT.MOOD_HINT_SPEEDRUN' : 'PLAY_NEXT.MOOD_HINT')}
</p>
)}
{moodRowMissing && (
<p className="text-muted-foreground">
{t(emptyKey(budget, mood))}
</p>
)}
</div>
{error && <p className="text-sm text-destructive">{t(error)}</p>}
{games === null && !error && (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{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-2 lg:grid-cols-4">
{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 && (
{error && (
<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')}
<p className="text-sm text-destructive">{t(error)}</p>
<Button variant="outline" onClick={() => loadAll(budget.maxMinutes, mood)}>
<Shuffle/>{t('PLAY_NEXT.REROLL')}
</Button>
</div>
)}
{games?.length === 0 && seen.length === 0 && (
{rows === null && !error && ROW_ORDER.map((source) => (
<div key={source} className={ROW_CLASS}>
{Array.from({length: 3}, (_, i) => <Skeleton key={i} className={`h-56 rounded-xl ${CARD_CLASS}`}/>)}
</div>
))}
{rows?.map(({source, games}) => (
<section key={source} aria-labelledby={`row-${source}`} className="min-w-0 space-y-3">
<div className="flex items-center justify-between gap-2">
<h2 id={`row-${source}`} className="text-xl font-semibold">{headingFor(source)}</h2>
<Button
size="sm"
variant="outline"
aria-label={t('PLAY_NEXT.REROLL_ROW', {category: headingFor(source)})}
onClick={() => loadRow(source, seen[source] ?? [], budget.maxMinutes, mood)}
disabled={loading.has(source)}
>
{loading.has(source) ? <Loader2 className="animate-spin"/> : <Shuffle/>}
</Button>
</div>
<div className={ROW_CLASS}>
{games.map((game) => (
<div key={game.appid} className={CARD_CLASS}><PickCard game={game}/></div>
))}
</div>
</section>
))}
{rows?.length === 0 && !moodRowMissing && (
<p className="text-muted-foreground">
{t(emptyKey(budget, mood))}
{t(emptyKey(budget, 'ANY'))}
</p>
)}
</PageContainer>
);
}
// The row heading says why a NEXT_UP or RANDOM game was picked.
const reasonText = (reason, t, language) => {
if (!reason) return null;
if (reason.source === 'SIMILAR') {
if (reason?.source === 'SIMILAR') {
return t('PLAY_NEXT.REASON.SIMILAR', {
name: reason.anchorName ?? `App ${reason.anchorAppid}`,
playtime: formatPlaytime(reason.anchorPlaytimeMinutes),
});
}
if (reason.source === 'LONGEST') {
if (reason?.source === 'LONGEST') {
return t('PLAY_NEXT.REASON.LONGEST', {date: formatDate(reason.firstSeenAt, language, {dateStyle: 'medium'})});
}
if (reason.source === 'NEXT_UP') return t('PLAY_NEXT.REASON.NEXT_UP');
return t('PLAY_NEXT.REASON.RANDOM');
return null;
};
const emptyKey = (budget, mood) => {