diff --git a/public/i18n/de.json b/public/i18n/de.json index 4de28fc..33ed291 100644 --- a/public/i18n/de.json +++ b/public/i18n/de.json @@ -86,7 +86,18 @@ "MOST_PLAYED": "Meistgespielt", "SHOW_ALL": "Alle anzeigen", "TO_LIBRARY": "Zur Bibliothek", - "WELCOME_BACK": "Willkommen zurück" + "WELCOME_BACK": "Willkommen zurück", + "CURRENTLY_PLAYING": "Spiele ich gerade", + "NOTHING_PLAYING": "Gerade nichts. Setze ein Spiel auf „Spiele ich“.", + "PILE_OF_SHAME": "Pile of Shame", + "PILE_SHARE": "{{percent}} % deiner Bibliothek", + "NEVER_PLAYED_one": "{{count}} Spiel nie gestartet", + "NEVER_PLAYED_other": "{{count}} Spiele nie gestartet", + "SUGGESTION": "Wie wäre es mit", + "OTHER_SUGGESTION": "Anderer Vorschlag", + "BACKLOG_EMPTY": "Dein Backlog ist leer. Stark!", + "SUMMARY_FAILED": "Übersicht konnte nicht geladen werden.", + "RETRY": "Erneut versuchen" }, "LIBRARY": { "TITLE": "Spielebibliothek", diff --git a/public/i18n/en.json b/public/i18n/en.json index 0e5f4ee..2d297ba 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -86,7 +86,18 @@ "MOST_PLAYED": "Most played", "SHOW_ALL": "Show all", "TO_LIBRARY": "Go to library", - "WELCOME_BACK": "Welcome back" + "WELCOME_BACK": "Welcome back", + "CURRENTLY_PLAYING": "Currently playing", + "NOTHING_PLAYING": "Nothing right now. Set a game to \"Playing\".", + "PILE_OF_SHAME": "Pile of Shame", + "PILE_SHARE": "{{percent}}% of your library", + "NEVER_PLAYED_one": "{{count}} game never started", + "NEVER_PLAYED_other": "{{count}} games never started", + "SUGGESTION": "How about", + "OTHER_SUGGESTION": "Other pick", + "BACKLOG_EMPTY": "Your backlog is empty. Nice!", + "SUMMARY_FAILED": "Could not load the overview.", + "RETRY": "Try again" }, "LIBRARY": { "TITLE": "Game Library", diff --git a/src/components/CurrentlyPlayingCard.jsx b/src/components/CurrentlyPlayingCard.jsx new file mode 100644 index 0000000..9add25f --- /dev/null +++ b/src/components/CurrentlyPlayingCard.jsx @@ -0,0 +1,47 @@ +import {Link} from 'react-router-dom'; +import {useTranslation} from 'react-i18next'; +import {ArrowRight, Gamepad2} from 'lucide-react'; + +import {Button} from '@/components/ui/button'; +import {Card} from '@/components/ui/card'; +import GameCover from './GameCover.jsx'; +import {formatPlaytime} from '../utils/games.js'; + +export default function CurrentlyPlayingCard({games, syncing}) { + const {t} = useTranslation(); + return ( + +
+

+ + {t('DASHBOARD.CURRENTLY_PLAYING')} +

+ +
+ + {games.length === 0 ? ( +

+ {syncing ? t('LIBRARY.SYNCING_FIRST_TIME') : t('DASHBOARD.NOTHING_PLAYING')} +

+ ) : ( + + )} +
+ ); +} diff --git a/src/components/GameStatusBadge.jsx b/src/components/GameStatusBadge.jsx index 7e53a1b..cadc198 100644 --- a/src/components/GameStatusBadge.jsx +++ b/src/components/GameStatusBadge.jsx @@ -5,11 +5,11 @@ import {Badge} from '@/components/ui/badge'; import {cn} from '@/lib/utils'; export const STATUS_META = { - BACKLOG: {icon: Inbox, text: 'text-muted-foreground', badge: 'bg-secondary text-secondary-foreground'}, - PLAYING: {icon: Gamepad2, text: 'text-primary', badge: 'bg-primary/15 text-primary'}, - FINISHED: {icon: CircleCheck, text: 'text-emerald-600 dark:text-emerald-400', badge: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400'}, - COMPLETED: {icon: Trophy, text: 'text-amber-600 dark:text-amber-400', badge: 'bg-amber-500/15 text-amber-600 dark:text-amber-400'}, - DROPPED: {icon: Ban, text: 'text-rose-600 dark:text-rose-400', badge: 'bg-rose-500/15 text-rose-600 dark:text-rose-400'}, + BACKLOG: {icon: Inbox, text: 'text-muted-foreground', badge: 'bg-secondary text-secondary-foreground', bar: 'bg-zinc-400 dark:bg-zinc-500'}, + PLAYING: {icon: Gamepad2, text: 'text-primary', badge: 'bg-primary/15 text-primary', bar: 'bg-primary'}, + FINISHED: {icon: CircleCheck, text: 'text-emerald-600 dark:text-emerald-400', badge: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400', bar: 'bg-emerald-500'}, + COMPLETED: {icon: Trophy, text: 'text-amber-600 dark:text-amber-400', badge: 'bg-amber-500/15 text-amber-600 dark:text-amber-400', bar: 'bg-amber-500'}, + DROPPED: {icon: Ban, text: 'text-rose-600 dark:text-rose-400', badge: 'bg-rose-500/15 text-rose-600 dark:text-rose-400', bar: 'bg-rose-500'}, }; /** diff --git a/src/components/PileOfShameCard.jsx b/src/components/PileOfShameCard.jsx new file mode 100644 index 0000000..f6a6a37 --- /dev/null +++ b/src/components/PileOfShameCard.jsx @@ -0,0 +1,99 @@ +import {Link} from 'react-router-dom'; +import {useTranslation} from 'react-i18next'; +import {Inbox, Shuffle} from 'lucide-react'; + +import {Button} from '@/components/ui/button'; +import {Card} from '@/components/ui/card'; +import GameCover from './GameCover.jsx'; +import {STATUS_META} from './GameStatusBadge.jsx'; +import {backlogShare, GAME_STATUSES} from '../utils/backlog.js'; + +export default function PileOfShameCard({summary, syncing, onShuffle, shuffling}) { + const {t, i18n} = useTranslation(); + const backlog = summary.byStatus?.BACKLOG ?? 0; + const suggestion = summary.suggestion; + + if (syncing && summary.total === 0) { + return ( + + + <p className="py-6 text-center text-sm text-muted-foreground">{t('LIBRARY.SYNCING_FIRST_TIME')}</p> + </Card> + ); + } + + return ( + <Card className="gap-5 p-5"> + <Title/> + + <div> + <div className="flex items-baseline gap-2"> + <span className="text-4xl font-bold tabular-nums">{backlog.toLocaleString(i18n.language)}</span> + <span className="text-sm text-muted-foreground"> + {t('DASHBOARD.PILE_SHARE', {percent: backlogShare(summary)})} + </span> + </div> + <p className="mt-1 text-sm text-muted-foreground"> + {t('DASHBOARD.NEVER_PLAYED', {count: summary.neverPlayed})} + </p> + </div> + + {summary.total > 0 && ( + <div className="space-y-2"> + <div className="flex h-2.5 overflow-hidden rounded-full bg-muted"> + {GAME_STATUSES.map((s) => ( + <div + key={s} + className={STATUS_META[s].bar} + style={{width: `${(summary.byStatus[s] / summary.total) * 100}%`}} + /> + ))} + </div> + <ul className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground"> + {GAME_STATUSES.map((s) => ( + <li key={s} className="flex items-center gap-1.5"> + <span className={`size-2 rounded-full ${STATUS_META[s].bar}`}/> + {t(`BACKLOG.STATUS.${s}`)} + <span className="tabular-nums text-foreground">{summary.byStatus[s]}</span> + </li> + ))} + </ul> + </div> + )} + + {suggestion ? ( + <div className="space-y-2"> + <div className="flex items-center justify-between"> + <span className="text-sm font-medium">{t('DASHBOARD.SUGGESTION')}</span> + {backlog > 1 && ( + <Button variant="ghost" size="sm" onClick={onShuffle} disabled={shuffling}> + <Shuffle/>{t('DASHBOARD.OTHER_SUGGESTION')} + </Button> + )} + </div> + <Link + to={`/library/${suggestion.appid}`} + className="group flex items-center gap-3 rounded-lg border p-2 transition-colors hover:bg-muted/50" + > + <GameCover appid={suggestion.appid} className="w-24 shrink-0"/> + <span className="min-w-0 truncate font-medium group-hover:text-primary transition-colors"> + {suggestion.name ?? `App ${suggestion.appid}`} + </span> + </Link> + </div> + ) : ( + <p className="text-sm text-muted-foreground">{t('DASHBOARD.BACKLOG_EMPTY')}</p> + )} + </Card> + ); +} + +function Title() { + const {t} = useTranslation(); + return ( + <h2 className="flex items-center gap-2 font-semibold"> + <Inbox className="size-4 text-rose-500"/> + {t('DASHBOARD.PILE_OF_SHAME')} + </h2> + ); +} diff --git a/src/pages/Dashboard.jsx b/src/pages/Dashboard.jsx index a5bd0a5..58ecfc7 100644 --- a/src/pages/Dashboard.jsx +++ b/src/pages/Dashboard.jsx @@ -1,13 +1,16 @@ import React, {useCallback, useEffect, useState} from 'react'; import {Link, useNavigate} from 'react-router-dom'; import {useTranslation} from 'react-i18next'; +import {toast} from 'sonner'; import {ArrowRight, Hash, Link2, RefreshCw, Trophy} 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 CurrentlyPlayingCard from '../components/CurrentlyPlayingCard.jsx'; import GameCover from '../components/GameCover.jsx'; +import PileOfShameCard from '../components/PileOfShameCard.jsx'; import SteamGameCount from '../components/SteamGameCount'; import SyncStatusBadge from '../components/SyncStatusBadge.jsx'; import useLibrarySync from '../hooks/useLibrarySync.js'; @@ -17,6 +20,8 @@ import {formatPlaytime} from '../utils/games.js'; const TOP_GAMES = 5; // The games endpoint accepts only the page sizes 20, 40 and 80. const TOP_GAMES_URL = '/api/library/games?page=0&size=20&sort=playtimeMinutes%2Cdesc'; +const PLAYING_GAMES = 4; +const PLAYING_URL = '/api/library/games?page=0&size=20&sort=playtimeMinutes%2Cdesc&status=PLAYING'; const authHeaders = () => ({ 'Authorization': `Bearer ${localStorage.getItem('tbp_jwt')}`, @@ -29,6 +34,10 @@ const Dashboard = () => { const [userData, setUserData] = useState(null); const [steamData, setSteamData] = useState({count: 0, hasSteamId: false}); const [topGames, setTopGames] = useState([]); + const [playing, setPlaying] = useState([]); + const [summary, setSummary] = useState(null); + const [summaryFailed, setSummaryFailed] = useState(false); + const [shuffling, setShuffling] = useState(false); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); @@ -55,10 +64,52 @@ const Dashboard = () => { } }, []); + const fetchPlaying = useCallback(async () => { + try { + const r = await fetch(`${import.meta.env.VITE_CORE_API_URL}${PLAYING_URL}`, {headers: authHeaders()}); + if (r.ok) { + const page = await r.json(); + setPlaying((page.content ?? []).slice(0, PLAYING_GAMES)); + } + } catch (err) { + console.error('Error fetching playing games:', err); + } + }, []); + + /** + * @param excludeAppid the current suggestion, so the new pick differs from it + * @returns false when the request failed + */ + const fetchSummary = useCallback(async (excludeAppid) => { + const query = excludeAppid ? `?exclude=${excludeAppid}` : ''; + try { + const r = await fetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/summary${query}`, {headers: authHeaders()}); + if (r.ok) { + setSummary(await r.json()); + setSummaryFailed(false); + return true; + } + } catch (err) { + console.error('Error fetching library summary:', err); + } + setSummaryFailed(true); + return false; + }, []); + + const shuffleSuggestion = async () => { + setShuffling(true); + if (!await fetchSummary(summary?.suggestion?.appid)) { + toast.error(t('DASHBOARD.SUMMARY_FAILED')); + } + setShuffling(false); + }; + const reloadLibrary = useCallback(() => { fetchSteamCount(); fetchTopGames(); - }, [fetchSteamCount, fetchTopGames]); + fetchPlaying(); + fetchSummary(); + }, [fetchSteamCount, fetchTopGames, fetchPlaying, fetchSummary]); useEffect(() => { const fetchUserData = async () => { @@ -78,7 +129,7 @@ const Dashboard = () => { setUserData(data); if (data.steamId) { - await Promise.all([fetchSteamCount(), fetchTopGames()]); + await Promise.all([fetchSteamCount(), fetchTopGames(), fetchPlaying(), fetchSummary()]); } } else if (profileResponse.status === 401 || profileResponse.status === 403) { localStorage.removeItem('tbp_jwt'); @@ -95,7 +146,7 @@ const Dashboard = () => { }; fetchUserData(); - }, [navigate, t, fetchSteamCount, fetchTopGames]); + }, [navigate, t, fetchSteamCount, fetchTopGames, fetchPlaying, fetchSummary]); const hasSteamId = !!userData?.steamId; const sync = useLibrarySync({ @@ -209,6 +260,24 @@ const Dashboard = () => { </Card> </div> )} + + {hasSteamId && ( + <div className="grid grid-cols-1 md:grid-cols-2 gap-6"> + <CurrentlyPlayingCard games={playing} syncing={sync.isRunning}/> + {summary && ( + <PileOfShameCard summary={summary} syncing={sync.isRunning} + onShuffle={shuffleSuggestion} shuffling={shuffling}/> + )} + {!summary && summaryFailed && ( + <Card className="items-center justify-center gap-3 p-6 text-center text-sm text-muted-foreground"> + {t('DASHBOARD.SUMMARY_FAILED')} + <Button variant="outline" size="sm" onClick={() => fetchSummary()}> + <RefreshCw/>{t('DASHBOARD.RETRY')} + </Button> + </Card> + )} + </div> + )} </div> ); }; diff --git a/src/utils/backlog.js b/src/utils/backlog.js index 910aee2..33f149f 100644 --- a/src/utils/backlog.js +++ b/src/utils/backlog.js @@ -19,3 +19,6 @@ export const libraryQuery = ({page, size, sort, status, q}) => { if (q?.trim()) params.set('q', q.trim()); return params.toString(); }; + +export const backlogShare = (summary) => + summary?.total ? Math.round(((summary.byStatus?.BACKLOG ?? 0) / summary.total) * 100) : 0; diff --git a/src/utils/backlog.test.js b/src/utils/backlog.test.js index c733a7b..c000e11 100644 --- a/src/utils/backlog.test.js +++ b/src/utils/backlog.test.js @@ -1,5 +1,5 @@ import {describe, expect, it} from 'vitest'; -import {libraryQuery, NO_PRIORITY, parseStatus, toBacklogRequest} from './backlog.js'; +import {backlogShare, libraryQuery, NO_PRIORITY, parseStatus, toBacklogRequest} from './backlog.js'; describe('toBacklogRequest', () => { it('sends every field', () => { @@ -32,3 +32,14 @@ describe('libraryQuery', () => { .toBe('page=1&size=40&sort=appid%2Casc&status=DROPPED&q=100%25+%26+more'); }); }); + +describe('backlogShare', () => { + it('is the rounded backlog percent of the library', () => { + expect(backlogShare({total: 3, byStatus: {BACKLOG: 2}})).toBe(67); + }); + + it('is 0 for an empty library', () => { + expect(backlogShare({total: 0, byStatus: {BACKLOG: 0}})).toBe(0); + expect(backlogShare(null)).toBe(0); + }); +});