Compare commits

..
4 Commits
Author SHA1 Message Date
lobo 5ff8d48c53 refactor(auth): send login token through apiFetch
Build, Test & Push Frontend / docker (push) Successful in 23s
Build, Test & Push Frontend / quality-check (push) Successful in 13s
All authenticated calls now use apiFetch, and only utils/auth.js
touches the stored token. The cookie login can then change these two
files instead of every page.
2026-09-25 11:29:14 +02:00
lobo 085fcf15a4 feat(achievements): dashboard card, library badge, 100 % hint and profile stats 2026-09-25 10:13:31 +02:00
lobo 2efdf64675 feat(play-next): page that picks the next backlog game 2026-09-25 09:29:15 +02:00
lobo 8dcd03f219 feat(registration): show the wait time after too many attempts 2026-09-25 09:06:30 +02:00
35 changed files with 1051 additions and 89 deletions

No files matched your search

+9 -6
View File
@@ -20,13 +20,17 @@ To Be Played is split into three repositories:
## Features
- **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, and an achievement card: unlocked
total, perfect games, average completion and the 5 games closest to 100 %.
- **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
in library, rating or priority, paging.
in library, rating or priority, paging. Each row shows the unlocked achievements.
- **Game detail**: status, priority, 1–5 star rating, notes, achievements, time to beat (story / extras / 100 %),
and a hint when Steam shows recent playtime for a game still in the backlog.
a hint when Steam shows recent playtime for a game still in the backlog, and one to set the status to 100 % when
every achievement is unlocked.
- **Wishlist** with store price and discount.
- **Public profile** at `/u/<display name>`, opt-in in the settings.
- **Public profile** at `/u/<display name>`, opt-in in the settings, with perfect games and average completion.
- **Appearance**: accent color, corner style, wide layout, page background and banner from your own game art.
Saved in Auth, so it follows you to every device.
- German and English, dark mode, mobile layout, session-expired message instead of a silent logout.
@@ -80,8 +84,7 @@ npm run build
## Roadmap
- "What to play next" page: time budget and mood on top of the current random suggestion.
- Achievement progress on the library cards and a library-wide achievement page.
- Mood filter for "What to play next".
- Move the JWT from `localStorage` to an httpOnly cookie (needs Auth changes) and add a Content Security Policy in
`nginx.conf`.
- Installable PWA.
+45 -6
View File
@@ -32,7 +32,8 @@
"ERROR_INVALID_INVITE_CODE": "Der Einladungscode ist ungültig oder wurde bereits verwendet.",
"ERROR_STEAM_ID_IN_USE": "Diese Steam-ID ist bereits mit einem anderen Konto verknüpft.",
"DISPLAY_NAME": "Anzeigename (optional)",
"ERROR_DISPLAY_NAME_IN_USE": "Dieser Anzeigename ist schon vergeben."
"ERROR_DISPLAY_NAME_IN_USE": "Dieser Anzeigename ist schon vergeben.",
"ERROR_TOO_MANY_ATTEMPTS": "Zu viele fehlgeschlagene Versuche. Bitte in {{minutes}} Minuten erneut versuchen."
},
"SETTINGS": {
"TITLE": "Einstellungen",
@@ -132,7 +133,19 @@
"ACTIVITY_TOTAL": "{{time}} in {{days}} Tagen",
"ACTIVITY_EMPTY": "Noch keine Spielzeit aufgezeichnet. Ab jetzt zählt jeder Sync mit.",
"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",
"ACHIEVEMENTS": {
"TITLE": "Erfolge",
"UNLOCKED": "Freigeschaltet",
"PERFECT": "Perfekte Spiele",
"AVERAGE": "Ø Abschluss",
"CLOSEST": "Fast bei 100 %",
"NONE": "Noch keine Erfolge freigeschaltet.",
"PENDING_one": "Für {{count}} Spiel fehlen noch Erfolgsdaten. Sie werden nach jedem Steam-Sync geladen.",
"PENDING_other": "Für {{count}} Spiele fehlen noch Erfolgsdaten. Sie werden nach jedem Steam-Sync geladen.",
"HIDDEN": "Steam zeigt deine Erfolge nicht. Stell in den Steam-Privatsphäre-Einstellungen „Spieldetails“ auf öffentlich."
}
},
"LIBRARY": {
"TITLE": "Spielebibliothek",
@@ -161,7 +174,8 @@
"NO_MATCH": "Keine Spiele für diesen Filter.",
"SORT_RATING": "Beste Bewertung",
"SORT_PRIORITY": "Höchste Priorität",
"FILTER_SHORT": "Kurz (bis 10 h)"
"FILTER_SHORT": "Kurz (bis 10 h)",
"ACHIEVEMENTS": "{{unlocked}} von {{total}} Erfolgen"
},
"GAME_DETAIL": {
"BACK": "Zurück",
@@ -185,7 +199,9 @@
"TTB_COMPLETELY": "100 %",
"SET_BACKGROUND": "Als Hintergrund",
"REMOVE_BACKGROUND": "Hintergrund entfernen",
"SET_BANNER": "Als Banner"
"SET_BANNER": "Als Banner",
"ALL_UNLOCKED": "Du hast alle Erfolge dieses Spiels freigeschaltet.",
"SET_COMPLETED": "Auf 100 % setzen"
},
"LANDING": {
"STATS_TITLE": "Community-Statistiken",
@@ -217,7 +233,8 @@
"MENU": "Menü",
"LANGUAGE": "Sprache",
"DARK_MODE": "Dunkles Design",
"LIGHT_MODE": "Helles Design"
"LIGHT_MODE": "Helles Design",
"PLAY_NEXT": "Nächstes Spiel"
},
"BACKLOG": {
"TITLE": "Mein Backlog",
@@ -265,6 +282,28 @@
"FINISHED_GAMES": "Durchgespielte Spiele",
"NOT_FOUND": "Dieses Profil gibt es nicht oder es ist privat.",
"ERROR_LOAD": "Profil konnte nicht geladen werden.",
"TO_START": "Zur Startseite"
"TO_START": "Zur Startseite",
"PERFECT_GAMES": "Perfekte Spiele",
"AVERAGE_COMPLETION": "Ø Abschluss"
},
"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."
}
}
+45 -6
View File
@@ -32,7 +32,8 @@
"ERROR_INVALID_INVITE_CODE": "The invite code is invalid or has already been used.",
"ERROR_STEAM_ID_IN_USE": "This Steam ID is already linked to another account.",
"DISPLAY_NAME": "Display name (optional)",
"ERROR_DISPLAY_NAME_IN_USE": "This display name is already taken."
"ERROR_DISPLAY_NAME_IN_USE": "This display name is already taken.",
"ERROR_TOO_MANY_ATTEMPTS": "Too many failed attempts. Please try again in {{minutes}} minutes."
},
"SETTINGS": {
"TITLE": "Settings",
@@ -132,7 +133,19 @@
"ACTIVITY_TOTAL": "{{time}} in {{days}} days",
"ACTIVITY_EMPTY": "No playtime recorded yet. Every sync from now on counts.",
"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",
"ACHIEVEMENTS": {
"TITLE": "Achievements",
"UNLOCKED": "Unlocked",
"PERFECT": "Perfect games",
"AVERAGE": "Avg. completion",
"CLOSEST": "Closest to 100 %",
"NONE": "No achievements unlocked yet.",
"PENDING_one": "No achievement data yet for {{count}} game. It is fetched after each Steam sync.",
"PENDING_other": "No achievement data yet for {{count}} games. It is fetched after each Steam sync.",
"HIDDEN": "Steam hides your achievements. Set \"Game details\" to public in your Steam privacy settings."
}
},
"LIBRARY": {
"TITLE": "Game Library",
@@ -161,7 +174,8 @@
"NO_MATCH": "No games match this filter.",
"SORT_RATING": "Best rated",
"SORT_PRIORITY": "Highest priority",
"FILTER_SHORT": "Short (up to 10 h)"
"FILTER_SHORT": "Short (up to 10 h)",
"ACHIEVEMENTS": "{{unlocked}} of {{total}} achievements"
},
"GAME_DETAIL": {
"BACK": "Back",
@@ -185,7 +199,9 @@
"TTB_COMPLETELY": "100%",
"SET_BACKGROUND": "Use as background",
"REMOVE_BACKGROUND": "Remove background",
"SET_BANNER": "Use as banner"
"SET_BANNER": "Use as banner",
"ALL_UNLOCKED": "You unlocked every achievement of this game.",
"SET_COMPLETED": "Set to 100 %"
},
"LANDING": {
"STATS_TITLE": "Community stats",
@@ -217,7 +233,8 @@
"MENU": "Menu",
"LANGUAGE": "Language",
"DARK_MODE": "Dark mode",
"LIGHT_MODE": "Light mode"
"LIGHT_MODE": "Light mode",
"PLAY_NEXT": "Play next"
},
"BACKLOG": {
"TITLE": "My backlog",
@@ -265,6 +282,28 @@
"FINISHED_GAMES": "Finished games",
"NOT_FOUND": "This profile does not exist or is private.",
"ERROR_LOAD": "Could not load the profile.",
"TO_START": "To the start page"
"TO_START": "To the start page",
"PERFECT_GAMES": "Perfect games",
"AVERAGE_COMPLETION": "Avg. completion"
},
"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."
}
}
+5 -2
View File
@@ -11,12 +11,14 @@ import SettingsPage from './pages/SettingsPage.jsx';
import GameLibrary from './pages/GameLibrary.jsx';
import GameDetail from './pages/GameDetail.jsx';
import WishlistPage from './pages/WishlistPage.jsx';
import PlayNextPage from './pages/PlayNextPage.jsx';
import PublicProfilePage from './pages/PublicProfilePage.jsx';
import AppHeader from './components/AppHeader.jsx';
import {Toaster} from '@/components/ui/sonner';
import {TooltipProvider} from '@/components/ui/tooltip';
import PageBackground from './components/PageBackground.jsx';
import {usePreferences} from './context/PreferencesContext.jsx';
import {clearToken, getToken} from './utils/auth.js';
function App() {
const {t} = useTranslation();
@@ -30,7 +32,7 @@ function App() {
useEffect(() => {
// No local expiry check here: a 401 from the backend ends the session (useSessionExpired), and a browser
// clock that runs ahead would otherwise end it silently.
setIsAuthenticated(!!localStorage.getItem('tbp_jwt'));
setIsAuthenticated(!!getToken());
}, [location]);
// On login and on page load; the cached look shows until Auth answers.
@@ -39,7 +41,7 @@ function App() {
}, [isAuthenticated, syncAppearance]);
const handleLogout = () => {
localStorage.removeItem('tbp_jwt');
clearToken();
resetAppearance();
setIsAuthenticated(false);
navigate('/login');
@@ -61,6 +63,7 @@ function App() {
<Route path="/library" element={<GameLibrary/>}/>
<Route path="/library/:appid" element={<GameDetail/>}/>
<Route path="/wishlist" element={<WishlistPage/>}/>
<Route path="/play-next" element={<PlayNextPage/>}/>
<Route path="/u/:name" element={<PublicProfilePage/>}/>
<Route path="/settings" element={<SettingsPage/>}/>
<Route path="/impressum" element={<ImprintPage/>}/>
+87
View File
@@ -0,0 +1,87 @@
import {Link} from 'react-router-dom';
import {useTranslation} from 'react-i18next';
import {Trophy} from 'lucide-react';
import {Card} from '@/components/ui/card';
import GameCover from './GameCover.jsx';
/**
* @param summary the answer of GET /api/library/achievements/summary
*/
export default function AchievementsCard({summary}) {
const {t, i18n} = useTranslation();
const closest = summary.closest ?? [];
const started = summary.startedGames ?? 0;
const pending = summary.pendingGames ?? 0;
const hidden = summary.hiddenGames ?? 0;
// A private profile keeps games pending; the privacy hint explains that better than the pending count.
const steamHidesAll = hidden > 0 && started === 0;
const stats = [
[(summary.unlocked ?? 0).toLocaleString(i18n.language), t('DASHBOARD.ACHIEVEMENTS.UNLOCKED')],
[(summary.perfectGames ?? 0).toLocaleString(i18n.language), t('DASHBOARD.ACHIEVEMENTS.PERFECT')],
[summary.averageCompletion != null ? `${summary.averageCompletion.toLocaleString(i18n.language)} %` : '–',
t('DASHBOARD.ACHIEVEMENTS.AVERAGE')],
];
return (
<Card className="gap-5 p-5">
<h2 className="flex items-center gap-2 font-semibold">
<Trophy className="size-4 text-amber-500"/>
{t('DASHBOARD.ACHIEVEMENTS.TITLE')}
</h2>
<div className="grid grid-cols-3 gap-3">
{stats.map(([value, label]) => (
<div key={label} className="rounded-lg bg-muted/50 p-3 text-center">
<div className="text-xl md:text-2xl font-bold tabular-nums">{value}</div>
<div className="text-xs text-muted-foreground">{label}</div>
</div>
))}
</div>
{started === 0 && pending === 0 && hidden === 0 && (
<p className="text-sm text-muted-foreground">{t('DASHBOARD.ACHIEVEMENTS.NONE')}</p>
)}
{closest.length > 0 && (
<div className="space-y-2">
<h3 className="text-sm font-medium">{t('DASHBOARD.ACHIEVEMENTS.CLOSEST')}</h3>
<ul className="space-y-2">
{closest.map((game) => (
<li key={game.appid}>
<Link
to={`/library/${game.appid}`}
className="group flex items-center gap-3 rounded-lg p-1 transition-colors hover:bg-muted/50"
>
<GameCover appid={game.appid} className="w-16 shrink-0"/>
<div className="min-w-0 flex-1 space-y-1">
<div className="flex items-baseline justify-between gap-2">
<span className="truncate text-sm font-medium group-hover:text-primary transition-colors">
{game.name ?? `App ${game.appid}`}
</span>
<span className="shrink-0 text-xs text-muted-foreground tabular-nums">
{game.unlocked} / {game.total}
</span>
</div>
<div className="h-1.5 overflow-hidden rounded-full bg-muted">
<div className="h-full rounded-full bg-amber-500"
style={{width: `${(game.unlocked / game.total) * 100}%`}}/>
</div>
</div>
</Link>
</li>
))}
</ul>
</div>
)}
{pending > 0 && !steamHidesAll && (
<p className="text-xs text-muted-foreground">{t('DASHBOARD.ACHIEVEMENTS.PENDING', {count: pending})}</p>
)}
{steamHidesAll && (
<p className="text-xs text-muted-foreground">{t('DASHBOARD.ACHIEVEMENTS.HIDDEN')}</p>
)}
</Card>
);
}
+52
View File
@@ -0,0 +1,52 @@
// @vitest-environment jsdom
import {afterEach, describe, expect, it, vi} from 'vitest';
import {cleanup, render, screen} from '@testing-library/react';
import {MemoryRouter} from 'react-router-dom';
import AchievementsCard from './AchievementsCard.jsx';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
const summary = (extra = {}) => ({
unlocked: 24, startedGames: 4, perfectGames: 2, averageCompletion: 71.3, pendingGames: 0, hiddenGames: 0,
closest: [{appid: 20, name: 'Portal 2', unlocked: 3, total: 4}], ...extra,
});
const renderCard = (data) => render(<MemoryRouter><AchievementsCard summary={data}/></MemoryRouter>);
describe('AchievementsCard', () => {
afterEach(() => {
cleanup();
});
it('shows the totals and links the games closest to 100 %', () => {
renderCard(summary());
expect(screen.getByText('24')).toBeTruthy();
expect(screen.getByText('2')).toBeTruthy();
expect(screen.getByText('71,3 %')).toBeTruthy();
expect(screen.getByRole('link', {name: /Portal 2/}).getAttribute('href')).toBe('/library/20');
expect(screen.getByText('3 / 4')).toBeTruthy();
expect(screen.queryByText(/DASHBOARD.ACHIEVEMENTS.PENDING/)).toBeNull();
});
it('says how many games are still syncing', () => {
renderCard(summary({pendingGames: 7}));
expect(screen.getByText('DASHBOARD.ACHIEVEMENTS.PENDING:7')).toBeTruthy();
});
it('explains hidden achievements when nothing is visible', () => {
renderCard(summary({unlocked: 0, startedGames: 0, perfectGames: 0, averageCompletion: null, hiddenGames: 5,
pendingGames: 200, closest: []}));
expect(screen.getByText('DASHBOARD.ACHIEVEMENTS.HIDDEN')).toBeTruthy();
expect(screen.queryByText(/DASHBOARD.ACHIEVEMENTS.PENDING/)).toBeNull();
expect(screen.getByText('–')).toBeTruthy();
});
it('says so when nothing is unlocked yet', () => {
renderCard(summary({unlocked: 0, startedGames: 0, perfectGames: 0, averageCompletion: null, closest: []}));
expect(screen.getByText('DASHBOARD.ACHIEVEMENTS.NONE')).toBeTruthy();
});
});
+2 -1
View File
@@ -1,6 +1,6 @@
import {Link, NavLink, useLocation} from 'react-router-dom';
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 {
@@ -20,6 +20,7 @@ const NAV_ITEMS = [
{to: '/dashboard', labelKey: 'NAV.DASHBOARD', Icon: LayoutDashboard},
// The wishlist is a tab of the library.
{to: '/library', labelKey: 'NAV.LIBRARY', Icon: Library, alsoActiveOn: ['/wishlist']},
{to: '/play-next', labelKey: 'NAV.PLAY_NEXT', Icon: Dices},
];
const navLinkClass = (isActive) => cn(
+2 -2
View File
@@ -13,6 +13,7 @@ import {cn} from '@/lib/utils';
import {STATUS_META} from './GameStatusBadge.jsx';
import {GAME_PRIORITIES, GAME_STATUSES, NO_PRIORITY, saveBacklogEntry, toBacklogRequest} from '../utils/backlog.js';
import useSessionExpired from '../hooks/useSessionExpired.js';
import {getToken} from '../utils/auth.js';
const NOTES_MAX = 2000;
@@ -41,8 +42,7 @@ export default function BacklogCard({game, onSaved}) {
const set = (field) => (value) => setForm((f) => ({...f, [field]: value}));
const save = async () => {
const token = localStorage.getItem('tbp_jwt');
if (!token) {
if (!getToken()) {
navigate('/login');
return;
}
+59
View File
@@ -0,0 +1,59 @@
import {useState} from 'react';
import {useTranslation} from 'react-i18next';
import {toast} from 'sonner';
import {Loader2, Trophy} from 'lucide-react';
import {Button} from '@/components/ui/button';
import {Card} from '@/components/ui/card';
import useSessionExpired from '../hooks/useSessionExpired.js';
import {saveGameStatus} from '../utils/backlog.js';
/**
* Shown for a game with every achievement unlocked that is not marked as 100 % yet.
*
* @param onChanged gets the saved backlog entry
*/
export default function CompletedHint({game, onChanged}) {
const {t} = useTranslation();
const expireSession = useSessionExpired();
const [busy, setBusy] = useState(false);
const allUnlocked = game.hasAchievements && game.statsVisible && game.achievementsTotal > 0
&& game.achievementsUnlocked >= game.achievementsTotal;
if (!allUnlocked || game.status === 'COMPLETED') return null;
const setCompleted = async () => {
setBusy(true);
try {
const response = await saveGameStatus(game.appid, 'COMPLETED');
if (response.status === 401) {
expireSession();
return;
}
if (!response.ok) {
toast.error(t('BACKLOG.SAVE_FAILED'));
return;
}
onChanged(await response.json());
toast.success(t('BACKLOG.SAVED'));
} catch (err) {
console.error('set completed error:', err);
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
} finally {
setBusy(false);
}
};
return (
<Card className="gap-3 border-amber-500/40 bg-amber-500/5 p-4 sm:flex-row sm:items-center sm:gap-4">
<div className="flex flex-1 items-start gap-3 sm:items-center">
<Trophy className="size-5 shrink-0 text-amber-500"/>
<p className="text-sm">{t('GAME_DETAIL.ALL_UNLOCKED')}</p>
</div>
<Button size="sm" className="self-end sm:self-auto" onClick={setCompleted} disabled={busy}>
{busy && <Loader2 className="animate-spin"/>}
{t('GAME_DETAIL.SET_COMPLETED')}
</Button>
</Card>
);
}
+48
View File
@@ -0,0 +1,48 @@
// @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 CompletedHint from './CompletedHint.jsx';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
const game = {appid: 220, status: 'FINISHED', hasAchievements: true, statsVisible: true, achievementsTotal: 10, achievementsUnlocked: 10};
const renderHint = (props) => render(<MemoryRouter><CompletedHint game={game} onChanged={() => {}} {...props}/></MemoryRouter>);
describe('CompletedHint', () => {
beforeEach(() => {
localStorage.setItem('tbp_jwt', 'token');
globalThis.fetch = vi.fn(async (url, init) => ({
ok: true, status: 200, json: async () => ({...JSON.parse(init.body), rating: 5, notes: 'keep', priority: null}),
}));
});
afterEach(() => {
cleanup();
localStorage.clear();
});
it.each([
['a game already at 100 %', {...game, status: 'COMPLETED'}],
['a game with achievements left', {...game, achievementsUnlocked: 9}],
['a game without achievements', {...game, hasAchievements: false, achievementsTotal: 0, achievementsUnlocked: 0}],
['a game whose achievements Steam hides', {...game, statsVisible: false}],
])('is hidden for %s', (_, other) => {
renderHint({game: other});
expect(screen.queryByRole('button')).toBeNull();
});
it('sets only the status to COMPLETED', async () => {
const onChanged = vi.fn();
renderHint({onChanged});
fireEvent.click(screen.getByRole('button', {name: 'GAME_DETAIL.SET_COMPLETED'}));
await waitFor(() => expect(onChanged).toHaveBeenCalledWith({status: 'COMPLETED', rating: 5, notes: 'keep', priority: null}));
const [url, init] = fetch.mock.calls[0];
expect(url).toMatch(/\/api\/library\/games\/220\/status$/);
expect(JSON.parse(init.body)).toEqual({status: 'COMPLETED'});
});
});
+4 -1
View File
@@ -1,6 +1,6 @@
import {Link} from 'react-router-dom';
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 {Card} from '@/components/ui/card';
@@ -86,6 +86,9 @@ export default function PileOfShameCard({summary, syncing, onShuffle, shuffling}
{suggestion.name ?? `App ${suggestion.appid}`}
</span>
</Link>
<Button asChild variant="outline" size="sm" className="w-full">
<Link to="/play-next"><Dices/>{t('DASHBOARD.OPEN_PICKER')}</Link>
</Button>
</div>
) : (
<p className="text-sm text-muted-foreground">{t('DASHBOARD.BACKLOG_EMPTY')}</p>
+4 -4
View File
@@ -3,6 +3,8 @@ import {useNavigate} from 'react-router-dom';
import {useTranslation} from 'react-i18next';
import {toast} from 'sonner';
import useSessionExpired from './useSessionExpired.js';
import {apiFetch} from '../utils/api.js';
import {getToken} from '../utils/auth.js';
const RELOAD_DELAY_MS = 4000;
@@ -17,16 +19,14 @@ export default function useForceSync(onStarted) {
const [busy, setBusy] = useState(false);
const trigger = async () => {
const token = localStorage.getItem('tbp_jwt');
if (!token) {
if (!getToken()) {
navigate('/login');
return;
}
setBusy(true);
try {
const response = await fetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/sync`, {
const response = await apiFetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/sync`, {
method: 'POST',
headers: {'Authorization': `Bearer ${token}`},
});
if (response.status === 401) {
expireSession();
+5 -5
View File
@@ -1,4 +1,6 @@
import {useEffect, useRef, useState} from 'react';
import {apiFetch} from '../utils/api.js';
import {getToken} from '../utils/auth.js';
const POLL_INTERVAL_MS = 3000;
const MAX_POLL_DURATION_MS = 120_000;
@@ -32,13 +34,11 @@ export default function useLibrarySync({enabled, expectInitialSync = false, onCo
const startedAt = Date.now();
const fetchOnce = async () => {
const token = localStorage.getItem('tbp_jwt');
if (!token) return;
if (!getToken()) return;
try {
const response = await fetch(
`${import.meta.env.VITE_CORE_API_URL}/api/library/sync-status`,
{headers: {'Authorization': `Bearer ${token}`}}
const response = await apiFetch(
`${import.meta.env.VITE_CORE_API_URL}/api/library/sync-status`
);
if (cancelled) return;
+2 -1
View File
@@ -1,6 +1,7 @@
import {useCallback, useRef} from 'react';
import {useLocation, useNavigate} from 'react-router-dom';
import {useResetAppearance} from '../context/PreferencesContext.jsx';
import {clearToken} from '../utils/auth.js';
/**
* Returns a function for a 401 answer: it removes the token and the user's look and opens the login page, which
@@ -16,7 +17,7 @@ export default function useSessionExpired() {
return useCallback(() => {
const {pathname, search} = locationRef.current;
localStorage.removeItem('tbp_jwt');
clearToken();
resetAppearance();
navigate('/login', {replace: true, state: {sessionExpired: true, from: pathname + search}});
}, [navigate, resetAppearance]);
+4 -4
View File
@@ -1,4 +1,6 @@
import {useEffect, useState} from 'react';
import {apiFetch} from '../utils/api.js';
import {getToken} from '../utils/auth.js';
/**
* The user's Steam name and avatar from Core, or null while loading, without a Steam ID or on any error. Only
@@ -7,7 +9,7 @@ import {useEffect, useState} from 'react';
*/
export default function useSteamProfile(enabled) {
const [profile, setProfile] = useState(null);
const token = localStorage.getItem('tbp_jwt');
const token = getToken();
useEffect(() => {
if (!enabled || !token) {
@@ -17,9 +19,7 @@ export default function useSteamProfile(enabled) {
let cancelled = false;
(async () => {
try {
const response = await fetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/steam-profile`, {
headers: {'Authorization': `Bearer ${token}`},
});
const response = await apiFetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/steam-profile`);
const data = response.ok ? await response.json() : null;
if (!cancelled) setProfile(data);
} catch {
+33 -15
View File
@@ -8,6 +8,7 @@ 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 AchievementsCard from '../components/AchievementsCard.jsx';
import ActivityCard from '../components/ActivityCard.jsx';
import CurrentlyPlayingCard from '../components/CurrentlyPlayingCard.jsx';
import GameCover from '../components/GameCover.jsx';
@@ -24,6 +25,8 @@ import SteamAvatar from '../components/SteamAvatar.jsx';
import useSteamProfile from '../hooks/useSteamProfile.js';
import {cn} from '@/lib/utils';
import {usePreferences} from '../context/PreferencesContext.jsx';
import {apiFetch} from '../utils/api.js';
import {getToken} from '../utils/auth.js';
const TOP_GAMES = 5;
// The games endpoint accepts only the page sizes 20, 40 and 80.
@@ -31,10 +34,7 @@ const TOP_GAMES_URL = '/api/library/games?page=0&size=20&sort=playtimeMinutes%2C
const PLAYING_GAMES = 4;
const PLAYING_URL = '/api/library/games?page=0&size=20&sort=playtimeMinutes%2Cdesc&status=PLAYING';
const authHeaders = () => ({
'Authorization': `Bearer ${localStorage.getItem('tbp_jwt')}`,
'Content-Type': 'application/json',
});
const JSON_HEADERS = {'Content-Type': 'application/json'};
const Dashboard = () => {
const {t} = useTranslation();
@@ -46,6 +46,7 @@ const Dashboard = () => {
const [playing, setPlaying] = useState([]);
const [summary, setSummary] = useState(null);
const [activity, setActivity] = useState(null);
const [achievements, setAchievements] = useState(null);
const [summaryFailed, setSummaryFailed] = useState(false);
const [shuffling, setShuffling] = useState(false);
const [isLoading, setIsLoading] = useState(true);
@@ -53,7 +54,7 @@ const Dashboard = () => {
const fetchSteamCount = useCallback(async () => {
try {
const r = await fetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/games-count`, {headers: authHeaders()});
const r = await apiFetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/games-count`, {headers: JSON_HEADERS});
if (r.status === 401) {
expireSession();
return;
@@ -68,7 +69,7 @@ const Dashboard = () => {
const fetchTopGames = useCallback(async () => {
try {
const r = await fetch(`${import.meta.env.VITE_CORE_API_URL}${TOP_GAMES_URL}`, {headers: authHeaders()});
const r = await apiFetch(`${import.meta.env.VITE_CORE_API_URL}${TOP_GAMES_URL}`, {headers: JSON_HEADERS});
if (r.status === 401) {
expireSession();
return;
@@ -84,7 +85,7 @@ const Dashboard = () => {
const fetchPlaying = useCallback(async () => {
try {
const r = await fetch(`${import.meta.env.VITE_CORE_API_URL}${PLAYING_URL}`, {headers: authHeaders()});
const r = await apiFetch(`${import.meta.env.VITE_CORE_API_URL}${PLAYING_URL}`, {headers: JSON_HEADERS});
if (r.status === 401) {
expireSession();
return;
@@ -105,7 +106,7 @@ const Dashboard = () => {
const fetchSummary = useCallback(async (excludeAppid) => {
const query = excludeAppid ? `?exclude=${excludeAppid}` : '';
try {
const r = await fetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/summary${query}`, {headers: authHeaders()});
const r = await apiFetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/summary${query}`, {headers: JSON_HEADERS});
if (r.status === 401) {
expireSession();
return true;
@@ -124,7 +125,7 @@ const Dashboard = () => {
const fetchActivity = useCallback(async () => {
try {
const r = await fetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/activity`, {headers: authHeaders()});
const r = await apiFetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/activity`, {headers: JSON_HEADERS});
if (r.status === 401) {
expireSession();
return;
@@ -137,6 +138,21 @@ const Dashboard = () => {
}
}, [expireSession]);
const fetchAchievements = useCallback(async () => {
try {
const r = await apiFetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/achievements/summary`, {headers: JSON_HEADERS});
if (r.status === 401) {
expireSession();
return;
}
if (r.ok) {
setAchievements(await r.json());
}
} catch (err) {
console.error('Error fetching achievements:', err);
}
}, [expireSession]);
const shuffleSuggestion = async () => {
setShuffling(true);
if (!await fetchSummary(summary?.suggestion?.appid)) {
@@ -151,19 +167,20 @@ const Dashboard = () => {
fetchPlaying();
fetchSummary();
fetchActivity();
}, [fetchSteamCount, fetchTopGames, fetchPlaying, fetchSummary, fetchActivity]);
fetchAchievements();
}, [fetchSteamCount, fetchTopGames, fetchPlaying, fetchSummary, fetchActivity, fetchAchievements]);
useEffect(() => {
const fetchUserData = async () => {
if (!localStorage.getItem('tbp_jwt')) {
if (!getToken()) {
navigate('/login');
return;
}
try {
const profileResponse = await fetch(`${import.meta.env.VITE_API_URL}/api/users/me?v=1`, {
const profileResponse = await apiFetch(`${import.meta.env.VITE_API_URL}/api/users/me?v=1`, {
method: 'GET',
headers: authHeaders(),
headers: JSON_HEADERS,
});
if (profileResponse.ok) {
@@ -171,7 +188,7 @@ const Dashboard = () => {
setUserData(data);
if (data.steamId) {
await Promise.all([fetchSteamCount(), fetchTopGames(), fetchPlaying(), fetchSummary(), fetchActivity()]);
await Promise.all([fetchSteamCount(), fetchTopGames(), fetchPlaying(), fetchSummary(), fetchActivity(), fetchAchievements()]);
}
} else if (profileResponse.status === 401) {
expireSession();
@@ -187,7 +204,7 @@ const Dashboard = () => {
};
fetchUserData();
}, [navigate, expireSession, t, fetchSteamCount, fetchTopGames, fetchPlaying, fetchSummary, fetchActivity]);
}, [navigate, expireSession, t, fetchSteamCount, fetchTopGames, fetchPlaying, fetchSummary, fetchActivity, fetchAchievements]);
const hasSteamId = !!userData?.steamId;
const {appearance} = usePreferences();
@@ -340,6 +357,7 @@ const Dashboard = () => {
)}
{hasSteamId && activity && <ActivityCard activity={activity}/>}
{hasSteamId && achievements && <AchievementsCard summary={achievements}/>}
</PageContainer>
);
};
+9
View File
@@ -56,6 +56,8 @@ describe('Dashboard', () => {
if (url.includes('/api/library/activity')) return ok({totalMinutes: 90, days: [{day: '2026-09-23', minutes: 0}, {day: '2026-09-24', minutes: 90}], recentGames: [
{appid: 1086940, name: "Baldur's Gate 3", playtimeMinutes: 900, playtime2Weeks: 90, status: 'PLAYING'}]});
if (url.includes('/api/library/games-count')) return ok({count: 1, hasSteamId: true});
if (url.includes('/api/library/achievements/summary')) return ok({unlocked: 24, startedGames: 4, perfectGames: 2,
averageCompletion: 71.3, pendingGames: 0, hiddenGames: 0, closest: []});
return ok({content: [], total: 0, byStatus: {}});
});
return render(
@@ -72,6 +74,13 @@ describe('Dashboard', () => {
expect(container.querySelector('img[src$="/1086940/library_hero.jpg"]')).not.toBeNull();
});
it('shows the achievement summary', async () => {
mockDashboard();
expect(await screen.findByText('DASHBOARD.ACHIEVEMENTS.TITLE')).toBeTruthy();
expect(screen.getByText('71,3 %')).toBeTruthy();
});
it('shows the picked banner game instead', async () => {
localStorage.setItem('tbp_appearance', JSON.stringify({bannerAppid: 620}));
const container = mockDashboard();
+6 -4
View File
@@ -11,6 +11,7 @@ import {cn} from '@/lib/utils';
import BacklogCard from '../components/BacklogCard.jsx';
import GameCover from '../components/GameCover.jsx';
import GameStatusBadge from '../components/GameStatusBadge.jsx';
import CompletedHint from '../components/CompletedHint.jsx';
import PlayingHint from '../components/PlayingHint.jsx';
import TimeToBeat from '../components/TimeToBeat.jsx';
import {formatPlaytime} from '../utils/games.js';
@@ -18,6 +19,8 @@ import useSessionExpired from '../hooks/useSessionExpired.js';
import useSaveAppearance from '../hooks/useSaveAppearance.js';
import {usePreferences} from '../context/PreferencesContext.jsx';
import PageContainer from '../components/PageContainer.jsx';
import {apiFetch} from '../utils/api.js';
import {getToken} from '../utils/auth.js';
const formatUnlockedAt = (instant, locale) => {
if (!instant) return '';
@@ -42,8 +45,7 @@ export default function GameDetail() {
useEffect(() => {
let cancelled = false;
const token = localStorage.getItem('tbp_jwt');
if (!token) {
if (!getToken()) {
navigate('/login');
return;
}
@@ -52,9 +54,8 @@ export default function GameDetail() {
(async () => {
try {
const url = `${import.meta.env.VITE_CORE_API_URL}/api/library/games/${appid}`;
const response = await fetch(url, {
const response = await apiFetch(url, {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
});
@@ -177,6 +178,7 @@ export default function GameDetail() {
</Card>
<PlayingHint game={data} onChanged={(entry) => setData((d) => ({...d, ...entry}))}/>
<CompletedHint game={data} onChanged={(entry) => setData((d) => ({...d, ...entry}))}/>
<BacklogCard key={data.appid} game={data}
onSaved={(entry) => setData((d) => ({...d, ...entry}))}/>
+12 -5
View File
@@ -7,7 +7,7 @@ import useForceSync from '../hooks/useForceSync.js';
import useSessionExpired from '../hooks/useSessionExpired.js';
import {formatPlaytime} from '../utils/games.js';
import {GAME_STATUSES, libraryQuery, parseStatus} from '../utils/backlog.js';
import {ArrowUpDown, ChevronLeft, ChevronRight, Clock, Library, RefreshCw, Search, SearchX, Timer} from 'lucide-react';
import {ArrowUpDown, ChevronLeft, ChevronRight, Clock, Library, RefreshCw, Search, SearchX, Timer, Trophy} from 'lucide-react';
import {Badge} from '@/components/ui/badge';
import {Button} from '@/components/ui/button';
@@ -19,6 +19,8 @@ import GameCover from '../components/GameCover.jsx';
import LibraryTabs from '../components/LibraryTabs.jsx';
import GameStatusBadge from '../components/GameStatusBadge.jsx';
import PageContainer from '../components/PageContainer.jsx';
import {apiFetch} from '../utils/api.js';
import {getToken} from '../utils/auth.js';
const PAGE_SIZES = [20, 40, 80];
const DEFAULT_SIZE = 20;
@@ -58,8 +60,7 @@ export default function GameLibrary() {
const [error, setError] = useState(null);
const load = useCallback(async () => {
const token = localStorage.getItem('tbp_jwt');
if (!token) {
if (!getToken()) {
navigate('/login');
return;
}
@@ -67,9 +68,8 @@ export default function GameLibrary() {
setError(null);
try {
const url = `${import.meta.env.VITE_CORE_API_URL}/api/library/games?${libraryQuery({page, size, sort, status, q, maxMinutes: shortOnly ? SHORT_GAME_MINUTES : null})}`;
const response = await fetch(url, {
const response = await apiFetch(url, {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
});
@@ -266,6 +266,13 @@ export default function GameLibrary() {
{g.name ?? `App ${g.appid}`}
</span>
<GameStatusBadge status={g.status} compact/>
{g.achievementsTotal > 0 && g.achievementsUnlocked != null && (
<Badge variant="secondary" className="hidden sm:inline-flex tabular-nums"
title={t('LIBRARY.ACHIEVEMENTS', {unlocked: g.achievementsUnlocked, total: g.achievementsTotal})}>
<Trophy className="size-3"/>
{g.achievementsUnlocked}/{g.achievementsTotal}
</Badge>
)}
<Badge variant="secondary" className="tabular-nums">
<Clock className="size-3"/>
{formatPlaytime(g.playtimeMinutes)}
+11
View File
@@ -51,6 +51,17 @@ afterEach(() => {
});
describe('GameLibrary search', () => {
it('shows the achievement progress of a game when it is known', async () => {
globalThis.fetch = vi.fn(async () => ({ok: true, status: 200, json: async () => ({...page, content: [
{...page.content[0], achievementsUnlocked: 3, achievementsTotal: 4},
{appid: 620, name: 'Portal 2', playtimeMinutes: 0, status: 'BACKLOG', achievementsUnlocked: null, achievementsTotal: 5},
]})}));
renderLibrary();
expect(await screen.findByText('3/4')).toBeTruthy();
expect(screen.queryByText(/\/5$/)).toBeNull();
});
it('writes the trimmed search to the URL after the delay and resets the page', async () => {
const lib = renderLibrary('/library?page=3');
+2 -2
View File
@@ -8,7 +8,7 @@ import {Input} from '@/components/ui/input';
import {Label} from '@/components/ui/label';
import AuthLayout from '../components/AuthLayout.jsx';
import FeedbackAlert from '../components/FeedbackAlert.jsx';
import {getValidToken, safeReturnPath} from '../utils/auth.js';
import {getValidToken, safeReturnPath, setToken} from '../utils/auth.js';
const LOGIN_ERROR_KEY = {
INVALID_CREDENTIALS: 'LOGIN.ERROR_INVALID_CREDENTIALS',
@@ -51,7 +51,7 @@ export default function LoginPage() {
const data = await response.json().catch(() => ({}));
if (response.ok) {
localStorage.setItem('tbp_jwt', data.token);
setToken(data.token);
navigate(target, {replace: true});
} else {
const minutes = Math.ceil(Number(response.headers.get('Retry-After') ?? 900) / 60);
+223
View File
@@ -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';
import {apiFetch} from '../utils/api.js';
import {getToken} from '../utils/auth.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) => {
if (!getToken()) {
navigate('/login');
return;
}
const request = ++latestRequest.current;
setLoading(true);
setError(null);
try {
const response = await apiFetch(
`${import.meta.env.VITE_CORE_API_URL}/api/library/picker?${pickerQuery(maxMinutes, exclude)}`);
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>
);
}
+42
View File
@@ -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);
});
});
+168
View File
@@ -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();
});
});
+8 -2
View File
@@ -1,7 +1,7 @@
import {useEffect, useState} from 'react';
import {Link, useParams} from 'react-router-dom';
import {useTranslation} from 'react-i18next';
import {CircleCheck, Clock, Gamepad2, Library, Star, UserRoundX} from 'lucide-react';
import {CircleCheck, Clock, Gamepad2, Library, Percent, Star, Trophy, UserRoundX} from 'lucide-react';
import {Card} from '@/components/ui/card';
import {Skeleton} from '@/components/ui/skeleton';
@@ -90,6 +90,12 @@ export default function PublicProfilePage() {
[Clock, formatPlaytime(library.totalPlaytimeMinutes), t('PROFILE.PLAYTIME')],
[CircleCheck, finishedCount.toLocaleString(i18n.language), t('PROFILE.FINISHED')],
];
if (library.averageCompletion != null) {
stats.push(
[Trophy, (library.perfectGames ?? 0).toLocaleString(i18n.language), t('PROFILE.PERFECT_GAMES')],
[Percent, `${library.averageCompletion.toLocaleString(i18n.language)} %`, t('PROFILE.AVERAGE_COMPLETION')],
);
}
const memberSince = profile.memberSince
? new Date(`${profile.memberSince}T12:00:00`).toLocaleDateString(i18n.language, {year: 'numeric', month: 'long'})
: null;
@@ -105,7 +111,7 @@ export default function PublicProfilePage() {
<p className="mt-2 text-sm text-muted-foreground">{t('PROFILE.MEMBER_SINCE', {date: memberSince})}</p>
)}
</div>
<div className="relative grid grid-cols-3 gap-3">
<div className={`relative grid gap-3 ${stats.length > 3 ? 'grid-cols-2 sm:grid-cols-5' : 'grid-cols-3'}`}>
{stats.map(([Icon, value, label]) => (
<div key={label} className="rounded-lg bg-muted/50 p-3 text-center">
<Icon className="mx-auto mb-1 size-4 text-primary"/>
+17
View File
@@ -43,6 +43,23 @@ describe('PublicProfilePage', () => {
expect(screen.getByText('Lobo')).toBeTruthy();
expect(screen.getByText('Half-Life 2')).toBeTruthy();
expect(screen.getByText('233')).toBeTruthy();
expect(screen.queryByText('PROFILE.PERFECT_GAMES')).toBeNull();
});
it('shows perfect games and the average completion when there are achievements', async () => {
library.perfectGames = 12;
library.averageCompletion = 64.5;
try {
mockBackends();
renderPage();
expect(await screen.findByText('PROFILE.PERFECT_GAMES')).toBeTruthy();
expect(screen.getByText('12')).toBeTruthy();
expect(screen.getByText('64,5 %')).toBeTruthy();
} finally {
delete library.perfectGames;
delete library.averageCompletion;
}
});
it('says the profile is unknown or private on a 404', async () => {
+5 -1
View File
@@ -18,6 +18,7 @@ const BACKEND_ERROR_KEY = {
INVALID_INVITATION_CODE: 'REGISTRATION.ERROR_INVALID_INVITE_CODE',
STEAM_ID_ALREADY_IN_USE: 'REGISTRATION.ERROR_STEAM_ID_IN_USE',
DISPLAY_NAME_ALREADY_IN_USE: 'REGISTRATION.ERROR_DISPLAY_NAME_IN_USE',
TOO_MANY_ATTEMPTS: 'REGISTRATION.ERROR_TOO_MANY_ATTEMPTS',
};
export default function RegistrationPage() {
@@ -65,8 +66,11 @@ export default function RegistrationPage() {
const data = await response.json().catch(() => ({}));
const mappedKey = BACKEND_ERROR_KEY[data.code];
const options = response.status === 429
? {minutes: Math.ceil(Number(response.headers.get('Retry-After') ?? 900) / 60)}
: undefined;
setFeedback({
message: mappedKey ? t(mappedKey) : data.message || t('REGISTRATION.ERROR_GENERIC'),
message: mappedKey ? t(mappedKey, options) : data.message || t('REGISTRATION.ERROR_GENERIC'),
isError: true,
});
} catch (err) {
+54
View File
@@ -0,0 +1,54 @@
// @vitest-environment jsdom
import {afterEach, describe, expect, it, vi} from 'vitest';
import {cleanup, fireEvent, render, screen} from '@testing-library/react';
import {MemoryRouter} from 'react-router-dom';
import RegistrationPage from './RegistrationPage.jsx';
// Own mock: the shared one drops the {{minutes}} value this test checks.
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key, options) => (options?.minutes === undefined ? key : `${key}:${options.minutes}`),
i18n: {language: 'de'},
}),
}));
function errorResponse(status, code, headers = {}) {
return {
ok: false,
status,
headers: new Headers(headers),
json: async () => ({code}),
};
}
function submitValidForm() {
render(<MemoryRouter><RegistrationPage/></MemoryRouter>);
fireEvent.change(screen.getByLabelText('REGISTRATION.EMAIL'), {target: {value: 'a@b.de'}});
fireEvent.change(screen.getByLabelText('REGISTRATION.PASSWORD'), {target: {value: 'secret123'}});
fireEvent.change(screen.getByLabelText('REGISTRATION.PASSWORD_CONFIRMATION'), {target: {value: 'secret123'}});
fireEvent.change(screen.getByLabelText('REGISTRATION.INVITE_CODE'), {target: {value: 'code'}});
fireEvent.change(screen.getByLabelText('REGISTRATION.STEAM_ID'), {target: {value: '76561198000000001'}});
fireEvent.click(screen.getByRole('button', {name: 'REGISTRATION.SUBMIT'}));
}
describe('RegistrationPage', () => {
afterEach(() => {
cleanup();
});
it('shows the wait time when the IP is blocked', async () => {
globalThis.fetch = vi.fn(async () => errorResponse(429, 'TOO_MANY_ATTEMPTS', {'Retry-After': '900'}));
submitValidForm();
expect(await screen.findByText('REGISTRATION.ERROR_TOO_MANY_ATTEMPTS:15')).toBeTruthy();
});
it('maps a known backend error to its message', async () => {
globalThis.fetch = vi.fn(async () => errorResponse(400, 'INVALID_INVITATION_CODE'));
submitValidForm();
expect(await screen.findByText('REGISTRATION.ERROR_INVALID_INVITE_CODE')).toBeTruthy();
});
});
+7 -9
View File
@@ -22,6 +22,8 @@ import useSaveAppearance from '../hooks/useSaveAppearance.js';
import {ACCENTS, CORNERS} from '../utils/appearance.js';
import {coverUrl} from '../utils/games.js';
import {cn} from '@/lib/utils';
import {apiFetch} from '../utils/api.js';
import {getToken, setToken} from '../utils/auth.js';
const BACKEND_ERROR_KEY = {
STEAM_ID_ALREADY_IN_USE: 'REGISTRATION.ERROR_STEAM_ID_IN_USE',
@@ -61,18 +63,16 @@ export default function SettingsPage() {
});
useEffect(() => {
const token = localStorage.getItem('tbp_jwt');
if (!token) {
if (!getToken()) {
navigate('/login');
return;
}
const load = async () => {
try {
const response = await fetch(`${import.meta.env.VITE_API_URL}/api/users/me?v=1`, {
const response = await apiFetch(`${import.meta.env.VITE_API_URL}/api/users/me?v=1`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
});
@@ -107,17 +107,15 @@ export default function SettingsPage() {
}, [navigate, expireSession, reset, t]);
const persistSettings = async (payload) => {
const token = localStorage.getItem('tbp_jwt');
if (!token) {
if (!getToken()) {
navigate('/login');
return {ok: false};
}
try {
const response = await fetch(`${import.meta.env.VITE_API_URL}/api/users/settings`, {
const response = await apiFetch(`${import.meta.env.VITE_API_URL}/api/users/settings`, {
method: 'PUT',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
@@ -136,7 +134,7 @@ export default function SettingsPage() {
}
const data = await response.json();
localStorage.setItem('tbp_jwt', data.token); // Core reads the steamId from the token
setToken(data.token); // Core reads the steamId from the token
toast.success(t('SETTINGS.SAVED'));
return {ok: true};
} catch (err) {
+4 -5
View File
@@ -11,6 +11,8 @@ import GameCover from '../components/GameCover.jsx';
import LibraryTabs from '../components/LibraryTabs.jsx';
import useSessionExpired from '../hooks/useSessionExpired.js';
import PageContainer from '../components/PageContainer.jsx';
import {apiFetch} from '../utils/api.js';
import {getToken} from '../utils/auth.js';
const formatAdded = (instant, locale) =>
instant ? new Date(instant).toLocaleDateString(locale, {year: 'numeric', month: 'short', day: 'numeric'}) : null;
@@ -24,17 +26,14 @@ export default function WishlistPage() {
const [onlyOnSale, setOnlyOnSale] = useState(false);
useEffect(() => {
const token = localStorage.getItem('tbp_jwt');
if (!token) {
if (!getToken()) {
navigate('/login');
return undefined;
}
let cancelled = false;
(async () => {
try {
const response = await fetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/wishlist`, {
headers: {'Authorization': `Bearer ${token}`},
});
const response = await apiFetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/wishlist`);
if (cancelled) return;
if (response.status === 401) {
expireSession();
+10
View File
@@ -0,0 +1,10 @@
import {getToken} from './auth.js';
/**
* fetch with the login token. The caller handles the response status, 401 included.
*/
export function apiFetch(url, init = {}) {
const token = getToken();
const headers = token ? {...init.headers, 'Authorization': `Bearer ${token}`} : init.headers;
return fetch(url, {...init, headers});
}
+40
View File
@@ -0,0 +1,40 @@
// @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {apiFetch} from './api.js';
describe('apiFetch', () => {
beforeEach(() => {
globalThis.fetch = vi.fn(async () => new Response(null, {status: 204}));
});
afterEach(() => {
localStorage.clear();
});
it('sends the stored token and keeps the other headers and options', async () => {
localStorage.setItem('tbp_jwt', 'the-token');
await apiFetch('/api/x', {method: 'PUT', headers: {'Content-Type': 'application/json'}, body: '{}'});
expect(fetch).toHaveBeenCalledWith('/api/x', {
method: 'PUT',
headers: {'Content-Type': 'application/json', 'Authorization': 'Bearer the-token'},
body: '{}',
});
});
it('sends no Authorization header without a token', async () => {
await apiFetch('/api/x');
expect(fetch).toHaveBeenCalledWith('/api/x', {headers: undefined});
});
it('reads the token on every call', async () => {
localStorage.setItem('tbp_jwt', 'old');
await apiFetch('/api/x');
localStorage.setItem('tbp_jwt', 'new');
await apiFetch('/api/x');
expect(fetch).toHaveBeenLastCalledWith('/api/x', {headers: {'Authorization': 'Bearer new'}});
});
});
+5 -4
View File
@@ -1,3 +1,5 @@
import {apiFetch} from './api.js';
// Auth accepts only these names (AppearanceRequest); style.css has a preset for each.
export const ACCENTS = ['violet', 'blue', 'green', 'orange', 'pink', 'red'];
export const CORNERS = ['sharp', 'default', 'round'];
@@ -49,9 +51,9 @@ export const applyAppearance = (appearance, root = document.documentElement) =>
* PUT /api/users/settings/appearance on Auth. The caller handles the response status.
*/
export const saveAppearance = (appearance) =>
fetch(`${import.meta.env.VITE_API_URL}/api/users/settings/appearance`, {
apiFetch(`${import.meta.env.VITE_API_URL}/api/users/settings/appearance`, {
method: 'PUT',
headers: {'Authorization': `Bearer ${localStorage.getItem('tbp_jwt')}`, 'Content-Type': 'application/json'},
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(appearance),
// Saves run one after another; a hung request must not hold back the next ones.
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
@@ -61,8 +63,7 @@ export const saveAppearance = (appearance) =>
* GET /api/users/me on Auth, only for its appearance. Null when there is no answer to use.
*/
export const loadAppearance = async () => {
const response = await fetch(`${import.meta.env.VITE_API_URL}/api/users/me?v=1`, {
headers: {'Authorization': `Bearer ${localStorage.getItem('tbp_jwt')}`},
const response = await apiFetch(`${import.meta.env.VITE_API_URL}/api/users/me?v=1`, {
// Saves wait for this load.
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
+8 -2
View File
@@ -1,5 +1,11 @@
const TOKEN_KEY = 'tbp_jwt';
export const getToken = () => localStorage.getItem(TOKEN_KEY);
export const setToken = (token) => localStorage.setItem(TOKEN_KEY, token);
export const clearToken = () => localStorage.removeItem(TOKEN_KEY);
/**
* Only checks `exp`. The signature is checked by the backends.
*/
@@ -17,9 +23,9 @@ export function isTokenValid(token, nowMs = Date.now()) {
* Returns the stored token, or null. Removes an expired token.
*/
export function getValidToken() {
const token = localStorage.getItem(TOKEN_KEY);
const token = getToken();
if (token && !isTokenValid(token)) {
localStorage.removeItem(TOKEN_KEY);
clearToken();
return null;
}
return token;
+14 -2
View File
@@ -1,3 +1,5 @@
import {apiFetch} from './api.js';
export const GAME_STATUSES = ['BACKLOG', 'PLAYING', 'FINISHED', 'COMPLETED', 'DROPPED'];
export const GAME_PRIORITIES = ['HIGH', 'MEDIUM', 'LOW'];
@@ -19,12 +21,22 @@ export const toBacklogRequest = ({status, rating, notes, priority}) => ({
* @param entry a backlog entry in request form, see toBacklogRequest
*/
export const saveBacklogEntry = (appid, entry) =>
fetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/games/${appid}/backlog`, {
apiFetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/games/${appid}/backlog`, {
method: 'PUT',
headers: {'Authorization': `Bearer ${localStorage.getItem('tbp_jwt')}`, 'Content-Type': 'application/json'},
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(entry),
});
/**
* PUT /api/library/games/{appid}/status. The caller handles the response status.
*/
export const saveGameStatus = (appid, status) =>
apiFetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/games/${appid}/status`, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({status}),
});
export const libraryQuery = ({page, size, sort, status, q, maxMinutes}) => {
const params = new URLSearchParams({page: String(page), size: String(size), sort});
if (status) params.set('status', status);