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} + + ) : ( + + {t('DASHBOARD.NO_STEAM_ID')} + + )} + + + + + {hasSteamId && ( + <> + + + + {t('LIBRARY.RESYNC')} + + > + )} + + {t('TILES.CUSTOMIZE')} + + + + ); +} 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')} + + + {t('DASHBOARD.SHOW_ALL')} + + + + {games.length === 0 ? ( + + {syncing ? t('LIBRARY.SYNCING_FIRST_TIME') : t('LIBRARY.EMPTY')} + + ) : ( + + {games.map((g, index) => ( + + + + {index + 1} + + + + + {g.name ?? `App ${g.appid}`} + + + + + + + {formatPlaytime(g.playtimeMinutes)} + + + + ))} + + )} + + ); +} 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} - - ) : ( - - {t('DASHBOARD.NO_STEAM_ID')} - - )} - - - - - {hasSteamId && ( - <> - - - - {t('LIBRARY.RESYNC')} - - > - )} - setEditing(true)}> - {t('TILES.CUSTOMIZE')} - - - + 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')} - - - {t('DASHBOARD.SHOW_ALL')} - - - - {games.length === 0 ? ( - - {syncing ? t('LIBRARY.SYNCING_FIRST_TIME') : t('LIBRARY.EMPTY')} - - ) : ( - - {games.map((g, index) => ( - - - - {index + 1} - - - - - {g.name ?? `App ${g.appid}`} - - - - - - - {formatPlaytime(g.playtimeMinutes)} - - - - ))} - - )} - - ); -} - 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 = {
{t('DASHBOARD.WELCOME_BACK')}
+ {syncing ? t('LIBRARY.SYNCING_FIRST_TIME') : t('LIBRARY.EMPTY')} +
- {syncing ? t('LIBRARY.SYNCING_FIRST_TIME') : t('LIBRARY.EMPTY')} -