Public Access
refactor(dashboard): load in useDashboardData, welcome and most played cards
This commit is contained in:
1 parent
05f4ca42e5
commit
6da612bebd
5 files changed
+230
-186
No files matched your search
@@ -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 (
|
||||||
|
<Card className="relative overflow-hidden p-6 md:p-8 flex flex-col md:flex-row md:items-center md:justify-between gap-6">
|
||||||
|
{/* A picked game, else the game played most in the last two weeks. */}
|
||||||
|
<HeroBanner appid={bannerAppid}/>
|
||||||
|
<div className="relative flex min-w-0 items-center gap-4">
|
||||||
|
{/* The slot is there before the avatar loads, so the title does not jump. */}
|
||||||
|
{hasSteamId && (
|
||||||
|
<span className="flex size-16 shrink-0 items-center justify-center rounded-full bg-muted ring-2 ring-primary/60">
|
||||||
|
<SteamAvatar url={avatarUrl} className="size-16" iconClassName="size-8 text-muted-foreground"/>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">{t('DASHBOARD.WELCOME_BACK')}</p>
|
||||||
|
<h1 className="mt-1 text-3xl md:text-4xl font-bold tracking-tight truncate">
|
||||||
|
{t('DASHBOARD.HELLO')} {userData?.displayName ?? userData?.email?.split('@')[0]} 👋
|
||||||
|
</h1>
|
||||||
|
{hasSteamId ? (
|
||||||
|
<Badge variant="outline" className="mt-3 gap-1.5 font-mono text-muted-foreground">
|
||||||
|
<Hash className="size-3"/>
|
||||||
|
{userData.steamId}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Button asChild variant="link" className="mt-2 h-auto p-0">
|
||||||
|
<Link to="/settings"><Link2 className="size-4"/>{t('DASHBOARD.NO_STEAM_ID')}</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative flex flex-col items-start md:items-end gap-3 shrink-0">
|
||||||
|
{hasSteamId && (
|
||||||
|
<>
|
||||||
|
<SyncStatusBadge
|
||||||
|
status={sync.status}
|
||||||
|
lastSyncedAt={sync.lastSyncedAt}
|
||||||
|
isRunning={sync.isRunning}
|
||||||
|
stale={sync.stale}
|
||||||
|
/>
|
||||||
|
<Button onClick={forceSync.trigger} disabled={syncBusy}>
|
||||||
|
<RefreshCw className={syncBusy ? 'animate-spin' : ''}/>
|
||||||
|
{t('LIBRARY.RESYNC')}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Button variant="ghost" size="sm" disabled={editing} onClick={onCustomize}>
|
||||||
|
<LayoutGrid/>{t('TILES.CUSTOMIZE')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Card className="gap-0 py-0">
|
||||||
|
<div className="flex items-center justify-between px-5 py-4 border-b">
|
||||||
|
<h2 className="flex items-center gap-2 font-semibold">
|
||||||
|
<Trophy className="size-4 text-amber-500"/>
|
||||||
|
{t('DASHBOARD.MOST_PLAYED')}
|
||||||
|
</h2>
|
||||||
|
<Button asChild variant="ghost" size="sm">
|
||||||
|
<Link to="/library">{t('DASHBOARD.SHOW_ALL')}<ArrowRight/></Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{games.length === 0 ? (
|
||||||
|
<p className="px-5 py-10 text-center text-sm text-muted-foreground">
|
||||||
|
{syncing ? t('LIBRARY.SYNCING_FIRST_TIME') : t('LIBRARY.EMPTY')}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ol className="divide-y">
|
||||||
|
{games.map((g, index) => (
|
||||||
|
<li key={g.appid}>
|
||||||
|
<Link
|
||||||
|
to={`/library/${g.appid}`}
|
||||||
|
className="group flex items-center gap-3 sm:gap-4 px-4 sm:px-5 py-3 transition-colors hover:bg-muted/50"
|
||||||
|
>
|
||||||
|
<span className="w-4 text-right text-sm font-semibold text-muted-foreground tabular-nums">
|
||||||
|
{index + 1}
|
||||||
|
</span>
|
||||||
|
<GameCover appid={g.appid} imageUrl={g.imageUrl} className="w-16 sm:w-24 shrink-0"/>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="truncate font-medium group-hover:text-primary transition-colors">
|
||||||
|
{g.name ?? `App ${g.appid}`}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 h-1.5 rounded-full bg-muted">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-primary"
|
||||||
|
style={{width: `${Math.max(2, (g.playtimeMinutes / maxMinutes) * 100)}%`}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="w-12 sm:w-14 text-right text-sm font-medium text-muted-foreground tabular-nums">
|
||||||
|
{formatPlaytime(g.playtimeMinutes)}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
+19
-186
@@ -1,10 +1,8 @@
|
|||||||
import {useCallback, useEffect, useState} from 'react';
|
import {useState} from 'react';
|
||||||
import {Link} from 'react-router-dom';
|
|
||||||
import {useTranslation} from 'react-i18next';
|
import {useTranslation} from 'react-i18next';
|
||||||
import {toast} from 'sonner';
|
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 {Button} from '@/components/ui/button';
|
||||||
import {Card} from '@/components/ui/card';
|
import {Card} from '@/components/ui/card';
|
||||||
import {Skeleton} from '@/components/ui/skeleton';
|
import {Skeleton} from '@/components/ui/skeleton';
|
||||||
@@ -12,52 +10,33 @@ import AchievementsCard from '../components/AchievementsCard.jsx';
|
|||||||
import ActivityCard from '../components/ActivityCard.jsx';
|
import ActivityCard from '../components/ActivityCard.jsx';
|
||||||
import CurrentlyPlayingCard from '../components/CurrentlyPlayingCard.jsx';
|
import CurrentlyPlayingCard from '../components/CurrentlyPlayingCard.jsx';
|
||||||
import DashboardHint from '../components/DashboardHint.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 PileOfShameCard from '../components/PileOfShameCard.jsx';
|
||||||
import SteamGameCount from '../components/SteamGameCount';
|
import SteamGameCount from '../components/SteamGameCount';
|
||||||
import SyncStatusBadge from '../components/SyncStatusBadge.jsx';
|
import useDashboardData from '../hooks/useDashboardData.js';
|
||||||
import useLibrarySync from '../hooks/useLibrarySync.js';
|
import useLibrarySync from '../hooks/useLibrarySync.js';
|
||||||
import useForceSync from '../hooks/useForceSync.js';
|
import useForceSync from '../hooks/useForceSync.js';
|
||||||
import {formatPlaytime} from '../utils/games.js';
|
|
||||||
import PageContainer from '../components/PageContainer.jsx';
|
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 useSteamProfile from '../hooks/useSteamProfile.js';
|
||||||
import useUserStats from '../hooks/useUserStats.js';
|
import useUserStats from '../hooks/useUserStats.js';
|
||||||
import useSaveResponse from '../hooks/useSaveResponse.js';
|
import useSaveResponse from '../hooks/useSaveResponse.js';
|
||||||
import TileGrid from '../components/TileGrid.jsx';
|
import TileGrid from '../components/TileGrid.jsx';
|
||||||
import statGroupTile from '../components/stats/statGroupTile.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 {usePreferences} from '../context/PreferencesContext.jsx';
|
||||||
import {useCurrentUserContext} from '../context/CurrentUserContext.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 Dashboard = () => {
|
||||||
const {t} = useTranslation();
|
const {t} = useTranslation();
|
||||||
const {user: userData, updateUser, reload: reloadUser} = useCurrentUserContext();
|
const {user: userData, updateUser} = useCurrentUserContext();
|
||||||
const [steamData, setSteamData] = useState({count: 0, hasSteamId: false});
|
const {
|
||||||
const [topGames, setTopGames] = useState([]);
|
steamData, topGames, playing, summary, summaryFailed, activity, achievements, isLoading, error,
|
||||||
const [playing, setPlaying] = useState([]);
|
fetchSummary, reloadLibrary,
|
||||||
const [summary, setSummary] = useState(null);
|
} = useDashboardData();
|
||||||
const [activity, setActivity] = useState(null);
|
|
||||||
const [achievements, setAchievements] = useState(null);
|
|
||||||
const [summaryFailed, setSummaryFailed] = useState(false);
|
|
||||||
const [shuffling, setShuffling] = useState(false);
|
const [shuffling, setShuffling] = useState(false);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const [error, setError] = useState(null);
|
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const runSave = useSaveResponse();
|
const runSave = useSaveResponse();
|
||||||
const layout = normalizeLayout(userData?.settings?.dashboardTiles, DASHBOARD_TILES, DEFAULT_DASHBOARD);
|
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.
|
// No groups would mean "all groups" to the stats endpoint.
|
||||||
const hasStatGroups = statGroups.length > 0;
|
const hasStatGroups = statGroups.length > 0;
|
||||||
const stats = useUserStats(statGroups, userData != null && hasStatGroups);
|
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 () => {
|
const shuffleSuggestion = async () => {
|
||||||
setShuffling(true);
|
setShuffling(true);
|
||||||
@@ -97,34 +57,6 @@ const Dashboard = () => {
|
|||||||
setShuffling(false);
|
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 hasSteamId = !!userData?.steamId;
|
||||||
const showLibrary = hasSteamId || steamData.count > 0;
|
const showLibrary = hasSteamId || steamData.count > 0;
|
||||||
const {appearance} = usePreferences();
|
const {appearance} = usePreferences();
|
||||||
@@ -135,7 +67,10 @@ const Dashboard = () => {
|
|||||||
const sync = useLibrarySync({
|
const sync = useLibrarySync({
|
||||||
enabled: hasSteamId,
|
enabled: hasSteamId,
|
||||||
expectInitialSync: hasSteamId && steamData.count === 0,
|
expectInitialSync: hasSteamId && steamData.count === 0,
|
||||||
onComplete: reloadLibrary,
|
onComplete: () => {
|
||||||
|
reloadLibrary();
|
||||||
|
if (hasStatGroups) stats.reload();
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const forceSync = useForceSync(sync.watch);
|
const forceSync = useForceSync(sync.watch);
|
||||||
|
|
||||||
@@ -151,8 +86,6 @@ const Dashboard = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const syncBusy = forceSync.busy || sync.isRunning;
|
|
||||||
|
|
||||||
const saveLayout = async (next) => {
|
const saveLayout = async (next) => {
|
||||||
// The default is saved as "never changed", so a later change of the default reaches this user too.
|
// 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;
|
const tiles = JSON.stringify(next) === JSON.stringify(DEFAULT_DASHBOARD) ? null : next;
|
||||||
@@ -192,54 +125,8 @@ const Dashboard = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContainer className="p-4 md:p-8 space-y-6">
|
<PageContainer className="p-4 md:p-8 space-y-6">
|
||||||
<Card className="relative overflow-hidden p-6 md:p-8 flex flex-col md:flex-row md:items-center md:justify-between gap-6">
|
<DashboardWelcomeCard bannerAppid={bannerAppid} avatarUrl={steamProfile?.avatarUrl}
|
||||||
{/* A picked game, else the game played most in the last two weeks. */}
|
sync={sync} forceSync={forceSync} editing={editing} onCustomize={() => setEditing(true)}/>
|
||||||
<HeroBanner appid={bannerAppid}/>
|
|
||||||
<div className="relative flex min-w-0 items-center gap-4">
|
|
||||||
{/* The slot is there before the avatar loads, so the title does not jump. */}
|
|
||||||
{hasSteamId && (
|
|
||||||
<span className="flex size-16 shrink-0 items-center justify-center rounded-full bg-muted ring-2 ring-primary/60">
|
|
||||||
<SteamAvatar url={steamProfile?.avatarUrl} className="size-16" iconClassName="size-8 text-muted-foreground"/>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="text-sm font-medium text-muted-foreground">{t('DASHBOARD.WELCOME_BACK')}</p>
|
|
||||||
<h1 className="mt-1 text-3xl md:text-4xl font-bold tracking-tight truncate">
|
|
||||||
{t('DASHBOARD.HELLO')} {userData?.displayName ?? userData?.email?.split('@')[0]} 👋
|
|
||||||
</h1>
|
|
||||||
{hasSteamId ? (
|
|
||||||
<Badge variant="outline" className="mt-3 gap-1.5 font-mono text-muted-foreground">
|
|
||||||
<Hash className="size-3"/>
|
|
||||||
{userData.steamId}
|
|
||||||
</Badge>
|
|
||||||
) : (
|
|
||||||
<Button asChild variant="link" className="mt-2 h-auto p-0">
|
|
||||||
<Link to="/settings"><Link2 className="size-4"/>{t('DASHBOARD.NO_STEAM_ID')}</Link>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative flex flex-col items-start md:items-end gap-3 shrink-0">
|
|
||||||
{hasSteamId && (
|
|
||||||
<>
|
|
||||||
<SyncStatusBadge
|
|
||||||
status={sync.status}
|
|
||||||
lastSyncedAt={sync.lastSyncedAt}
|
|
||||||
isRunning={sync.isRunning}
|
|
||||||
stale={sync.stale}
|
|
||||||
/>
|
|
||||||
<Button onClick={forceSync.trigger} disabled={syncBusy}>
|
|
||||||
<RefreshCw className={syncBusy ? 'animate-spin' : ''}/>
|
|
||||||
{t('LIBRARY.RESYNC')}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<Button variant="ghost" size="sm" disabled={editing} onClick={() => setEditing(true)}>
|
|
||||||
<LayoutGrid/>{t('TILES.CUSTOMIZE')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<TileGrid tiles={DASHBOARD_TILES} titles={DASHBOARD_TITLES} layout={layout} renderTile={renderTile} editing={editing}
|
<TileGrid tiles={DASHBOARD_TILES} titles={DASHBOARD_TITLES} layout={layout} renderTile={renderTile} editing={editing}
|
||||||
defaultLayout={DEFAULT_DASHBOARD} onSave={saveLayout} onCancel={() => setEditing(false)}/>
|
defaultLayout={DEFAULT_DASHBOARD} onSave={saveLayout} onCancel={() => setEditing(false)}/>
|
||||||
@@ -249,60 +136,6 @@ const Dashboard = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
function MostPlayedCard({games, syncing}) {
|
|
||||||
const {t} = useTranslation();
|
|
||||||
const maxMinutes = games[0]?.playtimeMinutes || 1;
|
|
||||||
return (
|
|
||||||
<Card className="gap-0 py-0">
|
|
||||||
<div className="flex items-center justify-between px-5 py-4 border-b">
|
|
||||||
<h2 className="flex items-center gap-2 font-semibold">
|
|
||||||
<Trophy className="size-4 text-amber-500"/>
|
|
||||||
{t('DASHBOARD.MOST_PLAYED')}
|
|
||||||
</h2>
|
|
||||||
<Button asChild variant="ghost" size="sm">
|
|
||||||
<Link to="/library">{t('DASHBOARD.SHOW_ALL')}<ArrowRight/></Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{games.length === 0 ? (
|
|
||||||
<p className="px-5 py-10 text-center text-sm text-muted-foreground">
|
|
||||||
{syncing ? t('LIBRARY.SYNCING_FIRST_TIME') : t('LIBRARY.EMPTY')}
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<ol className="divide-y">
|
|
||||||
{games.map((g, index) => (
|
|
||||||
<li key={g.appid}>
|
|
||||||
<Link
|
|
||||||
to={`/library/${g.appid}`}
|
|
||||||
className="group flex items-center gap-3 sm:gap-4 px-4 sm:px-5 py-3 transition-colors hover:bg-muted/50"
|
|
||||||
>
|
|
||||||
<span className="w-4 text-right text-sm font-semibold text-muted-foreground tabular-nums">
|
|
||||||
{index + 1}
|
|
||||||
</span>
|
|
||||||
<GameCover appid={g.appid} imageUrl={g.imageUrl} className="w-16 sm:w-24 shrink-0"/>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="truncate font-medium group-hover:text-primary transition-colors">
|
|
||||||
{g.name ?? `App ${g.appid}`}
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 h-1.5 rounded-full bg-muted">
|
|
||||||
<div
|
|
||||||
className="h-full rounded-full bg-primary"
|
|
||||||
style={{width: `${Math.max(2, (g.playtimeMinutes / maxMinutes) * 100)}%`}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<span className="w-12 sm:w-14 text-right text-sm font-medium text-muted-foreground tabular-nums">
|
|
||||||
{formatPlaytime(g.playtimeMinutes)}
|
|
||||||
</span>
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ol>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DashboardSkeleton() {
|
function DashboardSkeleton() {
|
||||||
return (
|
return (
|
||||||
<PageContainer className="p-4 md:p-8 space-y-6">
|
<PageContainer className="p-4 md:p-8 space-y-6">
|
||||||
|
|||||||
@@ -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 DEFAULT_PROFILE = all(['SUMMARY', 'STATUS', 'PLAYING', 'FINISHED']);
|
||||||
|
|
||||||
export const SIZES = ['S', 'M', 'L'];
|
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.
|
// Written out, so Tailwind finds every class.
|
||||||
const SPANS = {
|
const SPANS = {
|
||||||
|
|||||||
Reference in new issue
Block a user