From 38976e3240e98fe8fc657737e4982088a3e9d358 Mon Sep 17 00:00:00 2001 From: Andreas Dahm Date: Mon, 4 May 2026 15:13:24 +0200 Subject: [PATCH] Added more details and a landing page --- public/i18n/de.json | 38 ++++++ public/i18n/en.json | 38 ++++++ src/App.jsx | 2 + src/components/StatCard.jsx | 40 ++++++ src/hooks/usePublicStats.js | 35 +++++ src/pages/GameDetail.jsx | 258 ++++++++++++++++++++++++++++++++++++ src/pages/GameLibrary.jsx | 161 ++++++++++++++++------ src/pages/LandingPage.jsx | 122 ++++++++++++++++- 8 files changed, 654 insertions(+), 40 deletions(-) create mode 100644 src/components/StatCard.jsx create mode 100644 src/hooks/usePublicStats.js create mode 100644 src/pages/GameDetail.jsx diff --git a/public/i18n/de.json b/public/i18n/de.json index 4f1dc50..8ed7b52 100644 --- a/public/i18n/de.json +++ b/public/i18n/de.json @@ -77,15 +77,53 @@ "EMPTY": "Noch keine Spiele. Synchronisiere deine Steam-Bibliothek.", "ERROR_LOAD": "Spiele konnten nicht geladen werden.", "PER_PAGE": "Pro Seite:", + "SORT_BY": "Sortieren:", + "SORT_PLAYTIME_DESC": "Meistgespielt", + "SORT_PLAYTIME_ASC": "Wenigstgespielt", + "SORT_LAST_SEEN": "Zuletzt gesehen", + "SORT_FIRST_SEEN": "Neueste in Bibliothek", "PREV": "Zurück", "NEXT": "Weiter", "PAGE_OF": "Seite {{current}} von {{total}}", "GAMES": "Spiele", + "RESYNC": "Jetzt synchronisieren", + "SYNC_STARTED": "Synchronisation gestartet …", + "SYNC_COOLDOWN": "In {{seconds}}s erneut versuchen", "SYNCING": "Synchronisiere mit Steam …", "SYNCING_FIRST_TIME": "Erste Synchronisation mit Steam läuft. Deine Spiele erscheinen gleich.", "SYNC_FAILED": "Synchronisation fehlgeschlagen. Bitte später erneut versuchen.", "LAST_SYNCED": "Zuletzt synchronisiert {{when}}" }, + "GAME_DETAIL": { + "BACK": "Zurück", + "BACK_TO_LIBRARY": "Zur Bibliothek", + "LOADING": "Lade Spiel …", + "ERROR_LOAD": "Spiel konnte nicht geladen werden.", + "NOT_FOUND": "Spiel nicht in deiner Bibliothek.", + "PLAYTIME": "Spielzeit: {{time}}", + "OPEN_IN_STEAM": "Auf Steam ansehen", + "ACHIEVEMENTS": "Erfolge", + "UNLOCKED": "Erreicht", + "REMAINING": "Offen", + "COMPLETION": "Fortschritt", + "NO_ACHIEVEMENTS": "Dieses Spiel hat keine Erfolge auf Steam.", + "STATS_HIDDEN": "Statistiken von Steam verborgen — Profil und Spieldetails auf öffentlich stellen, um Erfolge zu sehen." + }, + "LANDING": { + "STATS_TITLE": "Community-Statistiken", + "STATS_LOADING": "Lade Statistiken …", + "TOTAL_USERS": "Registrierte Nutzer", + "STEAM_LINKED": "Mit Steam verknüpft", + "TOTAL_GAMES": "Bibliothekseinträge", + "GAMES_PLAYED": "Gespielte Spiele", + "DISTINCT_GAMES": "Verschiedene Titel", + "TOTAL_PLAYTIME_DAYS": "Gesamte Spielzeit", + "DAYS_SUFFIX": "Tage", + "BIGGEST_LIBRARY": "Größte Bibliothek", + "BIGGEST_BACKLOG": "Größter Backlog", + "TOP_PLAYED": "Meistgespielte Spiele", + "TOP_OWNED": "Meistbesessene 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 d9ecdae..366bd53 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -77,15 +77,53 @@ "EMPTY": "No games yet. Try syncing your Steam library.", "ERROR_LOAD": "Could not load your games.", "PER_PAGE": "Per page:", + "SORT_BY": "Sort by:", + "SORT_PLAYTIME_DESC": "Most played", + "SORT_PLAYTIME_ASC": "Least played", + "SORT_LAST_SEEN": "Recently seen", + "SORT_FIRST_SEEN": "Newest in library", "PREV": "Previous", "NEXT": "Next", "PAGE_OF": "Page {{current}} of {{total}}", "GAMES": "games", + "RESYNC": "Re-sync now", + "SYNC_STARTED": "Sync started …", + "SYNC_COOLDOWN": "Try again in {{seconds}}s", "SYNCING": "Syncing with Steam …", "SYNCING_FIRST_TIME": "First sync with Steam in progress. Your games will appear here shortly.", "SYNC_FAILED": "Sync failed. Please try again later.", "LAST_SYNCED": "Last synced {{when}}" }, + "GAME_DETAIL": { + "BACK": "Back", + "BACK_TO_LIBRARY": "Back to library", + "LOADING": "Loading game …", + "ERROR_LOAD": "Could not load this game.", + "NOT_FOUND": "Game not in your library.", + "PLAYTIME": "Playtime: {{time}}", + "OPEN_IN_STEAM": "View on Steam", + "ACHIEVEMENTS": "Achievements", + "UNLOCKED": "Unlocked", + "REMAINING": "Remaining", + "COMPLETION": "Completion", + "NO_ACHIEVEMENTS": "This game has no achievements on Steam.", + "STATS_HIDDEN": "Stats hidden by Steam — set your profile and game details to public to see achievements." + }, + "LANDING": { + "STATS_TITLE": "Community stats", + "STATS_LOADING": "Loading stats …", + "TOTAL_USERS": "Registered users", + "STEAM_LINKED": "Steam-linked", + "TOTAL_GAMES": "Library entries", + "GAMES_PLAYED": "Games played", + "DISTINCT_GAMES": "Unique titles", + "TOTAL_PLAYTIME_DAYS": "Total playtime", + "DAYS_SUFFIX": "days", + "BIGGEST_LIBRARY": "Biggest library", + "BIGGEST_BACKLOG": "Biggest backlog", + "TOP_PLAYED": "Most played games", + "TOP_OWNED": "Most owned 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 e030db0..df16c52 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -9,6 +9,7 @@ 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 GameDetail from './pages/GameDetail.jsx'; import {usePreferences} from './context/PreferencesContext.jsx'; function App() { @@ -80,6 +81,7 @@ function App() { }/> }/> }/> + }/> }/> }/> diff --git a/src/components/StatCard.jsx b/src/components/StatCard.jsx new file mode 100644 index 0000000..2034329 --- /dev/null +++ b/src/components/StatCard.jsx @@ -0,0 +1,40 @@ +import {useEffect, useState} from 'react'; + +function useCountUp(target, durationMs = 1600) { + const [value, setValue] = useState(0); + useEffect(() => { + const safeTarget = typeof target === 'number' && Number.isFinite(target) ? target : 0; + if (safeTarget === 0) { + setValue(0); + return; + } + let raf; + let start; + const step = (ts) => { + if (start === undefined) start = ts; + const progress = Math.min((ts - start) / durationMs, 1); + setValue(Math.floor(progress * safeTarget)); + if (progress < 1) raf = requestAnimationFrame(step); + }; + raf = requestAnimationFrame(step); + return () => cancelAnimationFrame(raf); + }, [target, durationMs]); + return value; +} + +export default function StatCard({label, value, icon, suffix}) { + const animated = useCountUp(value); + return ( +
+ {icon && ( + + )} +
+ {animated.toLocaleString()}{suffix ?? ''} +
+
+ {label} +
+
+ ); +} diff --git a/src/hooks/usePublicStats.js b/src/hooks/usePublicStats.js new file mode 100644 index 0000000..8b4b445 --- /dev/null +++ b/src/hooks/usePublicStats.js @@ -0,0 +1,35 @@ +import {useEffect, useState} from 'react'; + +export default function usePublicStats() { + const [data, setData] = useState({auth: null, core: null}); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + (async () => { + try { + const [authRes, coreRes] = await Promise.all([ + fetch(`${import.meta.env.VITE_API_URL}/api/stats/public`), + fetch(`${import.meta.env.VITE_CORE_API_URL}/api/stats/public`), + ]); + if (cancelled) return; + const auth = authRes.ok ? await authRes.json() : null; + const core = coreRes.ok ? await coreRes.json() : null; + if (!cancelled) setData({auth, core}); + } catch (err) { + if (!cancelled) { + console.error('public stats error:', err); + setError(err); + } + } finally { + if (!cancelled) setIsLoading(false); + } + })(); + return () => { + cancelled = true; + }; + }, []); + + return {data, isLoading, error}; +} diff --git a/src/pages/GameDetail.jsx b/src/pages/GameDetail.jsx new file mode 100644 index 0000000..75b4c15 --- /dev/null +++ b/src/pages/GameDetail.jsx @@ -0,0 +1,258 @@ +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`; +}; + +const formatUnlockedAt = (instant, locale) => { + if (!instant) return ''; + try { + return new Date(instant).toLocaleDateString(locale, {year: 'numeric', month: 'short', day: 'numeric'}); + } catch { + return ''; + } +}; + +export default function GameDetail() { + const {t, i18n} = useTranslation(); + const {appid} = useParams(); + const navigate = useNavigate(); + + const [data, setData] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + const token = localStorage.getItem('tbp_jwt'); + if (!token) { + navigate('/login'); + return; + } + setIsLoading(true); + setError(null); + (async () => { + try { + const url = `${import.meta.env.VITE_CORE_API_URL}/api/library/games/${appid}`; + const response = await fetch(url, { + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + }); + if (cancelled) return; + if (response.status === 401) { + localStorage.removeItem('tbp_jwt'); + navigate('/login'); + return; + } + if (response.status === 404) { + setError(t('GAME_DETAIL.NOT_FOUND')); + return; + } + if (!response.ok) { + setError(t('GAME_DETAIL.ERROR_LOAD')); + return; + } + const json = await response.json(); + setData(json); + } catch (err) { + if (!cancelled) { + console.error('GameDetail load error:', err); + setError(t('INFO.ERROR.NETWORK_FAILURE')); + } + } finally { + if (!cancelled) setIsLoading(false); + } + })(); + return () => { + cancelled = true; + }; + }, [appid, navigate, t]); + + const sortedAchievements = useMemo(() => { + if (!data?.achievements) return []; + return [...data.achievements].sort((a, b) => { + if (a.unlocked !== b.unlocked) return a.unlocked ? -1 : 1; + if (a.unlocked && b.unlocked) { + return (b.unlockedAt ?? '').localeCompare(a.unlockedAt ?? ''); + } + return (a.displayName ?? a.apiName).localeCompare(b.displayName ?? b.apiName); + }); + }, [data]); + + return ( +
+
+ + + {isLoading && ( +
+ {t('GAME_DETAIL.LOADING')} +
+ )} + + {error && !isLoading && ( +
+ {error} +
+ + {t('GAME_DETAIL.BACK_TO_LIBRARY')} + +
+
+ )} + + {!isLoading && !error && data && ( + <> +
+ {iconUrl(data.appid, data.imgIconUrl) ? ( + + ) : ( +
+ )} +
+

+ {data.name ?? `App ${data.appid}`} +

+

+ {t('GAME_DETAIL.PLAYTIME', {time: formatPlaytime(data.playtimeMinutes)})} +

+
+ + {t('GAME_DETAIL.OPEN_IN_STEAM')} + +
+ + {!data.hasAchievements && ( +
+ {t('GAME_DETAIL.NO_ACHIEVEMENTS')} +
+ )} + + {data.hasAchievements && !data.statsVisible && ( +
+ + {t('GAME_DETAIL.STATS_HIDDEN')} +
+ )} + + {data.hasAchievements && data.statsVisible && ( + <> +
+
+

+ {t('GAME_DETAIL.ACHIEVEMENTS')} +

+ + {data.achievementsUnlocked} / {data.achievementsTotal} + {' · '} + {data.completionPercent}% + +
+
+
+
+
+
+
+ {data.achievementsUnlocked} +
+
+ {t('GAME_DETAIL.UNLOCKED')} +
+
+
+
+ {data.achievementsRemaining} +
+
+ {t('GAME_DETAIL.REMAINING')} +
+
+
+
+ {data.completionPercent}% +
+
+ {t('GAME_DETAIL.COMPLETION')} +
+
+
+
+ +
+
    + {sortedAchievements.map((a) => ( +
  • + {a.unlocked ? ( + a.iconUrl + ? + :
    + ) : ( + a.iconGrayUrl + ? + :
    + )} +
    +
    + {a.displayName ?? a.apiName} +
    + {a.description && ( +
    + {a.description} +
    + )} + {a.unlocked && a.unlockedAt && ( +
    + {formatUnlockedAt(a.unlockedAt, i18n.language)} +
    + )} +
    +
  • + ))} +
+
+ + )} + + )} +
+
+ ); +} diff --git a/src/pages/GameLibrary.jsx b/src/pages/GameLibrary.jsx index 862add4..82dff1c 100644 --- a/src/pages/GameLibrary.jsx +++ b/src/pages/GameLibrary.jsx @@ -1,11 +1,18 @@ import {useCallback, useEffect, useState} from 'react'; -import {useNavigate, useSearchParams} from 'react-router-dom'; +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'; const PAGE_SIZES = [20, 40, 80]; const DEFAULT_SIZE = 20; +const DEFAULT_SORT = 'playtimeMinutes,desc'; +const SORT_OPTIONS = [ + {value: 'playtimeMinutes,desc', labelKey: 'LIBRARY.SORT_PLAYTIME_DESC'}, + {value: 'playtimeMinutes,asc', labelKey: 'LIBRARY.SORT_PLAYTIME_ASC'}, + {value: 'lastSeenAt,desc', labelKey: 'LIBRARY.SORT_LAST_SEEN'}, + {value: 'firstSeenAt,desc', labelKey: 'LIBRARY.SORT_FIRST_SEEN'}, +]; const iconUrl = (appid, hash) => hash @@ -26,6 +33,9 @@ export default function GameLibrary() { 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 sortParam = searchParams.get('sort') ?? DEFAULT_SORT; + const sort = SORT_OPTIONS.some((o) => o.value === sortParam) ? sortParam : DEFAULT_SORT; + const librarySearch = searchParams.toString(); const [data, setData] = useState({content: [], page: 0, size, totalElements: 0, totalPages: 0}); const [isLoading, setIsLoading] = useState(true); @@ -40,7 +50,7 @@ export default function GameLibrary() { setIsLoading(true); setError(null); try { - const url = `${import.meta.env.VITE_CORE_API_URL}/api/library/games?page=${page}&size=${size}`; + const url = `${import.meta.env.VITE_CORE_API_URL}/api/library/games?page=${page}&size=${size}&sort=${encodeURIComponent(sort)}`; const response = await fetch(url, { headers: { 'Authorization': `Bearer ${token}`, @@ -67,7 +77,7 @@ export default function GameLibrary() { } finally { setIsLoading(false); } - }, [navigate, page, size, t]); + }, [navigate, page, size, sort, t]); useEffect(() => { load(); @@ -90,11 +100,50 @@ export default function GameLibrary() { updateParams({size: event.target.value, page: 0}); }; + const onSortChange = (event) => { + updateParams({sort: event.target.value, page: 0}); + }; + const goToPage = (target) => { if (target < 0 || target >= data.totalPages) return; 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); + } + }; + return (
@@ -103,24 +152,53 @@ export default function GameLibrary() {

{t('LIBRARY.TITLE')}

- +
+ + + {syncMessage && ( + {syncMessage} + )} +
-
- - +
+
+ + +
+
+ + +
@@ -145,24 +223,31 @@ export default function GameLibrary() { {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)} - +
  • + + {icon ? ( + { e.currentTarget.style.visibility = 'hidden'; }} + /> + ) : ( +
    + )} + + {g.name ?? `App ${g.appid}`} + + + {formatPlaytime(g.playtimeMinutes)} + + +
  • ); })} diff --git a/src/pages/LandingPage.jsx b/src/pages/LandingPage.jsx index 847cf7f..c51d2b8 100644 --- a/src/pages/LandingPage.jsx +++ b/src/pages/LandingPage.jsx @@ -1,8 +1,20 @@ -import {useTranslation} from 'react-i18next'; +import {useTranslation} from 'react-i18next'; import {Link} from 'react-router-dom'; +import StatCard from '../components/StatCard.jsx'; + +import usePublicStats from '../hooks/usePublicStats.js'; + +const iconUrl = (appid, hash) => + hash + ? `https://cdn.cloudflare.steamstatic.com/steamcommunity/public/images/apps/${appid}/${hash}.jpg` + : null; export default function LandingPage() { const {t} = useTranslation(); + const {data, isLoading} = usePublicStats(); + + const auth = data.auth; + const core = data.core; return (
    @@ -17,6 +29,112 @@ export default function LandingPage() { {t('MAIN.GET_STARTED')} + +
    +

    + {t('LANDING.STATS_TITLE')} +

    + {isLoading ? ( +
    + {t('LANDING.STATS_LOADING')} +
    + ) : ( +
    + + + + + + + + +
    + )} + + {core?.topPlayedGames?.length > 0 && ( +
    +
    +

    + {t('LANDING.TOP_PLAYED')} +

    +
      + {core.topPlayedGames.map((g, idx) => ( +
    1. + {idx + 1} + {iconUrl(g.appid, g.imgIconUrl) ? ( + + ) : ( +
      + )} + + {g.name ?? `App ${g.appid}`} + + + {Math.round(g.value / 60).toLocaleString()}h + +
    2. + ))} +
    +
    +
    +

    + {t('LANDING.TOP_OWNED')} +

    +
      + {core.topOwnedGames.map((g, idx) => ( +
    1. + {idx + 1} + {iconUrl(g.appid, g.imgIconUrl) ? ( + + ) : ( +
      + )} + + {g.name ?? `App ${g.appid}`} + + + {g.value.toLocaleString()} + +
    2. + ))} +
    +
    +
    + )} +
    ); -} \ No newline at end of file +}