From 76a9ac3e6f27bf82e46d0df6ff15de4708bd802a Mon Sep 17 00:00:00 2001 From: lobo Date: Thu, 24 Sep 2026 09:49:06 +0200 Subject: [PATCH] feat(dashboard): new layout with sync button and top 5 most played Remove the placeholder tiles and the settings tile, fix the mobile header width. iconUrl/formatPlaytime move to utils/games.js (599 min now shows 10h, not 10.0h), and the sync button logic to the useForceSync hook shared with the library. --- public/i18n/de.json | 4 +- public/i18n/en.json | 4 +- src/App.jsx | 13 +-- src/hooks/useForceSync.js | 50 ++++++++++ src/pages/Dashboard.jsx | 188 +++++++++++++++++++++++++------------- src/pages/GameDetail.jsx | 12 +-- src/pages/GameLibrary.jsx | 48 +--------- src/utils/games.js | 11 +++ src/utils/games.test.js | 14 +++ 9 files changed, 216 insertions(+), 128 deletions(-) create mode 100644 src/hooks/useForceSync.js create mode 100644 src/utils/games.js create mode 100644 src/utils/games.test.js diff --git a/public/i18n/de.json b/public/i18n/de.json index 1842f8f..8dc7a6a 100644 --- a/public/i18n/de.json +++ b/public/i18n/de.json @@ -70,7 +70,9 @@ "LOADING": "Lade Dashboard ...", "HELLO": "Hallo", "STEAM_GAMES": "Steam Spiele", - "NO_STEAM_ID": "Keine Steam-ID hinterlegt – jetzt in den Einstellungen eintragen" + "NO_STEAM_ID": "Keine Steam-ID hinterlegt – jetzt in den Einstellungen eintragen", + "MOST_PLAYED": "Meistgespielt", + "SHOW_ALL": "Alle anzeigen" }, "LIBRARY": { "TITLE": "Spielebibliothek", diff --git a/public/i18n/en.json b/public/i18n/en.json index 1c8ac19..673a9d9 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -70,7 +70,9 @@ "LOADING": "Loading dashboard ...", "HELLO": "Hello", "STEAM_GAMES": "Steam Games", - "NO_STEAM_ID": "No Steam ID saved yet – add it in the settings" + "NO_STEAM_ID": "No Steam ID saved yet – add it in the settings", + "MOST_PLAYED": "Most played", + "SHOW_ALL": "Show all" }, "LIBRARY": { "TITLE": "Game Library", diff --git a/src/App.jsx b/src/App.jsx index bdde179..45d11b2 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -43,9 +43,9 @@ function App() { return (
-
- - Game Log +
+ + Game Log {isAuthenticated && ( @@ -65,7 +65,7 @@ function App() { )} -
+
@@ -76,8 +76,9 @@ function App() { {isAuthenticated ? ( <> - ) : ( diff --git a/src/hooks/useForceSync.js b/src/hooks/useForceSync.js new file mode 100644 index 0000000..8b7fdd5 --- /dev/null +++ b/src/hooks/useForceSync.js @@ -0,0 +1,50 @@ +import {useState} from 'react'; +import {useNavigate} from 'react-router-dom'; +import {useTranslation} from 'react-i18next'; + +const RELOAD_DELAY_MS = 4000; + +/** + * Starts a library sync (POST /api/library/sync) and calls onStarted after a short delay, + * so the caller can reload once Steam data has arrived. + */ +export default function useForceSync(onStarted) { + const {t} = useTranslation(); + const navigate = useNavigate(); + const [busy, setBusy] = useState(false); + const [message, setMessage] = useState(null); + + const trigger = async () => { + const token = localStorage.getItem('tbp_jwt'); + if (!token) { + navigate('/login'); + return; + } + setBusy(true); + setMessage(null); + try { + const response = await fetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/sync`, { + method: 'POST', + headers: {'Authorization': `Bearer ${token}`}, + }); + if (response.status === 429) { + const json = await response.json().catch(() => ({})); + setMessage(t('LIBRARY.SYNC_COOLDOWN', {seconds: json.retryAfterSeconds ?? 60})); + return; + } + if (!response.ok) { + setMessage(t('LIBRARY.SYNC_FAILED')); + return; + } + setMessage(t('LIBRARY.SYNC_STARTED')); + setTimeout(onStarted, RELOAD_DELAY_MS); + } catch (err) { + console.error('sync trigger error:', err); + setMessage(t('INFO.ERROR.NETWORK_FAILURE')); + } finally { + setBusy(false); + } + }; + + return {trigger, busy, message}; +} diff --git a/src/pages/Dashboard.jsx b/src/pages/Dashboard.jsx index 6e0787c..3bb37ea 100644 --- a/src/pages/Dashboard.jsx +++ b/src/pages/Dashboard.jsx @@ -4,25 +4,32 @@ import {useTranslation} from 'react-i18next'; import SteamGameCount from '../components/SteamGameCount'; import SyncStatusBadge from '../components/SyncStatusBadge.jsx'; import useLibrarySync from '../hooks/useLibrarySync.js'; +import useForceSync from '../hooks/useForceSync.js'; +import {formatPlaytime, iconUrl} 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 cardClass = 'bg-white dark:bg-slate-800 rounded-xl shadow-sm border border-slate-200 dark:border-slate-700'; + +const authHeaders = () => ({ + 'Authorization': `Bearer ${localStorage.getItem('tbp_jwt')}`, + 'Content-Type': 'application/json', +}); const Dashboard = () => { const {t} = useTranslation(); const navigate = useNavigate(); const [userData, setUserData] = useState(null); const [steamData, setSteamData] = useState({count: 0, hasSteamId: false}); + const [topGames, setTopGames] = useState([]); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); const fetchSteamCount = useCallback(async () => { - const token = localStorage.getItem('tbp_jwt'); - if (!token) return; try { - const r = await fetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/games-count`, { - headers: { - 'Authorization': `Bearer ${token}`, - 'Content-Type': 'application/json', - }, - }); + const r = await fetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/games-count`, {headers: authHeaders()}); if (r.ok) { setSteamData(await r.json()); } @@ -31,11 +38,26 @@ const Dashboard = () => { } }, []); + const fetchTopGames = useCallback(async () => { + try { + const r = await fetch(`${import.meta.env.VITE_CORE_API_URL}${TOP_GAMES_URL}`, {headers: authHeaders()}); + if (r.ok) { + const page = await r.json(); + setTopGames((page.content ?? []).slice(0, TOP_GAMES)); + } + } catch (err) { + console.error('Error fetching top games:', err); + } + }, []); + + const reloadLibrary = useCallback(() => { + fetchSteamCount(); + fetchTopGames(); + }, [fetchSteamCount, fetchTopGames]); + useEffect(() => { const fetchUserData = async () => { - const token = localStorage.getItem('tbp_jwt'); - - if (!token) { + if (!localStorage.getItem('tbp_jwt')) { navigate('/login'); return; } @@ -43,10 +65,7 @@ const Dashboard = () => { try { const profileResponse = await fetch(`${import.meta.env.VITE_API_URL}/api/users/me?v=1`, { method: 'GET', - headers: { - 'Authorization': `Bearer ${token}`, - 'Content-Type': 'application/json' - } + headers: authHeaders(), }); if (profileResponse.ok) { @@ -54,7 +73,7 @@ const Dashboard = () => { setUserData(data); if (data.steamId) { - await fetchSteamCount(); + await Promise.all([fetchSteamCount(), fetchTopGames()]); } } else if (profileResponse.status === 401 || profileResponse.status === 403) { localStorage.removeItem('tbp_jwt'); @@ -71,18 +90,19 @@ const Dashboard = () => { }; fetchUserData(); - }, [navigate, t, fetchSteamCount]); + }, [navigate, t, fetchSteamCount, fetchTopGames]); - const expectInitialSync = !!userData?.steamId && steamData.count === 0; + const hasSteamId = !!userData?.steamId; const sync = useLibrarySync({ - enabled: !!userData?.steamId, - expectInitialSync, - onComplete: fetchSteamCount, + enabled: hasSteamId, + expectInitialSync: hasSteamId && steamData.count === 0, + onComplete: reloadLibrary, }); + const forceSync = useForceSync(reloadLibrary); if (isLoading) { return ( -
+
{t('DASHBOARD.LOADING')}
); @@ -90,24 +110,22 @@ const Dashboard = () => { if (error) { return ( -
+
{error}
); } - return ( -
-
+ const syncBusy = forceSync.busy || sync.isRunning; -
-

- {t('DASHBOARD.HELLO')} {userData?.email.split('@')[0]} 👋 + return ( +
+
+
+

+ {t('DASHBOARD.HELLO')} {userData?.email?.split('@')[0]} 👋

-

- {userData?.dashboardMessage} -

- {userData?.steamId ? ( + {hasSteamId ? (

{t('SETTINGS.STEAM_ID')}: {userData.steamId} @@ -118,41 +136,83 @@ const Dashboard = () => { {t('DASHBOARD.NO_STEAM_ID')} )} - {userData?.steamId && ( -

- -
- )}
-
- {steamData.hasSteamId && ( - - )} -
- Tile 1 (Coming soon) + {hasSteamId && ( +
+ + + {forceSync.message && ( + {forceSync.message} + )}
- {!steamData.hasSteamId && ( -
- Tile 2 (Coming soon) -
- )} - - - - {t('SETTINGS.TITLE')} - - -
+ )} +
-
+ {hasSteamId && ( +
+ + +
+
+

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

+ + {t('DASHBOARD.SHOW_ALL')} + +
+ + {topGames.length === 0 ? ( +

+ {sync.isRunning ? t('LIBRARY.SYNCING_FIRST_TIME') : t('LIBRARY.EMPTY')} +

+ ) : ( +
    + {topGames.map((g, index) => { + const icon = iconUrl(g.appid, g.imgIconUrl); + return ( +
  1. + + + {index + 1}. + + {icon ? ( + + ) : ( +
    + )} + + {g.name ?? `App ${g.appid}`} + + + {formatPlaytime(g.playtimeMinutes)} + + +
  2. + ); + })} +
+ )} +
+
+ )}

); }; diff --git a/src/pages/GameDetail.jsx b/src/pages/GameDetail.jsx index 75b4c15..8fb6601 100644 --- a/src/pages/GameDetail.jsx +++ b/src/pages/GameDetail.jsx @@ -1,17 +1,7 @@ import {useEffect, useMemo, useState} from 'react'; import {Link, useNavigate, useParams} from 'react-router-dom'; import {useTranslation} from 'react-i18next'; - -const iconUrl = (appid, hash) => - hash - ? `https://cdn.cloudflare.steamstatic.com/steamcommunity/public/images/apps/${appid}/${hash}.jpg` - : null; - -const formatPlaytime = (minutes) => { - if (!minutes || minutes <= 0) return '0h'; - const hours = minutes / 60; - return hours >= 10 ? `${Math.round(hours)}h` : `${hours.toFixed(1)}h`; -}; +import {formatPlaytime, iconUrl} from '../utils/games.js'; const formatUnlockedAt = (instant, locale) => { if (!instant) return ''; diff --git a/src/pages/GameLibrary.jsx b/src/pages/GameLibrary.jsx index 82dff1c..6002354 100644 --- a/src/pages/GameLibrary.jsx +++ b/src/pages/GameLibrary.jsx @@ -3,6 +3,8 @@ import {Link, useNavigate, useSearchParams} from 'react-router-dom'; import {useTranslation} from 'react-i18next'; import useLibrarySync from '../hooks/useLibrarySync.js'; import SyncStatusBadge from '../components/SyncStatusBadge.jsx'; +import useForceSync from '../hooks/useForceSync.js'; +import {formatPlaytime, iconUrl} from '../utils/games.js'; const PAGE_SIZES = [20, 40, 80]; const DEFAULT_SIZE = 20; @@ -14,17 +16,6 @@ const SORT_OPTIONS = [ {value: 'firstSeenAt,desc', labelKey: 'LIBRARY.SORT_FIRST_SEEN'}, ]; -const iconUrl = (appid, hash) => - hash - ? `https://cdn.cloudflare.steamstatic.com/steamcommunity/public/images/apps/${appid}/${hash}.jpg` - : null; - -const formatPlaytime = (minutes) => { - if (!minutes || minutes <= 0) return '0h'; - const hours = minutes / 60; - return hours >= 10 ? `${Math.round(hours)}h` : `${hours.toFixed(1)}h`; -}; - export default function GameLibrary() { const {t} = useTranslation(); const navigate = useNavigate(); @@ -109,40 +100,7 @@ export default function GameLibrary() { updateParams({page: target}); }; - const [syncBusy, setSyncBusy] = useState(false); - const [syncMessage, setSyncMessage] = useState(null); - - const triggerSync = async () => { - const token = localStorage.getItem('tbp_jwt'); - if (!token) { - navigate('/login'); - return; - } - setSyncBusy(true); - setSyncMessage(null); - try { - const response = await fetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/sync`, { - method: 'POST', - headers: {'Authorization': `Bearer ${token}`}, - }); - if (response.status === 429) { - const json = await response.json().catch(() => ({})); - setSyncMessage(t('LIBRARY.SYNC_COOLDOWN', {seconds: json.retryAfterSeconds ?? 60})); - return; - } - if (!response.ok) { - setSyncMessage(t('LIBRARY.SYNC_FAILED')); - return; - } - setSyncMessage(t('LIBRARY.SYNC_STARTED')); - setTimeout(load, 4000); - } catch (err) { - console.error('sync trigger error:', err); - setSyncMessage(t('INFO.ERROR.NETWORK_FAILURE')); - } finally { - setSyncBusy(false); - } - }; + const {trigger: triggerSync, busy: syncBusy, message: syncMessage} = useForceSync(load); return (
diff --git a/src/utils/games.js b/src/utils/games.js new file mode 100644 index 0000000..954564a --- /dev/null +++ b/src/utils/games.js @@ -0,0 +1,11 @@ +export const iconUrl = (appid, hash) => + hash + ? `https://cdn.cloudflare.steamstatic.com/steamcommunity/public/images/apps/${appid}/${hash}.jpg` + : null; + +export const formatPlaytime = (minutes) => { + if (!minutes || minutes <= 0) return '0h'; + // Round to one decimal first, so 599 minutes becomes "10h" and not "10.0h". + const hours = Math.round(minutes / 6) / 10; + return hours >= 10 ? `${Math.round(hours)}h` : `${hours.toFixed(1)}h`; +}; diff --git a/src/utils/games.test.js b/src/utils/games.test.js new file mode 100644 index 0000000..d2d6206 --- /dev/null +++ b/src/utils/games.test.js @@ -0,0 +1,14 @@ +import {describe, expect, it} from 'vitest'; +import {formatPlaytime, iconUrl} from './games.js'; + +describe('formatPlaytime', () => { + it.each([[0, '0h'], [null, '0h'], [30, '0.5h'], [599, '10h'], [24720, '412h']])('%j minutes -> %s', (minutes, expected) => { + expect(formatPlaytime(minutes)).toBe(expected); + }); +}); + +describe('iconUrl', () => { + it('is null without a hash', () => { + expect(iconUrl(220, null)).toBeNull(); + }); +});