Files
ToBePlayed-Frontend/src/pages/Dashboard.jsx
T
lobo 27c402fa34 feat(psn): replace PSN import button with sync review
Remove the PSN import tab; linking now starts a sync.
Show the PSN review link, last played date and PSN expiry notes.
PSN rows hide the hours field; unchecked titles you saw are dismissed,
unseen ones stay open.
2026-10-08 08:15:37 +02:00

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 hasSteamId && 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;