Public Access
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ff8d48c53 | ||
|
|
085fcf15a4 | ||
|
|
2efdf64675 | ||
|
|
8dcd03f219 |
No files matched your search
@@ -20,13 +20,17 @@ To Be Played is split into three repositories:
|
|||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **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, 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
|
- **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 %),
|
- **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.
|
- **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.
|
- **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.
|
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.
|
- German and English, dark mode, mobile layout, session-expired message instead of a silent logout.
|
||||||
@@ -80,8 +84,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.
|
|
||||||
- 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`.
|
||||||
- Installable PWA.
|
- Installable PWA.
|
||||||
|
|||||||
+45
-6
@@ -32,7 +32,8 @@
|
|||||||
"ERROR_INVALID_INVITE_CODE": "Der Einladungscode ist ungültig oder wurde bereits verwendet.",
|
"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.",
|
"ERROR_STEAM_ID_IN_USE": "Diese Steam-ID ist bereits mit einem anderen Konto verknüpft.",
|
||||||
"DISPLAY_NAME": "Anzeigename (optional)",
|
"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": {
|
"SETTINGS": {
|
||||||
"TITLE": "Einstellungen",
|
"TITLE": "Einstellungen",
|
||||||
@@ -132,7 +133,19 @@
|
|||||||
"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",
|
||||||
|
"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": {
|
"LIBRARY": {
|
||||||
"TITLE": "Spielebibliothek",
|
"TITLE": "Spielebibliothek",
|
||||||
@@ -161,7 +174,8 @@
|
|||||||
"NO_MATCH": "Keine Spiele für diesen Filter.",
|
"NO_MATCH": "Keine Spiele für diesen Filter.",
|
||||||
"SORT_RATING": "Beste Bewertung",
|
"SORT_RATING": "Beste Bewertung",
|
||||||
"SORT_PRIORITY": "Höchste Priorität",
|
"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": {
|
"GAME_DETAIL": {
|
||||||
"BACK": "Zurück",
|
"BACK": "Zurück",
|
||||||
@@ -185,7 +199,9 @@
|
|||||||
"TTB_COMPLETELY": "100 %",
|
"TTB_COMPLETELY": "100 %",
|
||||||
"SET_BACKGROUND": "Als Hintergrund",
|
"SET_BACKGROUND": "Als Hintergrund",
|
||||||
"REMOVE_BACKGROUND": "Hintergrund entfernen",
|
"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": {
|
"LANDING": {
|
||||||
"STATS_TITLE": "Community-Statistiken",
|
"STATS_TITLE": "Community-Statistiken",
|
||||||
@@ -217,7 +233,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",
|
||||||
@@ -265,6 +282,28 @@
|
|||||||
"FINISHED_GAMES": "Durchgespielte Spiele",
|
"FINISHED_GAMES": "Durchgespielte Spiele",
|
||||||
"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",
|
||||||
|
"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
@@ -32,7 +32,8 @@
|
|||||||
"ERROR_INVALID_INVITE_CODE": "The invite code is invalid or has already been used.",
|
"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.",
|
"ERROR_STEAM_ID_IN_USE": "This Steam ID is already linked to another account.",
|
||||||
"DISPLAY_NAME": "Display name (optional)",
|
"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": {
|
"SETTINGS": {
|
||||||
"TITLE": "Settings",
|
"TITLE": "Settings",
|
||||||
@@ -132,7 +133,19 @@
|
|||||||
"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",
|
||||||
|
"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": {
|
"LIBRARY": {
|
||||||
"TITLE": "Game Library",
|
"TITLE": "Game Library",
|
||||||
@@ -161,7 +174,8 @@
|
|||||||
"NO_MATCH": "No games match this filter.",
|
"NO_MATCH": "No games match this filter.",
|
||||||
"SORT_RATING": "Best rated",
|
"SORT_RATING": "Best rated",
|
||||||
"SORT_PRIORITY": "Highest priority",
|
"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": {
|
"GAME_DETAIL": {
|
||||||
"BACK": "Back",
|
"BACK": "Back",
|
||||||
@@ -185,7 +199,9 @@
|
|||||||
"TTB_COMPLETELY": "100%",
|
"TTB_COMPLETELY": "100%",
|
||||||
"SET_BACKGROUND": "Use as background",
|
"SET_BACKGROUND": "Use as background",
|
||||||
"REMOVE_BACKGROUND": "Remove 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": {
|
"LANDING": {
|
||||||
"STATS_TITLE": "Community stats",
|
"STATS_TITLE": "Community stats",
|
||||||
@@ -217,7 +233,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",
|
||||||
@@ -265,6 +282,28 @@
|
|||||||
"FINISHED_GAMES": "Finished games",
|
"FINISHED_GAMES": "Finished games",
|
||||||
"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",
|
||||||
|
"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
@@ -11,12 +11,14 @@ 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';
|
||||||
import {TooltipProvider} from '@/components/ui/tooltip';
|
import {TooltipProvider} from '@/components/ui/tooltip';
|
||||||
import PageBackground from './components/PageBackground.jsx';
|
import PageBackground from './components/PageBackground.jsx';
|
||||||
import {usePreferences} from './context/PreferencesContext.jsx';
|
import {usePreferences} from './context/PreferencesContext.jsx';
|
||||||
|
import {clearToken, getToken} from './utils/auth.js';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const {t} = useTranslation();
|
const {t} = useTranslation();
|
||||||
@@ -30,7 +32,7 @@ function App() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// No local expiry check here: a 401 from the backend ends the session (useSessionExpired), and a browser
|
// 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.
|
// clock that runs ahead would otherwise end it silently.
|
||||||
setIsAuthenticated(!!localStorage.getItem('tbp_jwt'));
|
setIsAuthenticated(!!getToken());
|
||||||
}, [location]);
|
}, [location]);
|
||||||
|
|
||||||
// On login and on page load; the cached look shows until Auth answers.
|
// On login and on page load; the cached look shows until Auth answers.
|
||||||
@@ -39,7 +41,7 @@ function App() {
|
|||||||
}, [isAuthenticated, syncAppearance]);
|
}, [isAuthenticated, syncAppearance]);
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
localStorage.removeItem('tbp_jwt');
|
clearToken();
|
||||||
resetAppearance();
|
resetAppearance();
|
||||||
setIsAuthenticated(false);
|
setIsAuthenticated(false);
|
||||||
navigate('/login');
|
navigate('/login');
|
||||||
@@ -61,6 +63,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/>}/>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {cn} from '@/lib/utils';
|
|||||||
import {STATUS_META} from './GameStatusBadge.jsx';
|
import {STATUS_META} from './GameStatusBadge.jsx';
|
||||||
import {GAME_PRIORITIES, GAME_STATUSES, NO_PRIORITY, saveBacklogEntry, toBacklogRequest} from '../utils/backlog.js';
|
import {GAME_PRIORITIES, GAME_STATUSES, NO_PRIORITY, saveBacklogEntry, toBacklogRequest} from '../utils/backlog.js';
|
||||||
import useSessionExpired from '../hooks/useSessionExpired.js';
|
import useSessionExpired from '../hooks/useSessionExpired.js';
|
||||||
|
import {getToken} from '../utils/auth.js';
|
||||||
|
|
||||||
const NOTES_MAX = 2000;
|
const NOTES_MAX = 2000;
|
||||||
|
|
||||||
@@ -41,8 +42,7 @@ export default function BacklogCard({game, onSaved}) {
|
|||||||
const set = (field) => (value) => setForm((f) => ({...f, [field]: value}));
|
const set = (field) => (value) => setForm((f) => ({...f, [field]: value}));
|
||||||
|
|
||||||
const save = async () => {
|
const save = async () => {
|
||||||
const token = localStorage.getItem('tbp_jwt');
|
if (!getToken()) {
|
||||||
if (!token) {
|
|
||||||
navigate('/login');
|
navigate('/login');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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'});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import {useNavigate} from 'react-router-dom';
|
|||||||
import {useTranslation} from 'react-i18next';
|
import {useTranslation} from 'react-i18next';
|
||||||
import {toast} from 'sonner';
|
import {toast} from 'sonner';
|
||||||
import useSessionExpired from './useSessionExpired.js';
|
import useSessionExpired from './useSessionExpired.js';
|
||||||
|
import {apiFetch} from '../utils/api.js';
|
||||||
|
import {getToken} from '../utils/auth.js';
|
||||||
|
|
||||||
const RELOAD_DELAY_MS = 4000;
|
const RELOAD_DELAY_MS = 4000;
|
||||||
|
|
||||||
@@ -17,16 +19,14 @@ export default function useForceSync(onStarted) {
|
|||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
const trigger = async () => {
|
const trigger = async () => {
|
||||||
const token = localStorage.getItem('tbp_jwt');
|
if (!getToken()) {
|
||||||
if (!token) {
|
|
||||||
navigate('/login');
|
navigate('/login');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
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',
|
method: 'POST',
|
||||||
headers: {'Authorization': `Bearer ${token}`},
|
|
||||||
});
|
});
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
expireSession();
|
expireSession();
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import {useEffect, useRef, useState} from 'react';
|
import {useEffect, useRef, useState} from 'react';
|
||||||
|
import {apiFetch} from '../utils/api.js';
|
||||||
|
import {getToken} from '../utils/auth.js';
|
||||||
|
|
||||||
const POLL_INTERVAL_MS = 3000;
|
const POLL_INTERVAL_MS = 3000;
|
||||||
const MAX_POLL_DURATION_MS = 120_000;
|
const MAX_POLL_DURATION_MS = 120_000;
|
||||||
@@ -32,13 +34,11 @@ export default function useLibrarySync({enabled, expectInitialSync = false, onCo
|
|||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
|
|
||||||
const fetchOnce = async () => {
|
const fetchOnce = async () => {
|
||||||
const token = localStorage.getItem('tbp_jwt');
|
if (!getToken()) return;
|
||||||
if (!token) return;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const response = await apiFetch(
|
||||||
`${import.meta.env.VITE_CORE_API_URL}/api/library/sync-status`,
|
`${import.meta.env.VITE_CORE_API_URL}/api/library/sync-status`
|
||||||
{headers: {'Authorization': `Bearer ${token}`}}
|
|
||||||
);
|
);
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {useCallback, useRef} from 'react';
|
import {useCallback, useRef} from 'react';
|
||||||
import {useLocation, useNavigate} from 'react-router-dom';
|
import {useLocation, useNavigate} from 'react-router-dom';
|
||||||
import {useResetAppearance} from '../context/PreferencesContext.jsx';
|
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
|
* 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(() => {
|
return useCallback(() => {
|
||||||
const {pathname, search} = locationRef.current;
|
const {pathname, search} = locationRef.current;
|
||||||
localStorage.removeItem('tbp_jwt');
|
clearToken();
|
||||||
resetAppearance();
|
resetAppearance();
|
||||||
navigate('/login', {replace: true, state: {sessionExpired: true, from: pathname + search}});
|
navigate('/login', {replace: true, state: {sessionExpired: true, from: pathname + search}});
|
||||||
}, [navigate, resetAppearance]);
|
}, [navigate, resetAppearance]);
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import {useEffect, useState} from 'react';
|
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
|
* 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) {
|
export default function useSteamProfile(enabled) {
|
||||||
const [profile, setProfile] = useState(null);
|
const [profile, setProfile] = useState(null);
|
||||||
const token = localStorage.getItem('tbp_jwt');
|
const token = getToken();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!enabled || !token) {
|
if (!enabled || !token) {
|
||||||
@@ -17,9 +19,7 @@ export default function useSteamProfile(enabled) {
|
|||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/steam-profile`, {
|
const response = await apiFetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/steam-profile`);
|
||||||
headers: {'Authorization': `Bearer ${token}`},
|
|
||||||
});
|
|
||||||
const data = response.ok ? await response.json() : null;
|
const data = response.ok ? await response.json() : null;
|
||||||
if (!cancelled) setProfile(data);
|
if (!cancelled) setProfile(data);
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
+33
-15
@@ -8,6 +8,7 @@ import {Badge} from '@/components/ui/badge';
|
|||||||
import {Button} from '@/components/ui/button';
|
import {Button} from '@/components/ui/button';
|
||||||
import {Card} from '@/components/ui/card';
|
import {Card} from '@/components/ui/card';
|
||||||
import {Skeleton} from '@/components/ui/skeleton';
|
import {Skeleton} from '@/components/ui/skeleton';
|
||||||
|
import AchievementsCard from '../components/AchievementsCard.jsx';
|
||||||
import ActivityCard from '../components/ActivityCard.jsx';
|
import ActivityCard from '../components/ActivityCard.jsx';
|
||||||
import CurrentlyPlayingCard from '../components/CurrentlyPlayingCard.jsx';
|
import CurrentlyPlayingCard from '../components/CurrentlyPlayingCard.jsx';
|
||||||
import GameCover from '../components/GameCover.jsx';
|
import GameCover from '../components/GameCover.jsx';
|
||||||
@@ -24,6 +25,8 @@ import SteamAvatar from '../components/SteamAvatar.jsx';
|
|||||||
import useSteamProfile from '../hooks/useSteamProfile.js';
|
import useSteamProfile from '../hooks/useSteamProfile.js';
|
||||||
import {cn} from '@/lib/utils';
|
import {cn} from '@/lib/utils';
|
||||||
import {usePreferences} from '../context/PreferencesContext.jsx';
|
import {usePreferences} from '../context/PreferencesContext.jsx';
|
||||||
|
import {apiFetch} from '../utils/api.js';
|
||||||
|
import {getToken} from '../utils/auth.js';
|
||||||
|
|
||||||
const TOP_GAMES = 5;
|
const TOP_GAMES = 5;
|
||||||
// The games endpoint accepts only the page sizes 20, 40 and 80.
|
// 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_GAMES = 4;
|
||||||
const PLAYING_URL = '/api/library/games?page=0&size=20&sort=playtimeMinutes%2Cdesc&status=PLAYING';
|
const PLAYING_URL = '/api/library/games?page=0&size=20&sort=playtimeMinutes%2Cdesc&status=PLAYING';
|
||||||
|
|
||||||
const authHeaders = () => ({
|
const JSON_HEADERS = {'Content-Type': 'application/json'};
|
||||||
'Authorization': `Bearer ${localStorage.getItem('tbp_jwt')}`,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
});
|
|
||||||
|
|
||||||
const Dashboard = () => {
|
const Dashboard = () => {
|
||||||
const {t} = useTranslation();
|
const {t} = useTranslation();
|
||||||
@@ -46,6 +46,7 @@ const Dashboard = () => {
|
|||||||
const [playing, setPlaying] = useState([]);
|
const [playing, setPlaying] = useState([]);
|
||||||
const [summary, setSummary] = useState(null);
|
const [summary, setSummary] = useState(null);
|
||||||
const [activity, setActivity] = useState(null);
|
const [activity, setActivity] = useState(null);
|
||||||
|
const [achievements, setAchievements] = useState(null);
|
||||||
const [summaryFailed, setSummaryFailed] = useState(false);
|
const [summaryFailed, setSummaryFailed] = useState(false);
|
||||||
const [shuffling, setShuffling] = useState(false);
|
const [shuffling, setShuffling] = useState(false);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
@@ -53,7 +54,7 @@ const Dashboard = () => {
|
|||||||
|
|
||||||
const fetchSteamCount = useCallback(async () => {
|
const fetchSteamCount = useCallback(async () => {
|
||||||
try {
|
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) {
|
if (r.status === 401) {
|
||||||
expireSession();
|
expireSession();
|
||||||
return;
|
return;
|
||||||
@@ -68,7 +69,7 @@ const Dashboard = () => {
|
|||||||
|
|
||||||
const fetchTopGames = useCallback(async () => {
|
const fetchTopGames = useCallback(async () => {
|
||||||
try {
|
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) {
|
if (r.status === 401) {
|
||||||
expireSession();
|
expireSession();
|
||||||
return;
|
return;
|
||||||
@@ -84,7 +85,7 @@ const Dashboard = () => {
|
|||||||
|
|
||||||
const fetchPlaying = useCallback(async () => {
|
const fetchPlaying = useCallback(async () => {
|
||||||
try {
|
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) {
|
if (r.status === 401) {
|
||||||
expireSession();
|
expireSession();
|
||||||
return;
|
return;
|
||||||
@@ -105,7 +106,7 @@ const Dashboard = () => {
|
|||||||
const fetchSummary = useCallback(async (excludeAppid) => {
|
const fetchSummary = useCallback(async (excludeAppid) => {
|
||||||
const query = excludeAppid ? `?exclude=${excludeAppid}` : '';
|
const query = excludeAppid ? `?exclude=${excludeAppid}` : '';
|
||||||
try {
|
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) {
|
if (r.status === 401) {
|
||||||
expireSession();
|
expireSession();
|
||||||
return true;
|
return true;
|
||||||
@@ -124,7 +125,7 @@ const Dashboard = () => {
|
|||||||
|
|
||||||
const fetchActivity = useCallback(async () => {
|
const fetchActivity = useCallback(async () => {
|
||||||
try {
|
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) {
|
if (r.status === 401) {
|
||||||
expireSession();
|
expireSession();
|
||||||
return;
|
return;
|
||||||
@@ -137,6 +138,21 @@ const Dashboard = () => {
|
|||||||
}
|
}
|
||||||
}, [expireSession]);
|
}, [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 () => {
|
const shuffleSuggestion = async () => {
|
||||||
setShuffling(true);
|
setShuffling(true);
|
||||||
if (!await fetchSummary(summary?.suggestion?.appid)) {
|
if (!await fetchSummary(summary?.suggestion?.appid)) {
|
||||||
@@ -151,19 +167,20 @@ const Dashboard = () => {
|
|||||||
fetchPlaying();
|
fetchPlaying();
|
||||||
fetchSummary();
|
fetchSummary();
|
||||||
fetchActivity();
|
fetchActivity();
|
||||||
}, [fetchSteamCount, fetchTopGames, fetchPlaying, fetchSummary, fetchActivity]);
|
fetchAchievements();
|
||||||
|
}, [fetchSteamCount, fetchTopGames, fetchPlaying, fetchSummary, fetchActivity, fetchAchievements]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchUserData = async () => {
|
const fetchUserData = async () => {
|
||||||
if (!localStorage.getItem('tbp_jwt')) {
|
if (!getToken()) {
|
||||||
navigate('/login');
|
navigate('/login');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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',
|
method: 'GET',
|
||||||
headers: authHeaders(),
|
headers: JSON_HEADERS,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (profileResponse.ok) {
|
if (profileResponse.ok) {
|
||||||
@@ -171,7 +188,7 @@ const Dashboard = () => {
|
|||||||
setUserData(data);
|
setUserData(data);
|
||||||
|
|
||||||
if (data.steamId) {
|
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) {
|
} else if (profileResponse.status === 401) {
|
||||||
expireSession();
|
expireSession();
|
||||||
@@ -187,7 +204,7 @@ const Dashboard = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
fetchUserData();
|
fetchUserData();
|
||||||
}, [navigate, expireSession, t, fetchSteamCount, fetchTopGames, fetchPlaying, fetchSummary, fetchActivity]);
|
}, [navigate, expireSession, t, fetchSteamCount, fetchTopGames, fetchPlaying, fetchSummary, fetchActivity, fetchAchievements]);
|
||||||
|
|
||||||
const hasSteamId = !!userData?.steamId;
|
const hasSteamId = !!userData?.steamId;
|
||||||
const {appearance} = usePreferences();
|
const {appearance} = usePreferences();
|
||||||
@@ -340,6 +357,7 @@ const Dashboard = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{hasSteamId && activity && <ActivityCard activity={activity}/>}
|
{hasSteamId && activity && <ActivityCard activity={activity}/>}
|
||||||
|
{hasSteamId && achievements && <AchievementsCard summary={achievements}/>}
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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: [
|
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'}]});
|
{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/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 ok({content: [], total: 0, byStatus: {}});
|
||||||
});
|
});
|
||||||
return render(
|
return render(
|
||||||
@@ -72,6 +74,13 @@ describe('Dashboard', () => {
|
|||||||
expect(container.querySelector('img[src$="/1086940/library_hero.jpg"]')).not.toBeNull();
|
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 () => {
|
it('shows the picked banner game instead', async () => {
|
||||||
localStorage.setItem('tbp_appearance', JSON.stringify({bannerAppid: 620}));
|
localStorage.setItem('tbp_appearance', JSON.stringify({bannerAppid: 620}));
|
||||||
const container = mockDashboard();
|
const container = mockDashboard();
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {cn} from '@/lib/utils';
|
|||||||
import BacklogCard from '../components/BacklogCard.jsx';
|
import BacklogCard from '../components/BacklogCard.jsx';
|
||||||
import GameCover from '../components/GameCover.jsx';
|
import GameCover from '../components/GameCover.jsx';
|
||||||
import GameStatusBadge from '../components/GameStatusBadge.jsx';
|
import GameStatusBadge from '../components/GameStatusBadge.jsx';
|
||||||
|
import CompletedHint from '../components/CompletedHint.jsx';
|
||||||
import PlayingHint from '../components/PlayingHint.jsx';
|
import PlayingHint from '../components/PlayingHint.jsx';
|
||||||
import TimeToBeat from '../components/TimeToBeat.jsx';
|
import TimeToBeat from '../components/TimeToBeat.jsx';
|
||||||
import {formatPlaytime} from '../utils/games.js';
|
import {formatPlaytime} from '../utils/games.js';
|
||||||
@@ -18,6 +19,8 @@ import useSessionExpired from '../hooks/useSessionExpired.js';
|
|||||||
import useSaveAppearance from '../hooks/useSaveAppearance.js';
|
import useSaveAppearance from '../hooks/useSaveAppearance.js';
|
||||||
import {usePreferences} from '../context/PreferencesContext.jsx';
|
import {usePreferences} from '../context/PreferencesContext.jsx';
|
||||||
import PageContainer from '../components/PageContainer.jsx';
|
import PageContainer from '../components/PageContainer.jsx';
|
||||||
|
import {apiFetch} from '../utils/api.js';
|
||||||
|
import {getToken} from '../utils/auth.js';
|
||||||
|
|
||||||
const formatUnlockedAt = (instant, locale) => {
|
const formatUnlockedAt = (instant, locale) => {
|
||||||
if (!instant) return '';
|
if (!instant) return '';
|
||||||
@@ -42,8 +45,7 @@ export default function GameDetail() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const token = localStorage.getItem('tbp_jwt');
|
if (!getToken()) {
|
||||||
if (!token) {
|
|
||||||
navigate('/login');
|
navigate('/login');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -52,9 +54,8 @@ export default function GameDetail() {
|
|||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const url = `${import.meta.env.VITE_CORE_API_URL}/api/library/games/${appid}`;
|
const url = `${import.meta.env.VITE_CORE_API_URL}/api/library/games/${appid}`;
|
||||||
const response = await fetch(url, {
|
const response = await apiFetch(url, {
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${token}`,
|
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -177,6 +178,7 @@ export default function GameDetail() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<PlayingHint game={data} onChanged={(entry) => setData((d) => ({...d, ...entry}))}/>
|
<PlayingHint game={data} onChanged={(entry) => setData((d) => ({...d, ...entry}))}/>
|
||||||
|
<CompletedHint game={data} onChanged={(entry) => setData((d) => ({...d, ...entry}))}/>
|
||||||
|
|
||||||
<BacklogCard key={data.appid} game={data}
|
<BacklogCard key={data.appid} game={data}
|
||||||
onSaved={(entry) => setData((d) => ({...d, ...entry}))}/>
|
onSaved={(entry) => setData((d) => ({...d, ...entry}))}/>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import useForceSync from '../hooks/useForceSync.js';
|
|||||||
import useSessionExpired from '../hooks/useSessionExpired.js';
|
import useSessionExpired from '../hooks/useSessionExpired.js';
|
||||||
import {formatPlaytime} from '../utils/games.js';
|
import {formatPlaytime} from '../utils/games.js';
|
||||||
import {GAME_STATUSES, libraryQuery, parseStatus} from '../utils/backlog.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 {Badge} from '@/components/ui/badge';
|
||||||
import {Button} from '@/components/ui/button';
|
import {Button} from '@/components/ui/button';
|
||||||
@@ -19,6 +19,8 @@ import GameCover from '../components/GameCover.jsx';
|
|||||||
import LibraryTabs from '../components/LibraryTabs.jsx';
|
import LibraryTabs from '../components/LibraryTabs.jsx';
|
||||||
import GameStatusBadge from '../components/GameStatusBadge.jsx';
|
import GameStatusBadge from '../components/GameStatusBadge.jsx';
|
||||||
import PageContainer from '../components/PageContainer.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 PAGE_SIZES = [20, 40, 80];
|
||||||
const DEFAULT_SIZE = 20;
|
const DEFAULT_SIZE = 20;
|
||||||
@@ -58,8 +60,7 @@ export default function GameLibrary() {
|
|||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
const token = localStorage.getItem('tbp_jwt');
|
if (!getToken()) {
|
||||||
if (!token) {
|
|
||||||
navigate('/login');
|
navigate('/login');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -67,9 +68,8 @@ export default function GameLibrary() {
|
|||||||
setError(null);
|
setError(null);
|
||||||
try {
|
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 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: {
|
headers: {
|
||||||
'Authorization': `Bearer ${token}`,
|
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -266,6 +266,13 @@ export default function GameLibrary() {
|
|||||||
{g.name ?? `App ${g.appid}`}
|
{g.name ?? `App ${g.appid}`}
|
||||||
</span>
|
</span>
|
||||||
<GameStatusBadge status={g.status} compact/>
|
<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">
|
<Badge variant="secondary" className="tabular-nums">
|
||||||
<Clock className="size-3"/>
|
<Clock className="size-3"/>
|
||||||
{formatPlaytime(g.playtimeMinutes)}
|
{formatPlaytime(g.playtimeMinutes)}
|
||||||
|
|||||||
@@ -51,6 +51,17 @@ afterEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('GameLibrary search', () => {
|
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 () => {
|
it('writes the trimmed search to the URL after the delay and resets the page', async () => {
|
||||||
const lib = renderLibrary('/library?page=3');
|
const lib = renderLibrary('/library?page=3');
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {Input} from '@/components/ui/input';
|
|||||||
import {Label} from '@/components/ui/label';
|
import {Label} from '@/components/ui/label';
|
||||||
import AuthLayout from '../components/AuthLayout.jsx';
|
import AuthLayout from '../components/AuthLayout.jsx';
|
||||||
import FeedbackAlert from '../components/FeedbackAlert.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 = {
|
const LOGIN_ERROR_KEY = {
|
||||||
INVALID_CREDENTIALS: 'LOGIN.ERROR_INVALID_CREDENTIALS',
|
INVALID_CREDENTIALS: 'LOGIN.ERROR_INVALID_CREDENTIALS',
|
||||||
@@ -51,7 +51,7 @@ export default function LoginPage() {
|
|||||||
const data = await response.json().catch(() => ({}));
|
const data = await response.json().catch(() => ({}));
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
localStorage.setItem('tbp_jwt', data.token);
|
setToken(data.token);
|
||||||
navigate(target, {replace: true});
|
navigate(target, {replace: true});
|
||||||
} else {
|
} else {
|
||||||
const minutes = Math.ceil(Number(response.headers.get('Retry-After') ?? 900) / 60);
|
const minutes = Math.ceil(Number(response.headers.get('Retry-After') ?? 900) / 60);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import {useEffect, useState} from 'react';
|
import {useEffect, useState} from 'react';
|
||||||
import {Link, useParams} from 'react-router-dom';
|
import {Link, useParams} from 'react-router-dom';
|
||||||
import {useTranslation} from 'react-i18next';
|
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 {Card} from '@/components/ui/card';
|
||||||
import {Skeleton} from '@/components/ui/skeleton';
|
import {Skeleton} from '@/components/ui/skeleton';
|
||||||
@@ -90,6 +90,12 @@ export default function PublicProfilePage() {
|
|||||||
[Clock, formatPlaytime(library.totalPlaytimeMinutes), t('PROFILE.PLAYTIME')],
|
[Clock, formatPlaytime(library.totalPlaytimeMinutes), t('PROFILE.PLAYTIME')],
|
||||||
[CircleCheck, finishedCount.toLocaleString(i18n.language), t('PROFILE.FINISHED')],
|
[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
|
const memberSince = profile.memberSince
|
||||||
? new Date(`${profile.memberSince}T12:00:00`).toLocaleDateString(i18n.language, {year: 'numeric', month: 'long'})
|
? new Date(`${profile.memberSince}T12:00:00`).toLocaleDateString(i18n.language, {year: 'numeric', month: 'long'})
|
||||||
: null;
|
: 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>
|
<p className="mt-2 text-sm text-muted-foreground">{t('PROFILE.MEMBER_SINCE', {date: memberSince})}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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]) => (
|
{stats.map(([Icon, value, label]) => (
|
||||||
<div key={label} className="rounded-lg bg-muted/50 p-3 text-center">
|
<div key={label} className="rounded-lg bg-muted/50 p-3 text-center">
|
||||||
<Icon className="mx-auto mb-1 size-4 text-primary"/>
|
<Icon className="mx-auto mb-1 size-4 text-primary"/>
|
||||||
|
|||||||
@@ -43,6 +43,23 @@ describe('PublicProfilePage', () => {
|
|||||||
expect(screen.getByText('Lobo')).toBeTruthy();
|
expect(screen.getByText('Lobo')).toBeTruthy();
|
||||||
expect(screen.getByText('Half-Life 2')).toBeTruthy();
|
expect(screen.getByText('Half-Life 2')).toBeTruthy();
|
||||||
expect(screen.getByText('233')).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 () => {
|
it('says the profile is unknown or private on a 404', async () => {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ const BACKEND_ERROR_KEY = {
|
|||||||
INVALID_INVITATION_CODE: 'REGISTRATION.ERROR_INVALID_INVITE_CODE',
|
INVALID_INVITATION_CODE: 'REGISTRATION.ERROR_INVALID_INVITE_CODE',
|
||||||
STEAM_ID_ALREADY_IN_USE: 'REGISTRATION.ERROR_STEAM_ID_IN_USE',
|
STEAM_ID_ALREADY_IN_USE: 'REGISTRATION.ERROR_STEAM_ID_IN_USE',
|
||||||
DISPLAY_NAME_ALREADY_IN_USE: 'REGISTRATION.ERROR_DISPLAY_NAME_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() {
|
export default function RegistrationPage() {
|
||||||
@@ -65,8 +66,11 @@ export default function RegistrationPage() {
|
|||||||
|
|
||||||
const data = await response.json().catch(() => ({}));
|
const data = await response.json().catch(() => ({}));
|
||||||
const mappedKey = BACKEND_ERROR_KEY[data.code];
|
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({
|
setFeedback({
|
||||||
message: mappedKey ? t(mappedKey) : data.message || t('REGISTRATION.ERROR_GENERIC'),
|
message: mappedKey ? t(mappedKey, options) : data.message || t('REGISTRATION.ERROR_GENERIC'),
|
||||||
isError: true,
|
isError: true,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -22,6 +22,8 @@ import useSaveAppearance from '../hooks/useSaveAppearance.js';
|
|||||||
import {ACCENTS, CORNERS} from '../utils/appearance.js';
|
import {ACCENTS, CORNERS} from '../utils/appearance.js';
|
||||||
import {coverUrl} from '../utils/games.js';
|
import {coverUrl} from '../utils/games.js';
|
||||||
import {cn} from '@/lib/utils';
|
import {cn} from '@/lib/utils';
|
||||||
|
import {apiFetch} from '../utils/api.js';
|
||||||
|
import {getToken, setToken} from '../utils/auth.js';
|
||||||
|
|
||||||
const BACKEND_ERROR_KEY = {
|
const BACKEND_ERROR_KEY = {
|
||||||
STEAM_ID_ALREADY_IN_USE: 'REGISTRATION.ERROR_STEAM_ID_IN_USE',
|
STEAM_ID_ALREADY_IN_USE: 'REGISTRATION.ERROR_STEAM_ID_IN_USE',
|
||||||
@@ -61,18 +63,16 @@ export default function SettingsPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const token = localStorage.getItem('tbp_jwt');
|
if (!getToken()) {
|
||||||
if (!token) {
|
|
||||||
navigate('/login');
|
navigate('/login');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
try {
|
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',
|
method: 'GET',
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${token}`,
|
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -107,17 +107,15 @@ export default function SettingsPage() {
|
|||||||
}, [navigate, expireSession, reset, t]);
|
}, [navigate, expireSession, reset, t]);
|
||||||
|
|
||||||
const persistSettings = async (payload) => {
|
const persistSettings = async (payload) => {
|
||||||
const token = localStorage.getItem('tbp_jwt');
|
if (!getToken()) {
|
||||||
if (!token) {
|
|
||||||
navigate('/login');
|
navigate('/login');
|
||||||
return {ok: false};
|
return {ok: false};
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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',
|
method: 'PUT',
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${token}`,
|
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
@@ -136,7 +134,7 @@ export default function SettingsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
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'));
|
toast.success(t('SETTINGS.SAVED'));
|
||||||
return {ok: true};
|
return {ok: true};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import GameCover from '../components/GameCover.jsx';
|
|||||||
import LibraryTabs from '../components/LibraryTabs.jsx';
|
import LibraryTabs from '../components/LibraryTabs.jsx';
|
||||||
import useSessionExpired from '../hooks/useSessionExpired.js';
|
import useSessionExpired from '../hooks/useSessionExpired.js';
|
||||||
import PageContainer from '../components/PageContainer.jsx';
|
import PageContainer from '../components/PageContainer.jsx';
|
||||||
|
import {apiFetch} from '../utils/api.js';
|
||||||
|
import {getToken} from '../utils/auth.js';
|
||||||
|
|
||||||
const formatAdded = (instant, locale) =>
|
const formatAdded = (instant, locale) =>
|
||||||
instant ? new Date(instant).toLocaleDateString(locale, {year: 'numeric', month: 'short', day: 'numeric'}) : null;
|
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);
|
const [onlyOnSale, setOnlyOnSale] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const token = localStorage.getItem('tbp_jwt');
|
if (!getToken()) {
|
||||||
if (!token) {
|
|
||||||
navigate('/login');
|
navigate('/login');
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/wishlist`, {
|
const response = await apiFetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/wishlist`);
|
||||||
headers: {'Authorization': `Bearer ${token}`},
|
|
||||||
});
|
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
expireSession();
|
expireSession();
|
||||||
|
|||||||
@@ -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});
|
||||||
|
}
|
||||||
@@ -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'}});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import {apiFetch} from './api.js';
|
||||||
|
|
||||||
// Auth accepts only these names (AppearanceRequest); style.css has a preset for each.
|
// Auth accepts only these names (AppearanceRequest); style.css has a preset for each.
|
||||||
export const ACCENTS = ['violet', 'blue', 'green', 'orange', 'pink', 'red'];
|
export const ACCENTS = ['violet', 'blue', 'green', 'orange', 'pink', 'red'];
|
||||||
export const CORNERS = ['sharp', 'default', 'round'];
|
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.
|
* PUT /api/users/settings/appearance on Auth. The caller handles the response status.
|
||||||
*/
|
*/
|
||||||
export const saveAppearance = (appearance) =>
|
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',
|
method: 'PUT',
|
||||||
headers: {'Authorization': `Bearer ${localStorage.getItem('tbp_jwt')}`, 'Content-Type': 'application/json'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
body: JSON.stringify(appearance),
|
body: JSON.stringify(appearance),
|
||||||
// Saves run one after another; a hung request must not hold back the next ones.
|
// Saves run one after another; a hung request must not hold back the next ones.
|
||||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
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.
|
* GET /api/users/me on Auth, only for its appearance. Null when there is no answer to use.
|
||||||
*/
|
*/
|
||||||
export const loadAppearance = async () => {
|
export const loadAppearance = async () => {
|
||||||
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`, {
|
||||||
headers: {'Authorization': `Bearer ${localStorage.getItem('tbp_jwt')}`},
|
|
||||||
// Saves wait for this load.
|
// Saves wait for this load.
|
||||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||||
});
|
});
|
||||||
|
|||||||
+8
-2
@@ -1,5 +1,11 @@
|
|||||||
const TOKEN_KEY = 'tbp_jwt';
|
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.
|
* 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.
|
* Returns the stored token, or null. Removes an expired token.
|
||||||
*/
|
*/
|
||||||
export function getValidToken() {
|
export function getValidToken() {
|
||||||
const token = localStorage.getItem(TOKEN_KEY);
|
const token = getToken();
|
||||||
if (token && !isTokenValid(token)) {
|
if (token && !isTokenValid(token)) {
|
||||||
localStorage.removeItem(TOKEN_KEY);
|
clearToken();
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return token;
|
return token;
|
||||||
|
|||||||
+14
-2
@@ -1,3 +1,5 @@
|
|||||||
|
import {apiFetch} from './api.js';
|
||||||
|
|
||||||
export const GAME_STATUSES = ['BACKLOG', 'PLAYING', 'FINISHED', 'COMPLETED', 'DROPPED'];
|
export const GAME_STATUSES = ['BACKLOG', 'PLAYING', 'FINISHED', 'COMPLETED', 'DROPPED'];
|
||||||
export const GAME_PRIORITIES = ['HIGH', 'MEDIUM', 'LOW'];
|
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
|
* @param entry a backlog entry in request form, see toBacklogRequest
|
||||||
*/
|
*/
|
||||||
export const saveBacklogEntry = (appid, entry) =>
|
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',
|
method: 'PUT',
|
||||||
headers: {'Authorization': `Bearer ${localStorage.getItem('tbp_jwt')}`, 'Content-Type': 'application/json'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
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) =>
|
||||||
|
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}) => {
|
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