From 6da612bebdcd81f155a5f042c3017fc99879ffcc Mon Sep 17 00:00:00 2001 From: lobo Date: Wed, 30 Sep 2026 08:46:51 +0200 Subject: [PATCH] refactor(dashboard): load in useDashboardData, welcome and most played cards --- src/components/DashboardWelcomeCard.jsx | 69 ++++++++ src/components/MostPlayedCard.jsx | 62 +++++++ src/hooks/useDashboardData.js | 77 +++++++++ src/pages/Dashboard.jsx | 205 +++--------------------- src/utils/tiles.js | 3 + 5 files changed, 230 insertions(+), 186 deletions(-) create mode 100644 src/components/DashboardWelcomeCard.jsx create mode 100644 src/components/MostPlayedCard.jsx create mode 100644 src/hooks/useDashboardData.js diff --git a/src/components/DashboardWelcomeCard.jsx b/src/components/DashboardWelcomeCard.jsx new file mode 100644 index 0000000..acfa832 --- /dev/null +++ b/src/components/DashboardWelcomeCard.jsx @@ -0,0 +1,69 @@ +import {Link} from 'react-router-dom'; +import {useTranslation} from 'react-i18next'; +import {Hash, LayoutGrid, Link2, RefreshCw} from 'lucide-react'; + +import {Badge} from '@/components/ui/badge'; +import {Button} from '@/components/ui/button'; +import {Card} from '@/components/ui/card'; +import HeroBanner from './HeroBanner.jsx'; +import SteamAvatar from './SteamAvatar.jsx'; +import SyncStatusBadge from './SyncStatusBadge.jsx'; +import {useCurrentUserContext} from '../context/CurrentUserContext.jsx'; + +export default function DashboardWelcomeCard({bannerAppid, avatarUrl, sync, forceSync, editing, onCustomize}) { + const {t} = useTranslation(); + const {user: userData} = useCurrentUserContext(); + const hasSteamId = !!userData?.steamId; + const syncBusy = forceSync.busy || sync.isRunning; + + return ( + + {/* A picked game, else the game played most in the last two weeks. */} + +
+ {/* The slot is there before the avatar loads, so the title does not jump. */} + {hasSteamId && ( + + + + )} +
+

{t('DASHBOARD.WELCOME_BACK')}

+

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

+ {hasSteamId ? ( + + + {userData.steamId} + + ) : ( + + )} +
+
+ +
+ {hasSteamId && ( + <> + + + + )} + +
+
+ ); +} diff --git a/src/components/MostPlayedCard.jsx b/src/components/MostPlayedCard.jsx new file mode 100644 index 0000000..4b72bf4 --- /dev/null +++ b/src/components/MostPlayedCard.jsx @@ -0,0 +1,62 @@ +import {Link} from 'react-router-dom'; +import {useTranslation} from 'react-i18next'; +import {ArrowRight, Trophy} 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 MostPlayedCard({games, syncing}) { + const {t} = useTranslation(); + const maxMinutes = games[0]?.playtimeMinutes || 1; + return ( + +
+

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

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

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

+ ) : ( +
    + {games.map((g, index) => ( +
  1. + + + {index + 1} + + +
    +
    + {g.name ?? `App ${g.appid}`} +
    +
    +
    +
    +
    + + {formatPlaytime(g.playtimeMinutes)} + + +
  2. + ))} +
+ )} +
+ ); +} diff --git a/src/hooks/useDashboardData.js b/src/hooks/useDashboardData.js new file mode 100644 index 0000000..ee67de8 --- /dev/null +++ b/src/hooks/useDashboardData.js @@ -0,0 +1,77 @@ +import {useCallback, useEffect, useState} from 'react'; +import {useCurrentUserContext} from '../context/CurrentUserContext.jsx'; +import {request} from '../utils/api.js'; +import {PLAYING_GAMES, TOP_GAMES} from '../utils/tiles.js'; + +// 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_URL = '/api/library/games?page=0&size=20&sort=playtimeMinutes%2Cdesc&status=PLAYING'; + +// A card whose request fails or has no JSON body stays empty. +async function loadInto(path, set, pick = (data) => data) { + const {ok, data} = await request(`${import.meta.env.VITE_CORE_API_URL}${path}`); + if (ok && data != null) set(pick(data)); +} + +export default function useDashboardData() { + const {reload: reloadUser} = useCurrentUserContext(); + const [steamData, setSteamData] = useState({count: 0, hasSteamId: false}); + const [topGames, setTopGames] = useState([]); + const [playing, setPlaying] = useState([]); + const [summary, setSummary] = useState(null); + const [activity, setActivity] = useState(null); + const [achievements, setAchievements] = useState(null); + const [summaryFailed, setSummaryFailed] = useState(false); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchSteamCount = useCallback(() => loadInto('/api/library/games-count', setSteamData), []); + const fetchTopGames = useCallback(() => loadInto(TOP_GAMES_URL, setTopGames, (page) => (page.content ?? []).slice(0, TOP_GAMES.L)), []); + const fetchPlaying = useCallback(() => loadInto(PLAYING_URL, setPlaying, (page) => (page.content ?? []).slice(0, PLAYING_GAMES.L)), []); + const fetchActivity = useCallback(() => loadInto('/api/library/activity', setActivity), []); + const fetchAchievements = useCallback(() => loadInto('/api/library/achievements/summary', setAchievements), []); + + /** + * @param excludeAppid the current suggestion, so the new pick differs from it + * @returns false when the request failed; true on a 401, which ends the session without a "failed" message + */ + const fetchSummary = useCallback(async (excludeAppid) => { + const query = excludeAppid ? `?exclude=${excludeAppid}` : ''; + const {ok, status, data} = await request(`${import.meta.env.VITE_CORE_API_URL}/api/library/summary${query}`); + if (ok) setSummary(data); + setSummaryFailed(!ok && status !== 401); + return ok || status === 401; + }, []); + + const reloadLibrary = () => { + fetchSteamCount(); + fetchTopGames(); + fetchPlaying(); + fetchSummary(); + fetchActivity(); + fetchAchievements(); + }; + + useEffect(() => { + const fetchUserData = async () => { + const {ok, status, data} = await reloadUser(); + if (ok) { + // Games added by hand need no Steam ID; history and achievements come only from Steam. + await Promise.all([fetchSteamCount(), fetchTopGames(), fetchPlaying(), fetchSummary(), + ...(data.steamId ? [fetchActivity(), fetchAchievements()] : [])]); + } else if (status === 0) { + setError('INFO.ERROR.NETWORK_FAILURE'); + } else if (status !== 401) { + setError('INFO.ERROR.LOAD_PROFILE'); + } + setIsLoading(false); + }; + + fetchUserData(); + }, [reloadUser, fetchSteamCount, fetchTopGames, fetchPlaying, fetchSummary, fetchActivity, fetchAchievements]); + + return { + steamData, topGames, playing, summary, summaryFailed, activity, achievements, isLoading, error, + fetchSummary, reloadLibrary, + }; +} diff --git a/src/pages/Dashboard.jsx b/src/pages/Dashboard.jsx index 008166d..4c08fb3 100644 --- a/src/pages/Dashboard.jsx +++ b/src/pages/Dashboard.jsx @@ -1,10 +1,8 @@ -import {useCallback, useEffect, useState} from 'react'; -import {Link} from 'react-router-dom'; +import {useState} from 'react'; import {useTranslation} from 'react-i18next'; import {toast} from 'sonner'; -import {ArrowRight, Hash, LayoutGrid, Link2, RefreshCw, Trophy} from 'lucide-react'; +import {RefreshCw} 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'; @@ -12,52 +10,33 @@ import AchievementsCard from '../components/AchievementsCard.jsx'; import ActivityCard from '../components/ActivityCard.jsx'; import CurrentlyPlayingCard from '../components/CurrentlyPlayingCard.jsx'; import DashboardHint from '../components/DashboardHint.jsx'; -import GameCover from '../components/GameCover.jsx'; +import DashboardWelcomeCard from '../components/DashboardWelcomeCard.jsx'; +import MostPlayedCard from '../components/MostPlayedCard.jsx'; import PileOfShameCard from '../components/PileOfShameCard.jsx'; import SteamGameCount from '../components/SteamGameCount'; -import SyncStatusBadge from '../components/SyncStatusBadge.jsx'; +import useDashboardData from '../hooks/useDashboardData.js'; import useLibrarySync from '../hooks/useLibrarySync.js'; import useForceSync from '../hooks/useForceSync.js'; -import {formatPlaytime} from '../utils/games.js'; import PageContainer from '../components/PageContainer.jsx'; -import HeroBanner from '../components/HeroBanner.jsx'; -import SteamAvatar from '../components/SteamAvatar.jsx'; import useSteamProfile from '../hooks/useSteamProfile.js'; import useUserStats from '../hooks/useUserStats.js'; import useSaveResponse from '../hooks/useSaveResponse.js'; import TileGrid from '../components/TileGrid.jsx'; import statGroupTile from '../components/stats/statGroupTile.jsx'; -import {DASHBOARD_TILES, DASHBOARD_TITLES, DEFAULT_DASHBOARD, normalizeLayout, saveTiles, statGroup} from '../utils/tiles.js'; +import { + DASHBOARD_TILES, DASHBOARD_TITLES, DEFAULT_DASHBOARD, normalizeLayout, PLAYING_GAMES, saveTiles, statGroup, TOP_GAMES, +} from '../utils/tiles.js'; import {usePreferences} from '../context/PreferencesContext.jsx'; import {useCurrentUserContext} from '../context/CurrentUserContext.jsx'; -import {request} from '../utils/api.js'; - -// Games per tile size; the page loads the most for the large tile. -const TOP_GAMES = {S: 3, M: 5, L: 10}; -const PLAYING_GAMES = {S: 2, M: 4, L: 8}; -// 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_URL = '/api/library/games?page=0&size=20&sort=playtimeMinutes%2Cdesc&status=PLAYING'; - -// A card whose request fails or has no JSON body stays empty. -async function loadInto(path, set, pick = (data) => data) { - const {ok, data} = await request(`${import.meta.env.VITE_CORE_API_URL}${path}`); - if (ok && data != null) set(pick(data)); -} const Dashboard = () => { const {t} = useTranslation(); - const {user: userData, updateUser, reload: reloadUser} = useCurrentUserContext(); - const [steamData, setSteamData] = useState({count: 0, hasSteamId: false}); - const [topGames, setTopGames] = useState([]); - const [playing, setPlaying] = useState([]); - const [summary, setSummary] = useState(null); - const [activity, setActivity] = useState(null); - const [achievements, setAchievements] = useState(null); - const [summaryFailed, setSummaryFailed] = useState(false); + const {user: userData, updateUser} = useCurrentUserContext(); + const { + steamData, topGames, playing, summary, summaryFailed, activity, achievements, isLoading, error, + fetchSummary, reloadLibrary, + } = useDashboardData(); const [shuffling, setShuffling] = useState(false); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); const [editing, setEditing] = useState(false); const runSave = useSaveResponse(); const layout = normalizeLayout(userData?.settings?.dashboardTiles, DASHBOARD_TILES, DEFAULT_DASHBOARD); @@ -69,25 +48,6 @@ const Dashboard = () => { // No groups would mean "all groups" to the stats endpoint. const hasStatGroups = statGroups.length > 0; const stats = useUserStats(statGroups, userData != null && hasStatGroups); - const reloadStats = stats.reload; - - const fetchSteamCount = useCallback(() => loadInto('/api/library/games-count', setSteamData), []); - const fetchTopGames = useCallback(() => loadInto(TOP_GAMES_URL, setTopGames, (page) => (page.content ?? []).slice(0, TOP_GAMES.L)), []); - const fetchPlaying = useCallback(() => loadInto(PLAYING_URL, setPlaying, (page) => (page.content ?? []).slice(0, PLAYING_GAMES.L)), []); - const fetchActivity = useCallback(() => loadInto('/api/library/activity', setActivity), []); - const fetchAchievements = useCallback(() => loadInto('/api/library/achievements/summary', setAchievements), []); - - /** - * @param excludeAppid the current suggestion, so the new pick differs from it - * @returns false when the request failed; true on a 401, which ends the session without a "failed" message - */ - const fetchSummary = useCallback(async (excludeAppid) => { - const query = excludeAppid ? `?exclude=${excludeAppid}` : ''; - const {ok, status, data} = await request(`${import.meta.env.VITE_CORE_API_URL}/api/library/summary${query}`); - if (ok) setSummary(data); - setSummaryFailed(!ok && status !== 401); - return ok || status === 401; - }, []); const shuffleSuggestion = async () => { setShuffling(true); @@ -97,34 +57,6 @@ const Dashboard = () => { setShuffling(false); }; - const reloadLibrary = useCallback(() => { - fetchSteamCount(); - fetchTopGames(); - fetchPlaying(); - fetchSummary(); - fetchActivity(); - fetchAchievements(); - if (hasStatGroups) reloadStats(); - }, [fetchSteamCount, fetchTopGames, fetchPlaying, fetchSummary, fetchActivity, fetchAchievements, hasStatGroups, reloadStats]); - - useEffect(() => { - const fetchUserData = async () => { - const {ok, status, data} = await reloadUser(); - if (ok) { - // Games added by hand need no Steam ID; history and achievements come only from Steam. - await Promise.all([fetchSteamCount(), fetchTopGames(), fetchPlaying(), fetchSummary(), - ...(data.steamId ? [fetchActivity(), fetchAchievements()] : [])]); - } else if (status === 0) { - setError('INFO.ERROR.NETWORK_FAILURE'); - } else if (status !== 401) { - setError('INFO.ERROR.LOAD_PROFILE'); - } - setIsLoading(false); - }; - - fetchUserData(); - }, [reloadUser, fetchSteamCount, fetchTopGames, fetchPlaying, fetchSummary, fetchActivity, fetchAchievements]); - const hasSteamId = !!userData?.steamId; const showLibrary = hasSteamId || steamData.count > 0; const {appearance} = usePreferences(); @@ -135,7 +67,10 @@ const Dashboard = () => { const sync = useLibrarySync({ enabled: hasSteamId, expectInitialSync: hasSteamId && steamData.count === 0, - onComplete: reloadLibrary, + onComplete: () => { + reloadLibrary(); + if (hasStatGroups) stats.reload(); + }, }); const forceSync = useForceSync(sync.watch); @@ -151,8 +86,6 @@ const Dashboard = () => { ); } - const syncBusy = forceSync.busy || sync.isRunning; - const saveLayout = async (next) => { // The default is saved as "never changed", so a later change of the default reaches this user too. const tiles = JSON.stringify(next) === JSON.stringify(DEFAULT_DASHBOARD) ? null : next; @@ -192,54 +125,8 @@ const Dashboard = () => { return ( - - {/* A picked game, else the game played most in the last two weeks. */} - -
- {/* The slot is there before the avatar loads, so the title does not jump. */} - {hasSteamId && ( - - - - )} -
-

