Public Access
feat(tiles): let users arrange the dashboard and profile tiles
This commit is contained in:
1 parent
b55fcfec92
commit
aa9e83e0cb
24 files changed
+792
-426
No files matched your search
+135
-98
@@ -2,7 +2,7 @@ import React, {useCallback, useEffect, useState} from 'react';
|
||||
import {Link, useNavigate} from 'react-router-dom';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {toast} from 'sonner';
|
||||
import {ArrowRight, Hash, Link2, RefreshCw, Trophy} from 'lucide-react';
|
||||
import {ArrowRight, Hash, LayoutGrid, Link2, RefreshCw, Trophy} from 'lucide-react';
|
||||
|
||||
import {Badge} from '@/components/ui/badge';
|
||||
import {Button} from '@/components/ui/button';
|
||||
@@ -25,18 +25,22 @@ 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 {usePreferences} from '../context/PreferencesContext.jsx';
|
||||
import {apiFetch} from '../utils/api.js';
|
||||
import {hasSession} from '../utils/auth.js';
|
||||
|
||||
const TOP_GAMES = 5;
|
||||
// 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_GAMES = 4;
|
||||
const PLAYING_URL = '/api/library/games?page=0&size=20&sort=playtimeMinutes%2Cdesc&status=PLAYING';
|
||||
|
||||
const JSON_HEADERS = {'Content-Type': 'application/json'};
|
||||
const TREND_GROUPS = ['TREND'];
|
||||
|
||||
const Dashboard = () => {
|
||||
const {t} = useTranslation();
|
||||
@@ -53,8 +57,18 @@ const Dashboard = () => {
|
||||
const [shuffling, setShuffling] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const pileTrend = useUserStats(TREND_GROUPS, expireSession, hasSession());
|
||||
const reloadTrend = pileTrend.reload;
|
||||
const [editing, setEditing] = useState(false);
|
||||
const runSave = useSaveResponse();
|
||||
const layout = normalizeLayout(userData?.settings?.dashboardTiles, DASHBOARD_TILES, DEFAULT_DASHBOARD);
|
||||
// The stat groups of the shown stat tiles, and TREND for the pile card. Waits for the saved layout in /me.
|
||||
const statGroups = [...new Set([
|
||||
...layout.map((tile) => statGroup(tile.id)).filter(Boolean),
|
||||
...(layout.some((tile) => tile.id === 'PILE_OF_SHAME') ? ['TREND'] : []),
|
||||
])].sort();
|
||||
// No groups would mean "all groups" to the stats endpoint.
|
||||
const hasStatGroups = statGroups.length > 0;
|
||||
const stats = useUserStats(statGroups, expireSession, hasSession() && userData != null && hasStatGroups);
|
||||
const reloadStats = stats.reload;
|
||||
|
||||
const fetchSteamCount = useCallback(async () => {
|
||||
try {
|
||||
@@ -80,7 +94,7 @@ const Dashboard = () => {
|
||||
}
|
||||
if (r.ok) {
|
||||
const page = await r.json();
|
||||
setTopGames((page.content ?? []).slice(0, TOP_GAMES));
|
||||
setTopGames((page.content ?? []).slice(0, TOP_GAMES.L));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching top games:', err);
|
||||
@@ -96,7 +110,7 @@ const Dashboard = () => {
|
||||
}
|
||||
if (r.ok) {
|
||||
const page = await r.json();
|
||||
setPlaying((page.content ?? []).slice(0, PLAYING_GAMES));
|
||||
setPlaying((page.content ?? []).slice(0, PLAYING_GAMES.L));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching playing games:', err);
|
||||
@@ -172,8 +186,8 @@ const Dashboard = () => {
|
||||
fetchSummary();
|
||||
fetchActivity();
|
||||
fetchAchievements();
|
||||
reloadTrend();
|
||||
}, [fetchSteamCount, fetchTopGames, fetchPlaying, fetchSummary, fetchActivity, fetchAchievements, reloadTrend]);
|
||||
if (hasStatGroups) reloadStats();
|
||||
}, [fetchSteamCount, fetchTopGames, fetchPlaying, fetchSummary, fetchActivity, fetchAchievements, hasStatGroups, reloadStats]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchUserData = async () => {
|
||||
@@ -240,7 +254,43 @@ const Dashboard = () => {
|
||||
}
|
||||
|
||||
const syncBusy = forceSync.busy || sync.isRunning;
|
||||
const maxMinutes = topGames[0]?.playtimeMinutes || 1;
|
||||
|
||||
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;
|
||||
setUserData((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}/>;
|
||||
case 'PLAYING':
|
||||
return showLibrary && (
|
||||
<CurrentlyPlayingCard games={playing.slice(0, PLAYING_GAMES[size])} syncing={sync.isRunning} wide={size === 'L'}/>
|
||||
);
|
||||
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 hasSteamId && activity && <ActivityCard activity={activity}/>;
|
||||
case 'ACHIEVEMENTS': return hasSteamId && achievements && <AchievementsCard summary={achievements}/>;
|
||||
default: return statGroupTile(id, stats.stats);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<PageContainer className="p-4 md:p-8 space-y-6">
|
||||
@@ -272,101 +322,88 @@ const Dashboard = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasSteamId && (
|
||||
<div className="relative flex flex-col items-start md:items-end gap-3 shrink-0">
|
||||
<SyncStatusBadge
|
||||
status={sync.status}
|
||||
lastSyncedAt={sync.lastSyncedAt}
|
||||
isRunning={sync.isRunning}
|
||||
/>
|
||||
<Button onClick={forceSync.trigger} disabled={syncBusy}>
|
||||
<RefreshCw className={syncBusy ? 'animate-spin' : ''}/>
|
||||
{t('LIBRARY.RESYNC')}
|
||||
</Button>
|
||||
</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}
|
||||
/>
|
||||
<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>
|
||||
|
||||
{showLibrary && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<SteamGameCount targetCount={steamData.count}/>
|
||||
|
||||
<Card className="md:col-span-2 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>
|
||||
|
||||
{topGames.length === 0 ? (
|
||||
<p className="px-5 py-10 text-center text-sm text-muted-foreground">
|
||||
{sync.isRunning ? t('LIBRARY.SYNCING_FIRST_TIME') : t('LIBRARY.EMPTY')}
|
||||
</p>
|
||||
) : (
|
||||
<ol className="divide-y">
|
||||
{topGames.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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showLibrary && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<CurrentlyPlayingCard games={playing} syncing={sync.isRunning}/>
|
||||
{summary && (
|
||||
<PileOfShameCard summary={summary} trend={pileTrend.stats?.trend} syncing={sync.isRunning}
|
||||
onShuffle={shuffleSuggestion} shuffling={shuffling}/>
|
||||
)}
|
||||
{!summary && 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>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasSteamId && activity && <ActivityCard activity={activity}/>}
|
||||
{hasSteamId && achievements && <AchievementsCard summary={achievements}/>}
|
||||
<TileGrid tiles={DASHBOARD_TILES} titles={DASHBOARD_TITLES} layout={layout} renderTile={renderTile} editing={editing}
|
||||
defaultLayout={DEFAULT_DASHBOARD} onSave={saveLayout} onCancel={() => setEditing(false)}/>
|
||||
{/* Last child: space-y gives every other child a bottom margin, which would move the fixed card. */}
|
||||
<DashboardHint/>
|
||||
</PageContainer>
|
||||
);
|
||||
};
|
||||
|
||||
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() {
|
||||
return (
|
||||
<PageContainer className="p-4 md:p-8 space-y-6">
|
||||
|
||||
Reference in new issue
Block a user