From 8b290d5a13dbe967923104958bbda4432276e7f8 Mon Sep 17 00:00:00 2001 From: Andreas Dahm Date: Mon, 4 May 2026 11:48:08 +0200 Subject: [PATCH] Displaying the game library with server side pagination --- public/i18n/de.json | 11 ++ public/i18n/en.json | 11 ++ src/App.jsx | 2 + src/components/SteamGameCount.jsx | 12 +- src/components/SyncStatusBadge.jsx | 45 +++++++ src/hooks/useLibrarySync.js | 91 +++++++++++++ src/pages/Dashboard.jsx | 2 +- src/pages/GameLibrary.jsx | 198 +++++++++++++++++++++++++++++ 8 files changed, 367 insertions(+), 5 deletions(-) create mode 100644 src/components/SyncStatusBadge.jsx create mode 100644 src/hooks/useLibrarySync.js create mode 100644 src/pages/GameLibrary.jsx diff --git a/public/i18n/de.json b/public/i18n/de.json index 0b4999e..a5fa5ae 100644 --- a/public/i18n/de.json +++ b/public/i18n/de.json @@ -71,6 +71,17 @@ "HELLO": "Hallo", "STEAM_GAMES": "Steam Spiele" }, + "LIBRARY": { + "TITLE": "Spielebibliothek", + "LOADING": "Lade Spiele …", + "EMPTY": "Noch keine Spiele. Synchronisiere deine Steam-Bibliothek.", + "ERROR_LOAD": "Spiele konnten nicht geladen werden.", + "PER_PAGE": "Pro Seite:", + "PREV": "Zurück", + "NEXT": "Weiter", + "PAGE_OF": "Seite {{current}} von {{total}}", + "GAMES": "Spiele" + }, "MAIN": { "WELCOME": "Dein Game-Backlog unter Kontrolle", "DESCRIPTION": "Organisiere deinen Pile of Shame. Verfolge, verwalte und erobere deinen Game-Backlog wie nie zuvor.", diff --git a/public/i18n/en.json b/public/i18n/en.json index aa9ddff..076e52d 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -71,6 +71,17 @@ "HELLO": "Hello", "STEAM_GAMES": "Steam Games" }, + "LIBRARY": { + "TITLE": "Game Library", + "LOADING": "Loading games …", + "EMPTY": "No games yet. Try syncing your Steam library.", + "ERROR_LOAD": "Could not load your games.", + "PER_PAGE": "Per page:", + "PREV": "Previous", + "NEXT": "Next", + "PAGE_OF": "Page {{current}} of {{total}}", + "GAMES": "games" + }, "MAIN": { "WELCOME": "Control your game backlog", "DESCRIPTION": "Organize your pile of shame. Track, manage, and conquer your gaming backlog like never before.", diff --git a/src/App.jsx b/src/App.jsx index fd04487..e030db0 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -8,6 +8,7 @@ import ImprintPage from './pages/ImprintPage'; import Dashboard from "./pages/Dashboard.jsx"; import RegistrationPage from './pages/RegistrationPage.jsx'; import SettingsPage from './pages/SettingsPage.jsx'; +import GameLibrary from './pages/GameLibrary.jsx'; import {usePreferences} from './context/PreferencesContext.jsx'; function App() { @@ -78,6 +79,7 @@ function App() { }/> }/> }/> + }/> }/> }/> diff --git a/src/components/SteamGameCount.jsx b/src/components/SteamGameCount.jsx index 092bcc8..1b7ec65 100644 --- a/src/components/SteamGameCount.jsx +++ b/src/components/SteamGameCount.jsx @@ -1,4 +1,5 @@ import React, {useState, useEffect} from 'react'; +import {Link} from 'react-router-dom'; import {useTranslation} from 'react-i18next'; const SteamGameCount = ({targetCount}) => { @@ -7,13 +8,13 @@ const SteamGameCount = ({targetCount}) => { useEffect(() => { let startTime; - const duration = 1000; // 1 second + const duration = 1000; const startCount = 0; const animate = (timestamp) => { if (!startTime) startTime = timestamp; const progress = Math.min((timestamp - startTime) / duration, 1); - + const currentCount = Math.floor(progress * (targetCount - startCount) + startCount); setCount(currentCount); @@ -26,14 +27,17 @@ const SteamGameCount = ({targetCount}) => { }, [targetCount]); return ( -
+
{count}
{t('DASHBOARD.STEAM_GAMES')}
-
+ ); }; diff --git a/src/components/SyncStatusBadge.jsx b/src/components/SyncStatusBadge.jsx new file mode 100644 index 0000000..52dca05 --- /dev/null +++ b/src/components/SyncStatusBadge.jsx @@ -0,0 +1,45 @@ +import {useTranslation} from 'react-i18next'; + +const formatRelative = (iso, locale) => { + if (!iso) return null; + const then = new Date(iso).getTime(); + const diffSec = Math.round((Date.now() - then) / 1000); + const rtf = new Intl.RelativeTimeFormat(locale, {numeric: 'auto'}); + if (diffSec < 60) return rtf.format(-diffSec, 'second'); + if (diffSec < 3600) return rtf.format(-Math.round(diffSec / 60), 'minute'); + if (diffSec < 86400) return rtf.format(-Math.round(diffSec / 3600), 'hour'); + return rtf.format(-Math.round(diffSec / 86400), 'day'); +}; + +export default function SyncStatusBadge({status, lastSyncedAt, isRunning}) { + const {t, i18n} = useTranslation(); + + if (isRunning) { + return ( +
+ + {t('LIBRARY.SYNCING')} +
+ ); + } + + if (status === 'FAILED') { + return ( +
+ + {t('LIBRARY.SYNC_FAILED')} +
+ ); + } + + if (lastSyncedAt) { + return ( +
+ + {t('LIBRARY.LAST_SYNCED', {when: formatRelative(lastSyncedAt, i18n.language)})} +
+ ); + } + + return null; +} diff --git a/src/hooks/useLibrarySync.js b/src/hooks/useLibrarySync.js new file mode 100644 index 0000000..92ff310 --- /dev/null +++ b/src/hooks/useLibrarySync.js @@ -0,0 +1,91 @@ +import {useEffect, useRef, useState} from 'react'; + +const POLL_INTERVAL_MS = 3000; +const MAX_POLL_DURATION_MS = 120_000; +const INITIAL_SYNC_GRACE_MS = 15_000; + +/** + * Polls /api/library/sync-status while a sync is running (or expected), + * and fires onComplete once when the run transitions to OK / FAILED. + * + * @param {Object} opts + * @param {boolean} opts.enabled - skip work entirely when false (e.g. user has no steamId) + * @param {boolean} opts.expectInitialSync - if true and no row yet, keep polling (covers the + * gap between async sync dispatch and first DB write) + * @param {() => void} opts.onComplete - called once per transition to OK/FAILED + * @returns {{status: string|null, lastSyncedAt: string|null, isRunning: boolean, error: string|null}} + */ +export default function useLibrarySync({enabled, expectInitialSync = false, onComplete}) { + const [state, setState] = useState({status: null, lastSyncedAt: null, error: null}); + const previousRunningRef = useRef(null); + const onCompleteRef = useRef(onComplete); + onCompleteRef.current = onComplete; + + useEffect(() => { + if (!enabled) return undefined; + + let cancelled = false; + let timer = null; + const startedAt = Date.now(); + + const fetchOnce = async () => { + const token = localStorage.getItem('tbp_jwt'); + if (!token) return; + + try { + const response = await fetch( + `${import.meta.env.VITE_CORE_API_URL}/api/library/sync-status`, + {headers: {'Authorization': `Bearer ${token}`}} + ); + if (cancelled) return; + + if (!response.ok) { + setState((s) => ({...s, error: 'sync-status fetch failed'})); + return; + } + + const json = await response.json(); + if (cancelled) return; + + const status = json.lastSyncStatus; + const lastSyncedAt = json.lastSyncedAt; + setState({status, lastSyncedAt, error: null}); + + const isRunning = status === 'RUNNING'; + const wasRunning = previousRunningRef.current; + + if (wasRunning === true && !isRunning) { + onCompleteRef.current?.(); + } + previousRunningRef.current = isRunning; + + const elapsed = Date.now() - startedAt; + const waitingForInitial = + expectInitialSync && status == null && elapsed < INITIAL_SYNC_GRACE_MS; + const stillWaiting = isRunning || waitingForInitial; + + if (stillWaiting && elapsed < MAX_POLL_DURATION_MS) { + timer = setTimeout(fetchOnce, POLL_INTERVAL_MS); + } + } catch (err) { + if (cancelled) return; + console.error('sync-status poll error:', err); + setState((s) => ({...s, error: 'network'})); + } + }; + + fetchOnce(); + + return () => { + cancelled = true; + if (timer) clearTimeout(timer); + }; + }, [enabled, expectInitialSync]); + + return { + status: state.status, + lastSyncedAt: state.lastSyncedAt, + isRunning: state.status === 'RUNNING', + error: state.error, + }; +} diff --git a/src/pages/Dashboard.jsx b/src/pages/Dashboard.jsx index 832d690..206b2d3 100644 --- a/src/pages/Dashboard.jsx +++ b/src/pages/Dashboard.jsx @@ -36,7 +36,7 @@ const Dashboard = () => { // If user has a steamId, fetch game count from Core Service if (data.steamId) { try { - const steamResponse = await fetch(`${import.meta.env.VITE_CORE_API_URL}/api/steam/games-count`, { + const steamResponse = await fetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/games-count`, { method: 'GET', headers: { 'Authorization': `Bearer ${token}`, diff --git a/src/pages/GameLibrary.jsx b/src/pages/GameLibrary.jsx new file mode 100644 index 0000000..862add4 --- /dev/null +++ b/src/pages/GameLibrary.jsx @@ -0,0 +1,198 @@ +import {useCallback, useEffect, useState} from 'react'; +import {useNavigate, useSearchParams} from 'react-router-dom'; +import {useTranslation} from 'react-i18next'; +import useLibrarySync from '../hooks/useLibrarySync.js'; +import SyncStatusBadge from '../components/SyncStatusBadge.jsx'; + +const PAGE_SIZES = [20, 40, 80]; +const DEFAULT_SIZE = 20; + +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(); + const [searchParams, setSearchParams] = useSearchParams(); + + const page = Math.max(0, parseInt(searchParams.get('page') ?? '0', 10) || 0); + const sizeParam = parseInt(searchParams.get('size') ?? String(DEFAULT_SIZE), 10); + const size = PAGE_SIZES.includes(sizeParam) ? sizeParam : DEFAULT_SIZE; + + const [data, setData] = useState({content: [], page: 0, size, totalElements: 0, totalPages: 0}); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + const load = useCallback(async () => { + const token = localStorage.getItem('tbp_jwt'); + if (!token) { + navigate('/login'); + return; + } + setIsLoading(true); + setError(null); + try { + const url = `${import.meta.env.VITE_CORE_API_URL}/api/library/games?page=${page}&size=${size}`; + const response = await fetch(url, { + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + }); + + if (response.status === 401) { + localStorage.removeItem('tbp_jwt'); + navigate('/login'); + return; + } + + if (!response.ok) { + setError(t('LIBRARY.ERROR_LOAD')); + return; + } + + const json = await response.json(); + setData(json); + } catch (err) { + console.error('Library load error:', err); + setError(t('INFO.ERROR.NETWORK_FAILURE')); + } finally { + setIsLoading(false); + } + }, [navigate, page, size, t]); + + useEffect(() => { + load(); + }, [load]); + + const expectInitialSync = !isLoading && !error && data.totalElements === 0; + const sync = useLibrarySync({ + enabled: true, + expectInitialSync, + onComplete: load, + }); + + const updateParams = (next) => { + const params = new URLSearchParams(searchParams); + Object.entries(next).forEach(([k, v]) => params.set(k, String(v))); + setSearchParams(params); + }; + + const onSizeChange = (event) => { + updateParams({size: event.target.value, page: 0}); + }; + + const goToPage = (target) => { + if (target < 0 || target >= data.totalPages) return; + updateParams({page: target}); + }; + + return ( +
+
+
+
+

+ {t('LIBRARY.TITLE')} +

+ +
+
+ + +
+
+ + {isLoading && ( +
+ {t('LIBRARY.LOADING')} +
+ )} + + {error && ( +
{error}
+ )} + + {!isLoading && !error && data.content.length === 0 && ( +
+ {sync.isRunning ? t('LIBRARY.SYNCING_FIRST_TIME') : t('LIBRARY.EMPTY')} +
+ )} + + {!isLoading && !error && data.content.length > 0 && ( +
    + {data.content.map((g) => { + const icon = iconUrl(g.appid, g.imgIconUrl); + return ( +
  • + {icon ? ( + { e.currentTarget.style.visibility = 'hidden'; }} + /> + ) : ( +
    + )} + + {g.name ?? `App ${g.appid}`} + + + {formatPlaytime(g.playtimeMinutes)} + +
  • + ); + })} +
+ )} + + {!isLoading && !error && data.totalPages > 1 && ( +
+ + + {t('LIBRARY.PAGE_OF', {current: data.page + 1, total: data.totalPages})} + {' · '} + {data.totalElements} {t('LIBRARY.GAMES')} + + +
+ )} +
+
+ ); +}