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
@@ -21,6 +21,8 @@ To Be Played is split into three repositories:
|
|||||||
|
|
||||||
- **Dashboard**: most played games, games in progress, "Pile of Shame" with backlog hours and a random suggestion,
|
- **Dashboard**: most played games, games in progress, "Pile of Shame" with backlog hours and a random suggestion,
|
||||||
Steam avatar and a banner from the most played game of the last two weeks.
|
Steam avatar and a banner from the most played game of the last two weeks.
|
||||||
|
- **What to play next** (`/play-next`): three backlog picks for a time budget (up to 5 / 15 / 40 h or any), high
|
||||||
|
priority first, reroll without repeats, and "Start playing" in one click.
|
||||||
- **Library**: status filters, "short games" filter (up to 10 h), search, sorting by playtime, recently seen, newest
|
- **Library**: status filters, "short games" filter (up to 10 h), search, sorting by playtime, recently seen, newest
|
||||||
in library, rating or priority, paging.
|
in library, rating or priority, paging.
|
||||||
- **Game detail**: status, priority, 1–5 star rating, notes, achievements, time to beat (story / extras / 100 %),
|
- **Game detail**: status, priority, 1–5 star rating, notes, achievements, time to beat (story / extras / 100 %),
|
||||||
@@ -80,7 +82,7 @@ npm run build
|
|||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
|
|
||||||
- "What to play next" page: time budget and mood on top of the current random suggestion.
|
- Mood filter for "What to play next".
|
||||||
- Achievement progress on the library cards and a library-wide achievement page.
|
- Achievement progress on the library cards and a library-wide achievement page.
|
||||||
- Move the JWT from `localStorage` to an httpOnly cookie (needs Auth changes) and add a Content Security Policy in
|
- Move the JWT from `localStorage` to an httpOnly cookie (needs Auth changes) and add a Content Security Policy in
|
||||||
`nginx.conf`.
|
`nginx.conf`.
|
||||||
|
|||||||
+24
-2
@@ -133,7 +133,8 @@
|
|||||||
"ACTIVITY_TOTAL": "{{time}} in {{days}} Tagen",
|
"ACTIVITY_TOTAL": "{{time}} in {{days}} Tagen",
|
||||||
"ACTIVITY_EMPTY": "Noch keine Spielzeit aufgezeichnet. Ab jetzt zählt jeder Sync mit.",
|
"ACTIVITY_EMPTY": "Noch keine Spielzeit aufgezeichnet. Ab jetzt zählt jeder Sync mit.",
|
||||||
"RECENTLY_PLAYED": "Zuletzt gespielt (2 Wochen)",
|
"RECENTLY_PLAYED": "Zuletzt gespielt (2 Wochen)",
|
||||||
"BACKLOG_HOURS": "≈ {{time}} Spielzeit bis alles durch ist"
|
"BACKLOG_HOURS": "≈ {{time}} Spielzeit bis alles durch ist",
|
||||||
|
"OPEN_PICKER": "Such mir was aus"
|
||||||
},
|
},
|
||||||
"LIBRARY": {
|
"LIBRARY": {
|
||||||
"TITLE": "Spielebibliothek",
|
"TITLE": "Spielebibliothek",
|
||||||
@@ -218,7 +219,8 @@
|
|||||||
"MENU": "Menü",
|
"MENU": "Menü",
|
||||||
"LANGUAGE": "Sprache",
|
"LANGUAGE": "Sprache",
|
||||||
"DARK_MODE": "Dunkles Design",
|
"DARK_MODE": "Dunkles Design",
|
||||||
"LIGHT_MODE": "Helles Design"
|
"LIGHT_MODE": "Helles Design",
|
||||||
|
"PLAY_NEXT": "Nächstes Spiel"
|
||||||
},
|
},
|
||||||
"BACKLOG": {
|
"BACKLOG": {
|
||||||
"TITLE": "Mein Backlog",
|
"TITLE": "Mein Backlog",
|
||||||
@@ -267,5 +269,25 @@
|
|||||||
"NOT_FOUND": "Dieses Profil gibt es nicht oder es ist privat.",
|
"NOT_FOUND": "Dieses Profil gibt es nicht oder es ist privat.",
|
||||||
"ERROR_LOAD": "Profil konnte nicht geladen werden.",
|
"ERROR_LOAD": "Profil konnte nicht geladen werden.",
|
||||||
"TO_START": "Zur Startseite"
|
"TO_START": "Zur Startseite"
|
||||||
|
},
|
||||||
|
"PLAY_NEXT": {
|
||||||
|
"TITLE": "Was spiele ich als Nächstes?",
|
||||||
|
"SUBTITLE": "Drei Spiele aus deinem Backlog, hohe Priorität zuerst.",
|
||||||
|
"BUDGET_LABEL": "Spieldauer",
|
||||||
|
"BUDGET": {
|
||||||
|
"SHORT": "Bis 5 h",
|
||||||
|
"MEDIUM": "Bis 15 h",
|
||||||
|
"LONG": "Bis 40 h",
|
||||||
|
"ANY": "Beliebig lang"
|
||||||
|
},
|
||||||
|
"BUDGET_HINT": "Nur Spiele mit bekannter Spieldauer (von IGDB).",
|
||||||
|
"REROLL": "Andere Vorschläge",
|
||||||
|
"START_PLAYING": "Jetzt spielen",
|
||||||
|
"STARTED": "Wird gespielt",
|
||||||
|
"ALL_SHOWN": "Du hast alle passenden Spiele gesehen.",
|
||||||
|
"START_OVER": "Von vorn",
|
||||||
|
"EMPTY": "Dein Backlog ist leer. Stark!",
|
||||||
|
"EMPTY_BUDGET": "Kein Backlog-Spiel passt zu dieser Dauer. Versuch eine längere.",
|
||||||
|
"ERROR_LOAD": "Die Vorschläge konnten nicht geladen werden."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+24
-2
@@ -133,7 +133,8 @@
|
|||||||
"ACTIVITY_TOTAL": "{{time}} in {{days}} days",
|
"ACTIVITY_TOTAL": "{{time}} in {{days}} days",
|
||||||
"ACTIVITY_EMPTY": "No playtime recorded yet. Every sync from now on counts.",
|
"ACTIVITY_EMPTY": "No playtime recorded yet. Every sync from now on counts.",
|
||||||
"RECENTLY_PLAYED": "Recently played (2 weeks)",
|
"RECENTLY_PLAYED": "Recently played (2 weeks)",
|
||||||
"BACKLOG_HOURS": "≈ {{time}} of play to clear it"
|
"BACKLOG_HOURS": "≈ {{time}} of play to clear it",
|
||||||
|
"OPEN_PICKER": "Pick for me"
|
||||||
},
|
},
|
||||||
"LIBRARY": {
|
"LIBRARY": {
|
||||||
"TITLE": "Game Library",
|
"TITLE": "Game Library",
|
||||||
@@ -218,7 +219,8 @@
|
|||||||
"MENU": "Menu",
|
"MENU": "Menu",
|
||||||
"LANGUAGE": "Language",
|
"LANGUAGE": "Language",
|
||||||
"DARK_MODE": "Dark mode",
|
"DARK_MODE": "Dark mode",
|
||||||
"LIGHT_MODE": "Light mode"
|
"LIGHT_MODE": "Light mode",
|
||||||
|
"PLAY_NEXT": "Play next"
|
||||||
},
|
},
|
||||||
"BACKLOG": {
|
"BACKLOG": {
|
||||||
"TITLE": "My backlog",
|
"TITLE": "My backlog",
|
||||||
@@ -267,5 +269,25 @@
|
|||||||
"NOT_FOUND": "This profile does not exist or is private.",
|
"NOT_FOUND": "This profile does not exist or is private.",
|
||||||
"ERROR_LOAD": "Could not load the profile.",
|
"ERROR_LOAD": "Could not load the profile.",
|
||||||
"TO_START": "To the start page"
|
"TO_START": "To the start page"
|
||||||
|
},
|
||||||
|
"PLAY_NEXT": {
|
||||||
|
"TITLE": "What to play next",
|
||||||
|
"SUBTITLE": "Three games from your backlog, high priority first.",
|
||||||
|
"BUDGET_LABEL": "Time to beat",
|
||||||
|
"BUDGET": {
|
||||||
|
"SHORT": "Up to 5 h",
|
||||||
|
"MEDIUM": "Up to 15 h",
|
||||||
|
"LONG": "Up to 40 h",
|
||||||
|
"ANY": "Any length"
|
||||||
|
},
|
||||||
|
"BUDGET_HINT": "Only games with a known time to beat (from IGDB).",
|
||||||
|
"REROLL": "Other picks",
|
||||||
|
"START_PLAYING": "Start playing",
|
||||||
|
"STARTED": "Now playing",
|
||||||
|
"ALL_SHOWN": "You have seen every game that fits.",
|
||||||
|
"START_OVER": "Start over",
|
||||||
|
"EMPTY": "Your backlog is empty. Nice!",
|
||||||
|
"EMPTY_BUDGET": "No backlog game fits this length. Try a longer one.",
|
||||||
|
"ERROR_LOAD": "Could not load the picks."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -11,6 +11,7 @@ import SettingsPage from './pages/SettingsPage.jsx';
|
|||||||
import GameLibrary from './pages/GameLibrary.jsx';
|
import GameLibrary from './pages/GameLibrary.jsx';
|
||||||
import GameDetail from './pages/GameDetail.jsx';
|
import GameDetail from './pages/GameDetail.jsx';
|
||||||
import WishlistPage from './pages/WishlistPage.jsx';
|
import WishlistPage from './pages/WishlistPage.jsx';
|
||||||
|
import PlayNextPage from './pages/PlayNextPage.jsx';
|
||||||
import PublicProfilePage from './pages/PublicProfilePage.jsx';
|
import PublicProfilePage from './pages/PublicProfilePage.jsx';
|
||||||
import AppHeader from './components/AppHeader.jsx';
|
import AppHeader from './components/AppHeader.jsx';
|
||||||
import {Toaster} from '@/components/ui/sonner';
|
import {Toaster} from '@/components/ui/sonner';
|
||||||
@@ -61,6 +62,7 @@ function App() {
|
|||||||
<Route path="/library" element={<GameLibrary/>}/>
|
<Route path="/library" element={<GameLibrary/>}/>
|
||||||
<Route path="/library/:appid" element={<GameDetail/>}/>
|
<Route path="/library/:appid" element={<GameDetail/>}/>
|
||||||
<Route path="/wishlist" element={<WishlistPage/>}/>
|
<Route path="/wishlist" element={<WishlistPage/>}/>
|
||||||
|
<Route path="/play-next" element={<PlayNextPage/>}/>
|
||||||
<Route path="/u/:name" element={<PublicProfilePage/>}/>
|
<Route path="/u/:name" element={<PublicProfilePage/>}/>
|
||||||
<Route path="/settings" element={<SettingsPage/>}/>
|
<Route path="/settings" element={<SettingsPage/>}/>
|
||||||
<Route path="/impressum" element={<ImprintPage/>}/>
|
<Route path="/impressum" element={<ImprintPage/>}/>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import {Link, NavLink, useLocation} from 'react-router-dom';
|
import {Link, NavLink, useLocation} from 'react-router-dom';
|
||||||
import {useTranslation} from 'react-i18next';
|
import {useTranslation} from 'react-i18next';
|
||||||
import {Gamepad2, Languages, LayoutDashboard, Library, LogOut, Moon, Settings, Sun} from 'lucide-react';
|
import {Dices, Gamepad2, Languages, LayoutDashboard, Library, LogOut, Moon, Settings, Sun} from 'lucide-react';
|
||||||
|
|
||||||
import {Button} from '@/components/ui/button';
|
import {Button} from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
@@ -20,6 +20,7 @@ const NAV_ITEMS = [
|
|||||||
{to: '/dashboard', labelKey: 'NAV.DASHBOARD', Icon: LayoutDashboard},
|
{to: '/dashboard', labelKey: 'NAV.DASHBOARD', Icon: LayoutDashboard},
|
||||||
// The wishlist is a tab of the library.
|
// The wishlist is a tab of the library.
|
||||||
{to: '/library', labelKey: 'NAV.LIBRARY', Icon: Library, alsoActiveOn: ['/wishlist']},
|
{to: '/library', labelKey: 'NAV.LIBRARY', Icon: Library, alsoActiveOn: ['/wishlist']},
|
||||||
|
{to: '/play-next', labelKey: 'NAV.PLAY_NEXT', Icon: Dices},
|
||||||
];
|
];
|
||||||
|
|
||||||
const navLinkClass = (isActive) => cn(
|
const navLinkClass = (isActive) => cn(
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import {Link} from 'react-router-dom';
|
import {Link} from 'react-router-dom';
|
||||||
import {useTranslation} from 'react-i18next';
|
import {useTranslation} from 'react-i18next';
|
||||||
import {Inbox, Shuffle} from 'lucide-react';
|
import {Dices, Inbox, Shuffle} from 'lucide-react';
|
||||||
|
|
||||||
import {Button} from '@/components/ui/button';
|
import {Button} from '@/components/ui/button';
|
||||||
import {Card} from '@/components/ui/card';
|
import {Card} from '@/components/ui/card';
|
||||||
@@ -86,6 +86,9 @@ export default function PileOfShameCard({summary, syncing, onShuffle, shuffling}
|
|||||||
{suggestion.name ?? `App ${suggestion.appid}`}
|
{suggestion.name ?? `App ${suggestion.appid}`}
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
<Button asChild variant="outline" size="sm" className="w-full">
|
||||||
|
<Link to="/play-next"><Dices/>{t('DASHBOARD.OPEN_PICKER')}</Link>
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-muted-foreground">{t('DASHBOARD.BACKLOG_EMPTY')}</p>
|
<p className="text-sm text-muted-foreground">{t('DASHBOARD.BACKLOG_EMPTY')}</p>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||||
|
import {cleanup, render, screen, waitFor} from '@testing-library/react';
|
||||||
|
import {MemoryRouter} from 'react-router-dom';
|
||||||
|
import PlayNextPage from './PlayNextPage.jsx';
|
||||||
|
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
|
||||||
|
|
||||||
|
// react-i18next hands out a new `t` on every language change; this mock does the same on demand.
|
||||||
|
const i18n = {language: 'de', t: (key) => key};
|
||||||
|
vi.mock('react-i18next', () => ({useTranslation: () => ({t: i18n.t, i18n})}));
|
||||||
|
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
|
||||||
|
|
||||||
|
const game = (appid) => ({appid, name: `Game ${appid}`, playtimeMinutes: 0, status: 'BACKLOG', rating: null,
|
||||||
|
priority: null, timeToBeatMinutes: 600});
|
||||||
|
|
||||||
|
const pickerCalls = () => globalThis.fetch.mock.calls.filter(([url]) => url.includes('/api/library/picker')).length;
|
||||||
|
|
||||||
|
describe('PlayNextPage on a language change', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.setItem('tbp_jwt', 'token');
|
||||||
|
i18n.t = (key) => key;
|
||||||
|
globalThis.fetch = vi.fn(async () => ({ok: true, status: 200, json: async () => ({games: [game(1)]})}));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the picks when the language changes', async () => {
|
||||||
|
const {rerender} = render(<MemoryRouter><PreferencesProvider><PlayNextPage/></PreferencesProvider></MemoryRouter>);
|
||||||
|
await screen.findByText('Game 1');
|
||||||
|
expect(pickerCalls()).toBe(1);
|
||||||
|
|
||||||
|
i18n.language = 'en';
|
||||||
|
i18n.t = (key) => key;
|
||||||
|
rerender(<MemoryRouter><PreferencesProvider><PlayNextPage/></PreferencesProvider></MemoryRouter>);
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText('Game 1')).toBeTruthy());
|
||||||
|
expect(pickerCalls()).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||||
|
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
|
||||||
|
import {MemoryRouter} from 'react-router-dom';
|
||||||
|
import PlayNextPage from './PlayNextPage.jsx';
|
||||||
|
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
|
||||||
|
|
||||||
|
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||||
|
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
|
||||||
|
|
||||||
|
const game = (appid, extra = {}) => ({
|
||||||
|
appid, name: `Game ${appid}`, playtimeMinutes: 0, status: 'BACKLOG', rating: null, priority: null,
|
||||||
|
timeToBeatMinutes: 600, ...extra,
|
||||||
|
});
|
||||||
|
|
||||||
|
const json = (body, status = 200) => ({ok: status < 400, status, json: async () => body});
|
||||||
|
|
||||||
|
// Answers each picker call with the next list and records every request.
|
||||||
|
function mockBackend(pickerAnswers, detail = null) {
|
||||||
|
const answers = [...pickerAnswers];
|
||||||
|
globalThis.fetch = vi.fn(async (url, options = {}) => {
|
||||||
|
if (url.includes('/api/library/picker')) return json({games: answers.shift() ?? []});
|
||||||
|
if (options.method === 'PUT') return json(JSON.parse(options.body));
|
||||||
|
return json(detail);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const pickerUrls = () => globalThis.fetch.mock.calls
|
||||||
|
.map(([url]) => url)
|
||||||
|
.filter((url) => url.includes('/api/library/picker'))
|
||||||
|
.map((url) => new URL(url, 'http://x').searchParams);
|
||||||
|
|
||||||
|
function renderPage() {
|
||||||
|
render(<MemoryRouter><PreferencesProvider><PlayNextPage/></PreferencesProvider></MemoryRouter>);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('PlayNextPage', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.setItem('tbp_jwt', 'token');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows three picks for any length by default', async () => {
|
||||||
|
mockBackend([[game(1), game(2), game(3)]]);
|
||||||
|
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
expect(await screen.findByText('Game 1')).toBeTruthy();
|
||||||
|
expect(screen.getByText('Game 3')).toBeTruthy();
|
||||||
|
const params = pickerUrls()[0];
|
||||||
|
expect(params.get('count')).toBe('3');
|
||||||
|
expect(params.has('maxMinutes')).toBe(false);
|
||||||
|
expect(params.has('exclude')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('asks for games up to the chosen length', async () => {
|
||||||
|
mockBackend([[game(1)], [game(2)]]);
|
||||||
|
renderPage();
|
||||||
|
await screen.findByText('Game 1');
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', {name: 'PLAY_NEXT.BUDGET.SHORT'}));
|
||||||
|
|
||||||
|
expect(await screen.findByText('Game 2')).toBeTruthy();
|
||||||
|
expect(pickerUrls()[1].get('maxMinutes')).toBe('300');
|
||||||
|
expect(pickerUrls()[1].has('exclude')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops the old picks when a new budget fails to load', async () => {
|
||||||
|
mockBackend([[game(1)]]);
|
||||||
|
const pickerFetch = globalThis.fetch;
|
||||||
|
globalThis.fetch = vi.fn(async (url, options) =>
|
||||||
|
(url.includes('maxMinutes=300') ? json({}, 500) : pickerFetch(url, options)));
|
||||||
|
renderPage();
|
||||||
|
await screen.findByText('Game 1');
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', {name: 'PLAY_NEXT.BUDGET.SHORT'}));
|
||||||
|
|
||||||
|
expect(await screen.findByText('PLAY_NEXT.ERROR_LOAD')).toBeTruthy();
|
||||||
|
expect(screen.queryByText('Game 1')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rerolls without the games already shown', async () => {
|
||||||
|
mockBackend([[game(1), game(2)], [game(3)]]);
|
||||||
|
renderPage();
|
||||||
|
await screen.findByText('Game 1');
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', {name: 'PLAY_NEXT.REROLL'}));
|
||||||
|
|
||||||
|
expect(await screen.findByText('Game 3')).toBeTruthy();
|
||||||
|
expect(pickerUrls()[1].getAll('exclude')).toEqual(['1', '2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('offers to start over when every game was shown', async () => {
|
||||||
|
mockBackend([[game(1)], [], [game(1)]]);
|
||||||
|
renderPage();
|
||||||
|
await screen.findByText('Game 1');
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', {name: 'PLAY_NEXT.REROLL'}));
|
||||||
|
fireEvent.click(await screen.findByRole('button', {name: 'PLAY_NEXT.START_OVER'}));
|
||||||
|
|
||||||
|
expect(await screen.findByText('Game 1')).toBeTruthy();
|
||||||
|
expect(pickerUrls()[2].has('exclude')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says so when the backlog is empty', async () => {
|
||||||
|
mockBackend([[]]);
|
||||||
|
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
expect(await screen.findByText('PLAY_NEXT.EMPTY')).toBeTruthy();
|
||||||
|
expect(screen.queryByRole('button', {name: 'PLAY_NEXT.REROLL'})).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts playing with a status-only update', async () => {
|
||||||
|
mockBackend([[game(7, {priority: 'HIGH'})]]);
|
||||||
|
renderPage();
|
||||||
|
await screen.findByText('Game 7');
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', {name: 'PLAY_NEXT.START_PLAYING'}));
|
||||||
|
|
||||||
|
expect(await screen.findByText('PLAY_NEXT.STARTED')).toBeTruthy();
|
||||||
|
const puts = globalThis.fetch.mock.calls.filter(([, options]) => options?.method === 'PUT');
|
||||||
|
expect(puts).toHaveLength(1);
|
||||||
|
expect(puts[0][0]).toContain('/api/library/games/7/status');
|
||||||
|
expect(JSON.parse(puts[0][1].body)).toEqual({status: 'PLAYING'});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets the user try again when saving fails', async () => {
|
||||||
|
mockBackend([[game(7)]]);
|
||||||
|
const pickerFetch = globalThis.fetch;
|
||||||
|
globalThis.fetch = vi.fn(async (url, options) =>
|
||||||
|
(options?.method === 'PUT' ? json({}, 500) : pickerFetch(url, options)));
|
||||||
|
renderPage();
|
||||||
|
await screen.findByText('Game 7');
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', {name: 'PLAY_NEXT.START_PLAYING'}));
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByRole('button', {name: 'PLAY_NEXT.START_PLAYING'}).disabled).toBe(false));
|
||||||
|
expect(screen.queryByText('PLAY_NEXT.STARTED')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never sends more excludes than the backend reads', async () => {
|
||||||
|
const answers = Array.from({length: 40}, (_, round) => [game(round * 3 + 1), game(round * 3 + 2), game(round * 3 + 3)]);
|
||||||
|
mockBackend(answers);
|
||||||
|
renderPage();
|
||||||
|
await screen.findByText('Game 1');
|
||||||
|
|
||||||
|
for (let round = 1; round < 40; round++) {
|
||||||
|
fireEvent.click(screen.getByRole('button', {name: 'PLAY_NEXT.REROLL'}));
|
||||||
|
await screen.findByText(`Game ${round * 3 + 1}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const largest = Math.max(...pickerUrls().map((params) => params.getAll('exclude').length));
|
||||||
|
expect(largest).toBeLessThanOrEqual(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('offers a retry when the first load fails', async () => {
|
||||||
|
globalThis.fetch = vi.fn(async () => { throw new TypeError('Failed to fetch'); });
|
||||||
|
renderPage();
|
||||||
|
await screen.findByText('INFO.ERROR.NETWORK_FAILURE');
|
||||||
|
|
||||||
|
expect(screen.queryByRole('button', {name: 'PLAY_NEXT.REROLL'})).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -25,6 +25,16 @@ export const saveBacklogEntry = (appid, entry) =>
|
|||||||
body: JSON.stringify(entry),
|
body: JSON.stringify(entry),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/library/games/{appid}/status. The caller handles the response status.
|
||||||
|
*/
|
||||||
|
export const saveGameStatus = (appid, status) =>
|
||||||
|
fetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/games/${appid}/status`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {'Authorization': `Bearer ${localStorage.getItem('tbp_jwt')}`, 'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({status}),
|
||||||
|
});
|
||||||
|
|
||||||
export const libraryQuery = ({page, size, sort, status, q, maxMinutes}) => {
|
export const libraryQuery = ({page, size, sort, status, q, maxMinutes}) => {
|
||||||
const params = new URLSearchParams({page: String(page), size: String(size), sort});
|
const params = new URLSearchParams({page: String(page), size: String(size), sort});
|
||||||
if (status) params.set('status', status);
|
if (status) params.set('status', status);
|
||||||
|
|||||||
Reference in new issue
Block a user