feat(tiles): let users arrange the dashboard and profile tiles
Build, Test & Push Frontend / quality-check (push) Successful in 18s
Build, Test & Push Frontend / docker (push) Successful in 28s

This commit is contained in:
lobo committed 2026-09-28 15:03:16 +02:00
1 parent b55fcfec92
commit aa9e83e0cb
24 files changed
+792 -426

No files matched your search

+135 -98
View File
@@ -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">