{t('DASHBOARD.WELCOME_BACK')}

-

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

- {hasSteamId ? ( - - - {userData.steamId} - - ) : ( - - )} -
-
- -
- {hasSteamId && ( - <> - - - - )} - -
-
+ setEditing(true)}/> setEditing(false)}/> @@ -249,60 +136,6 @@ const Dashboard = () => { ); }; -function MostPlayedCard({games, syncing}) { - const {t} = useTranslation(); - const maxMinutes = games[0]?.playtimeMinutes || 1; - return ( - -
-

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

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

- {syncing ? t('LIBRARY.SYNCING_FIRST_TIME') : t('LIBRARY.EMPTY')} -

- ) : ( -
    - {games.map((g, index) => ( -
  1. - - - {index + 1} - - -
    -
    - {g.name ?? `App ${g.appid}`} -
    -
    -
    -
    -
    - - {formatPlaytime(g.playtimeMinutes)} - - -
  2. - ))} -
- )} -
- ); -} - function DashboardSkeleton() { return ( diff --git a/src/utils/tiles.js b/src/utils/tiles.js index 79ea521..e800c75 100644 --- a/src/utils/tiles.js +++ b/src/utils/tiles.js @@ -57,6 +57,9 @@ export const DEFAULT_DASHBOARD = all(['GAME_COUNT', 'MOST_PLAYED', 'PLAYING', 'P export const DEFAULT_PROFILE = all(['SUMMARY', 'STATUS', 'PLAYING', 'FINISHED']); export const SIZES = ['S', 'M', 'L']; +// Games per dashboard tile size; the dashboard loads the most for the large tile. +export const TOP_GAMES = {S: 3, M: 5, L: 10}; +export const PLAYING_GAMES = {S: 2, M: 4, L: 8}; // Written out, so Tailwind finds every class. const SPANS = {