Public Access
Detail page groups achievements by set: Steam, then PSN trophies. Tiles show the real count; a perfect game gets an amber trophy. Dashboard card shows for PSN-only users; CSP allows PSN icons.
169 lines
8.1 KiB
React
169 lines
8.1 KiB
React
import {useEffect, useState} from 'react';
|
|
import {useTranslation} from 'react-i18next';
|
|
import {toast} from 'sonner';
|
|
import {RefreshCw} from 'lucide-react';
|
|
|
|
import {Button} from '@/components/ui/button';
|
|
import {Card} from '@/components/ui/card';
|
|
import {Skeleton} from '@/components/ui/skeleton';
|
|
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 DashboardWelcomeCard from '../components/DashboardWelcomeCard.jsx';
|
|
import MostPlayedCard from '../components/MostPlayedCard.jsx';
|
|
import PileOfShameCard from '../components/PileOfShameCard.jsx';
|
|
import SteamGameCount from '../components/SteamGameCount';
|
|
import UpNextCard from '../components/UpNextCard.jsx';
|
|
import SyncChangesBanner from '../components/SyncChangesBanner.jsx';
|
|
import WhatsNewDialog from '../components/WhatsNewDialog.jsx';
|
|
import useDashboardData from '../hooks/useDashboardData.js';
|
|
import useLibrarySync from '../hooks/useLibrarySync.js';
|
|
import useForceSync from '../hooks/useForceSync.js';
|
|
import PageContainer from '../components/PageContainer.jsx';
|
|
import useSteamProfile from '../hooks/useSteamProfile.js';
|
|
import useUserStats from '../hooks/useUserStats.js';
|
|
import useSaveResponse from '../hooks/useSaveResponse.js';
|
|
import useStatCharts from '../hooks/useStatCharts.js';
|
|
import {chartProps} from '../utils/statCharts.js';
|
|
import TileGrid from '../components/TileGrid.jsx';
|
|
import {StatDrillProvider} from '../components/stats/StatDrill.jsx';
|
|
import statGroupTile from '../components/stats/statGroupTile.jsx';
|
|
import {
|
|
DASHBOARD_TILES, DASHBOARD_TITLES, DEFAULT_DASHBOARD, normalizeLayout, PLAYING_GAMES, saveTiles, statGroup, TOP_GAMES, UP_NEXT_GAMES,
|
|
} from '../utils/tiles.js';
|
|
import {usePreferences} from '../context/PreferencesContext.jsx';
|
|
import {useCurrentUserContext} from '../context/CurrentUserContext.jsx';
|
|
|
|
const Dashboard = () => {
|
|
const {t} = useTranslation();
|
|
const {user: userData, updateUser} = useCurrentUserContext();
|
|
const {
|
|
steamData, topGames, playing, upNext, summary, summaryFailed, activity, achievements, isLoading, error,
|
|
fetchSummary, reloadLibrary,
|
|
} = useDashboardData();
|
|
const [shuffling, setShuffling] = useState(false);
|
|
const [editing, setEditing] = useState(false);
|
|
const runSave = useSaveResponse();
|
|
const {charts, change} = useStatCharts();
|
|
const layout = normalizeLayout(userData?.settings?.dashboardTiles, DASHBOARD_TILES, DEFAULT_DASHBOARD);
|
|
// The stat groups of the shown stat tiles, and TREND for the pile card and the "pile grew" hint. Waits for the
|
|
// saved layout in /me.
|
|
const statGroups = [...new Set([...layout.map((tile) => statGroup(tile.id)).filter(Boolean), 'TREND'])].sort();
|
|
const stats = useUserStats(statGroups, userData != null);
|
|
|
|
const shuffleSuggestion = async () => {
|
|
setShuffling(true);
|
|
if (!await fetchSummary(summary?.suggestion?.appid)) {
|
|
toast.error(t('DASHBOARD.SUMMARY_FAILED'));
|
|
}
|
|
setShuffling(false);
|
|
};
|
|
|
|
const hasSteamId = !!userData?.steamId;
|
|
// A PlayStation link syncs too, also without a Steam ID.
|
|
const canSync = hasSteamId || !!steamData.canSync;
|
|
const showLibrary = canSync || steamData.count > 0;
|
|
const {appearance, equipped} = usePreferences();
|
|
const steamProfile = useSteamProfile(hasSteamId);
|
|
const bannerAppid = appearance.showBanner
|
|
? appearance.bannerAppid ?? activity?.recentGames?.[0]?.appid ?? null
|
|
: null;
|
|
const sync = useLibrarySync({
|
|
enabled: canSync,
|
|
expectInitialSync: canSync && steamData.count === 0,
|
|
onComplete: () => {
|
|
reloadLibrary();
|
|
stats.reload();
|
|
},
|
|
});
|
|
const {watch} = sync;
|
|
const forceSync = useForceSync(watch);
|
|
useEffect(() => {
|
|
if (steamData.syncStarted) watch();
|
|
}, [steamData, watch]);
|
|
|
|
if (isLoading) {
|
|
return <DashboardSkeleton/>;
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<div className="py-24 text-center text-destructive">
|
|
{t(error)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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;
|
|
if (!await runSave(() => saveTiles('dashboard-tiles', tiles))) return;
|
|
updateUser((d) => ({...d, settings: {...d.settings, dashboardTiles: tiles}}));
|
|
setEditing(false);
|
|
};
|
|
|
|
const renderTile = (id, size) => {
|
|
switch (id) {
|
|
case 'GAME_COUNT': return showLibrary && <SteamGameCount targetCount={steamData.count}/>;
|
|
case 'MOST_PLAYED':
|
|
return showLibrary && <MostPlayedCard games={topGames.slice(0, TOP_GAMES[size])} syncing={sync.isRunning}
|
|
equipped={equipped} {...chartProps(charts, change, 'TOP_GAMES')}/>;
|
|
case 'PLAYING':
|
|
return showLibrary && (
|
|
<CurrentlyPlayingCard games={playing.slice(0, PLAYING_GAMES[size])} syncing={sync.isRunning} wide={size === 'L'}/>
|
|
);
|
|
case 'UP_NEXT': return showLibrary && <UpNextCard games={upNext.slice(0, UP_NEXT_GAMES[size])}/>;
|
|
case 'PILE_OF_SHAME':
|
|
if (!showLibrary) return null;
|
|
if (summary) {
|
|
return <PileOfShameCard summary={summary} trend={stats.stats?.trend} syncing={sync.isRunning}
|
|
onShuffle={shuffleSuggestion} shuffling={shuffling}/>;
|
|
}
|
|
return summaryFailed && (
|
|
<Card className="items-center justify-center gap-3 p-6 text-center text-sm text-muted-foreground">
|
|
{t('DASHBOARD.SUMMARY_FAILED')}
|
|
<Button variant="outline" size="sm" onClick={() => fetchSummary()}>
|
|
<RefreshCw/>{t('DASHBOARD.RETRY')}
|
|
</Button>
|
|
</Card>
|
|
);
|
|
case 'ACTIVITY': return canSync && activity && <ActivityCard activity={activity} charts={charts} onChart={change}/>;
|
|
case 'ACHIEVEMENTS': return canSync && achievements && <AchievementsCard summary={achievements}/>;
|
|
default: return statGroupTile(id, stats.stats, true, charts, change);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<StatDrillProvider source="own">
|
|
<PageContainer className="p-4 md:p-8 space-y-6">
|
|
<DashboardWelcomeCard bannerAppid={bannerAppid} avatarUrl={steamProfile?.avatarUrl}
|
|
equipped={equipped}
|
|
sync={sync} forceSync={forceSync} canSync={canSync} editing={editing} onCustomize={() => setEditing(true)}/>
|
|
{canSync && <SyncChangesBanner lastSyncedAt={sync.lastSyncedAt}/>}
|
|
|
|
<TileGrid tiles={DASHBOARD_TILES} titles={DASHBOARD_TITLES} layout={layout} renderTile={renderTile} editing={editing}
|
|
defaultLayout={DEFAULT_DASHBOARD} onSave={saveLayout} onCancel={() => setEditing(false)}/>
|
|
<WhatsNewDialog/>
|
|
{/* Last child: space-y gives every other child a bottom margin, which would move the fixed card. */}
|
|
<DashboardHint user={userData} canSync={canSync} summary={summaryFailed ? null : summary ?? undefined}
|
|
trend={stats.failed ? null : stats.stats ? stats.stats.trend ?? null : undefined}/>
|
|
</PageContainer>
|
|
</StatDrillProvider>
|
|
);
|
|
};
|
|
|
|
function DashboardSkeleton() {
|
|
return (
|
|
<PageContainer className="p-4 md:p-8 space-y-6">
|
|
<Skeleton className="h-40 rounded-xl"/>
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
|
<Skeleton className="h-64 rounded-xl"/>
|
|
<Skeleton className="h-64 rounded-xl md:col-span-2"/>
|
|
</div>
|
|
</PageContainer>
|
|
);
|
|
}
|
|
|
|
export default Dashboard;
|