feat(dashboard): new layout with sync button and top 5 most played

Remove the placeholder tiles and the settings tile, fix the mobile header width. iconUrl/formatPlaytime move to utils/games.js (599 min now shows 10h, not 10.0h), and the sync button logic to the useForceSync hook shared with the library.
This commit is contained in:
lobo committed 2026-09-24 09:49:06 +02:00
1 parent 84e31b23ea
commit 76a9ac3e6f
9 files changed
+216 -128

No files matched your search

+124 -64
View File
@@ -4,25 +4,32 @@ import {useTranslation} from 'react-i18next';
import SteamGameCount from '../components/SteamGameCount';
import SyncStatusBadge from '../components/SyncStatusBadge.jsx';
import useLibrarySync from '../hooks/useLibrarySync.js';
import useForceSync from '../hooks/useForceSync.js';
import {formatPlaytime, iconUrl} from '../utils/games.js';
const TOP_GAMES = 5;
// 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 cardClass = 'bg-white dark:bg-slate-800 rounded-xl shadow-sm border border-slate-200 dark:border-slate-700';
const authHeaders = () => ({
'Authorization': `Bearer ${localStorage.getItem('tbp_jwt')}`,
'Content-Type': 'application/json',
});
const Dashboard = () => {
const {t} = useTranslation();
const navigate = useNavigate();
const [userData, setUserData] = useState(null);
const [steamData, setSteamData] = useState({count: 0, hasSteamId: false});
const [topGames, setTopGames] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
const fetchSteamCount = useCallback(async () => {
const token = localStorage.getItem('tbp_jwt');
if (!token) return;
try {
const r = await fetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/games-count`, {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
});
const r = await fetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/games-count`, {headers: authHeaders()});
if (r.ok) {
setSteamData(await r.json());
}
@@ -31,11 +38,26 @@ const Dashboard = () => {
}
}, []);
const fetchTopGames = useCallback(async () => {
try {
const r = await fetch(`${import.meta.env.VITE_CORE_API_URL}${TOP_GAMES_URL}`, {headers: authHeaders()});
if (r.ok) {
const page = await r.json();
setTopGames((page.content ?? []).slice(0, TOP_GAMES));
}
} catch (err) {
console.error('Error fetching top games:', err);
}
}, []);
const reloadLibrary = useCallback(() => {
fetchSteamCount();
fetchTopGames();
}, [fetchSteamCount, fetchTopGames]);
useEffect(() => {
const fetchUserData = async () => {
const token = localStorage.getItem('tbp_jwt');
if (!token) {
if (!localStorage.getItem('tbp_jwt')) {
navigate('/login');
return;
}
@@ -43,10 +65,7 @@ const Dashboard = () => {
try {
const profileResponse = await fetch(`${import.meta.env.VITE_API_URL}/api/users/me?v=1`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
headers: authHeaders(),
});
if (profileResponse.ok) {
@@ -54,7 +73,7 @@ const Dashboard = () => {
setUserData(data);
if (data.steamId) {
await fetchSteamCount();
await Promise.all([fetchSteamCount(), fetchTopGames()]);
}
} else if (profileResponse.status === 401 || profileResponse.status === 403) {
localStorage.removeItem('tbp_jwt');
@@ -71,18 +90,19 @@ const Dashboard = () => {
};
fetchUserData();
}, [navigate, t, fetchSteamCount]);
}, [navigate, t, fetchSteamCount, fetchTopGames]);
const expectInitialSync = !!userData?.steamId && steamData.count === 0;
const hasSteamId = !!userData?.steamId;
const sync = useLibrarySync({
enabled: !!userData?.steamId,
expectInitialSync,
onComplete: fetchSteamCount,
enabled: hasSteamId,
expectInitialSync: hasSteamId && steamData.count === 0,
onComplete: reloadLibrary,
});
const forceSync = useForceSync(reloadLibrary);
if (isLoading) {
return (
<div className="flex min-h-screen items-center justify-center bg-slate-50 dark:bg-slate-900 text-slate-900 dark:text-white">
<div className="py-24 text-center text-slate-600 dark:text-slate-300">
{t('DASHBOARD.LOADING')}
</div>
);
@@ -90,24 +110,22 @@ const Dashboard = () => {
if (error) {
return (
<div className="flex min-h-screen items-center justify-center bg-slate-50 dark:bg-slate-900 text-red-500">
<div className="py-24 text-center text-red-500">
{error}
</div>
);
}
return (
<div className="min-h-screen bg-slate-50 dark:bg-slate-900 p-8">
<div className="max-w-4xl mx-auto space-y-8">
const syncBusy = forceSync.busy || sync.isRunning;
<div className="bg-white dark:bg-slate-800 rounded-xl shadow-sm p-8 border border-slate-200 dark:border-slate-700">
<h1 className="text-3xl font-bold text-slate-900 dark:text-white mb-2">
{t('DASHBOARD.HELLO')} {userData?.email.split('@')[0]} 👋
return (
<div className="w-full max-w-5xl mx-auto p-4 md:p-8 space-y-6">
<section className={`${cardClass} p-6 md:p-8 flex flex-col md:flex-row md:items-center md:justify-between gap-4`}>
<div className="min-w-0">
<h1 className="text-2xl md:text-3xl font-bold text-slate-900 dark:text-white truncate">
{t('DASHBOARD.HELLO')} {userData?.email?.split('@')[0]} 👋
</h1>
<p className="text-lg text-slate-600 dark:text-slate-300">
{userData?.dashboardMessage}
</p>
{userData?.steamId ? (
{hasSteamId ? (
<p className="mt-2 text-sm text-slate-500 dark:text-slate-400">
<i className="mdi mdi-steam mr-1"></i>
{t('SETTINGS.STEAM_ID')}: <span className="font-mono">{userData.steamId}</span>
@@ -118,41 +136,83 @@ const Dashboard = () => {
{t('DASHBOARD.NO_STEAM_ID')}
</Link>
)}
{userData?.steamId && (
<div className="mt-4">
<SyncStatusBadge
status={sync.status}
lastSyncedAt={sync.lastSyncedAt}
isRunning={sync.isRunning}
/>
</div>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{steamData.hasSteamId && (
<SteamGameCount targetCount={steamData.count} />
)}
<div className="bg-white dark:bg-slate-800 h-40 rounded-xl shadow-sm border border-slate-200 dark:border-slate-700 flex items-center justify-center">
<span className="text-slate-400 dark:text-slate-500">Tile 1 (Coming soon)</span>
{hasSteamId && (
<div className="flex flex-col items-start md:items-end gap-2 shrink-0">
<SyncStatusBadge
status={sync.status}
lastSyncedAt={sync.lastSyncedAt}
isRunning={sync.isRunning}
/>
<button
type="button"
onClick={forceSync.trigger}
disabled={syncBusy}
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-bold bg-blue-600 hover:bg-blue-500 text-white shadow-md shadow-blue-500/20 transition-colors disabled:opacity-50"
>
<i className={`mdi mdi-sync ${syncBusy ? 'mdi-spin' : ''}`}></i>
{t('LIBRARY.RESYNC')}
</button>
{forceSync.message && (
<span className="text-xs text-slate-500 dark:text-slate-400">{forceSync.message}</span>
)}
</div>
{!steamData.hasSteamId && (
<div className="bg-white dark:bg-slate-800 h-40 rounded-xl shadow-sm border border-slate-200 dark:border-slate-700 flex items-center justify-center">
<span className="text-slate-400 dark:text-slate-500">Tile 2 (Coming soon)</span>
</div>
)}
<Link
to="/settings"
className="bg-white dark:bg-slate-800 h-40 rounded-xl shadow-sm border border-slate-200 dark:border-slate-700 flex flex-col items-center justify-center hover:border-blue-500 dark:hover:border-blue-500 transition-colors"
>
<i className="mdi mdi-cog text-4xl text-slate-500 dark:text-slate-400 mb-2"></i>
<span className="text-slate-700 dark:text-slate-300 font-semibold">
{t('SETTINGS.TITLE')}
</span>
</Link>
</div>
)}
</section>
</div>
{hasSteamId && (
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<SteamGameCount targetCount={steamData.count}/>
<section className={`${cardClass} md:col-span-2 flex flex-col`}>
<div className="flex items-center justify-between px-5 py-4 border-b border-slate-200 dark:border-slate-700">
<h2 className="font-bold text-slate-900 dark:text-white">
<i className="mdi mdi-trophy-outline mr-2 text-amber-500"></i>
{t('DASHBOARD.MOST_PLAYED')}
</h2>
<Link to="/library" className="text-sm font-semibold text-blue-600 dark:text-blue-400 hover:underline">
{t('DASHBOARD.SHOW_ALL')} <i className="mdi mdi-arrow-right"></i>
</Link>
</div>
{topGames.length === 0 ? (
<p className="px-5 py-8 text-center text-sm text-slate-500 dark:text-slate-400">
{sync.isRunning ? t('LIBRARY.SYNCING_FIRST_TIME') : t('LIBRARY.EMPTY')}
</p>
) : (
<ol className="divide-y divide-slate-200 dark:divide-slate-700">
{topGames.map((g, index) => {
const icon = iconUrl(g.appid, g.imgIconUrl);
return (
<li key={g.appid}>
<Link
to={`/library/${g.appid}`}
className="flex items-center gap-3 px-5 py-3 hover:bg-slate-50 dark:hover:bg-slate-700/50 transition-colors"
>
<span className="w-5 text-right text-sm font-bold text-slate-400 dark:text-slate-500 tabular-nums">
{index + 1}.
</span>
{icon ? (
<img src={icon} alt="" className="w-8 h-8 rounded" loading="lazy"/>
) : (
<div className="w-8 h-8 rounded bg-slate-200 dark:bg-slate-700"/>
)}
<span className="flex-1 min-w-0 truncate text-slate-900 dark:text-white">
{g.name ?? `App ${g.appid}`}
</span>
<span className="text-sm text-slate-500 dark:text-slate-400 tabular-nums">
{formatPlaytime(g.playtimeMinutes)}
</span>
</Link>
</li>
);
})}
</ol>
)}
</section>
</div>
)}
</div>
);
};