feat(ui): modern shadcn/ui theme with violet accent across all pages
Build, Test & Push Frontend / quality-check (push) Successful in 8s
Build, Test & Push Frontend / docker (push) Successful in 26s

Add shadcn/ui (Radix + Tailwind 4) components, zinc/violet theme tokens and self-hosted Inter. New header with user menu, Steam cover art, skeletons and toasts. Replace @mdi/font with lucide-react. Fix: top-owned list on the landing page only showed with top-played entries; numbers use the app language.
This commit is contained in:
lobo committed 2026-09-24 10:07:32 +02:00
1 parent 76a9ac3e6f
commit e480369a98
43 files changed
+3804 -979

No files matched your search

+76 -68
View File
@@ -1,18 +1,23 @@
import React, {useCallback, useEffect, useState} from 'react';
import {Link, useNavigate} from 'react-router-dom';
import {useTranslation} from 'react-i18next';
import {ArrowRight, Hash, Link2, RefreshCw, Trophy} from 'lucide-react';
import {Badge} from '@/components/ui/badge';
import {Button} from '@/components/ui/button';
import {Card} from '@/components/ui/card';
import {Skeleton} from '@/components/ui/skeleton';
import GameCover from '../components/GameCover.jsx';
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';
import {formatPlaytime} 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',
@@ -101,120 +106,123 @@ const Dashboard = () => {
const forceSync = useForceSync(reloadLibrary);
if (isLoading) {
return (
<div className="py-24 text-center text-slate-600 dark:text-slate-300">
{t('DASHBOARD.LOADING')}
</div>
);
return <DashboardSkeleton/>;
}
if (error) {
return (
<div className="py-24 text-center text-red-500">
<div className="py-24 text-center text-destructive">
{error}
</div>
);
}
const syncBusy = forceSync.busy || sync.isRunning;
const maxMinutes = topGames[0]?.playtimeMinutes || 1;
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">
<Card className="relative overflow-hidden p-6 md:p-8 flex flex-col md:flex-row md:items-center md:justify-between gap-6">
<div className="pointer-events-none absolute inset-0 bg-gradient-to-br from-primary/15 via-transparent to-transparent"/>
<div className="relative min-w-0">
<p className="text-sm font-medium text-muted-foreground">{t('DASHBOARD.WELCOME_BACK')}</p>
<h1 className="mt-1 text-3xl md:text-4xl font-bold tracking-tight truncate">
{t('DASHBOARD.HELLO')} {userData?.email?.split('@')[0]} 👋
</h1>
{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>
</p>
<Badge variant="outline" className="mt-3 gap-1.5 font-mono text-muted-foreground">
<Hash className="size-3"/>
{userData.steamId}
</Badge>
) : (
<Link to="/settings" className="mt-2 inline-block text-sm text-blue-600 dark:text-blue-400 hover:underline">
<i className="mdi mdi-steam mr-1"></i>
{t('DASHBOARD.NO_STEAM_ID')}
</Link>
<Button asChild variant="link" className="mt-2 h-auto p-0">
<Link to="/settings"><Link2 className="size-4"/>{t('DASHBOARD.NO_STEAM_ID')}</Link>
</Button>
)}
</div>
{hasSteamId && (
<div className="flex flex-col items-start md:items-end gap-2 shrink-0">
<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
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>
<Button onClick={forceSync.trigger} disabled={syncBusy}>
<RefreshCw className={syncBusy ? 'animate-spin' : ''}/>
{t('LIBRARY.RESYNC')}
</button>
{forceSync.message && (
<span className="text-xs text-slate-500 dark:text-slate-400">{forceSync.message}</span>
)}
</Button>
</div>
)}
</section>
</Card>
{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>
<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>
<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>
<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-8 text-center text-sm text-slate-500 dark:text-slate-400">
<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 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">
<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} 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}`}
</span>
<span className="text-sm text-slate-500 dark:text-slate-400 tabular-nums">
{formatPlaytime(g.playtimeMinutes)}
</span>
</Link>
</li>
);
})}
</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>
)}
</section>
</Card>
</div>
)}
</div>
);
};
function DashboardSkeleton() {
return (
<div className="w-full max-w-5xl mx-auto 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>
</div>
);
}
export default Dashboard;