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

+85 -126
View File
@@ -1,146 +1,105 @@
import {useTranslation} from 'react-i18next';
import {Link} from 'react-router-dom';
import {getValidToken} from '../utils/auth.js';
import {
Archive, ArrowRight, Blocks, Clock, Gamepad2, Library, Link2, Sparkles, Trophy, Users,
} 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 StatCard from '../components/StatCard.jsx';
import {getValidToken} from '../utils/auth.js';
import usePublicStats from '../hooks/usePublicStats.js';
const iconUrl = (appid, hash) =>
hash
? `https://cdn.cloudflare.steamstatic.com/steamcommunity/public/images/apps/${appid}/${hash}.jpg`
: null;
function RankedGames({title, games, formatValue}) {
return (
<Card className="gap-0 py-0">
<h3 className="flex items-center gap-2 border-b px-5 py-4 font-semibold">
<Trophy className="size-4 text-amber-500"/>
{title}
</h3>
<ol className="divide-y">
{games.map((g, idx) => (
<li key={g.appid} className="flex items-center gap-3 px-5 py-3">
<span className="w-4 text-right text-sm font-semibold text-muted-foreground tabular-nums">{idx + 1}</span>
<GameCover appid={g.appid} className="w-16 shrink-0"/>
<span className="min-w-0 flex-1 truncate font-medium">{g.name ?? `App ${g.appid}`}</span>
<span className="text-sm text-muted-foreground tabular-nums">{formatValue(g.value ?? 0)}</span>
</li>
))}
</ol>
</Card>
);
}
export default function LandingPage() {
const {t} = useTranslation();
const {t, i18n} = useTranslation();
const {data, isLoading} = usePublicStats();
const auth = data.auth;
const core = data.core;
const stats = [
[t('LANDING.TOTAL_USERS'), auth?.totalUsers, Users],
[t('LANDING.STEAM_LINKED'), auth?.usersWithSteamLinked, Link2],
[t('LANDING.TOTAL_GAMES'), core?.totalLibraryEntries, Gamepad2],
[t('LANDING.GAMES_PLAYED'), core?.totalGamesPlayed, Sparkles],
[t('LANDING.DISTINCT_GAMES'), core?.totalDistinctGames, Blocks],
[t('LANDING.TOTAL_PLAYTIME_DAYS'), core?.totalPlaytimeDays, Clock, ` ${t('LANDING.DAYS_SUFFIX')}`],
[t('LANDING.BIGGEST_LIBRARY'), core?.biggestLibrary, Library],
[t('LANDING.BIGGEST_BACKLOG'), core?.biggestBacklog, Archive],
];
return (
<div className="grow flex flex-col items-center p-4 md:p-8">
<section className="text-center my-12 md:my-20">
<h1 className="text-4xl md:text-6xl font-black text-slate-900 dark:text-white mb-6 tracking-tight">
{t('MAIN.WELCOME')}
</h1>
<p className="text-lg text-slate-600 dark:text-slate-400 max-w-2xl mx-auto mb-8">
{t('MAIN.DESCRIPTION')}
</p>
{getValidToken() ? (
<Link to="/dashboard" className="bg-blue-600 hover:bg-blue-500 text-white px-8 py-3 rounded-xl font-bold text-lg transition-all shadow-lg shadow-blue-500/30">
{t('NAV.TO_DASHBOARD')}
</Link>
) : (
<Link to="/login" className="bg-blue-600 hover:bg-blue-500 text-white px-8 py-3 rounded-xl font-bold text-lg transition-all shadow-lg shadow-blue-500/30">
{t('MAIN.GET_STARTED')}
</Link>
)}
<div className="grow">
<section className="relative overflow-hidden border-b">
<div className="pointer-events-none absolute inset-0 bg-gradient-to-b from-primary/15 via-primary/5 to-transparent"/>
<div className="relative mx-auto max-w-3xl px-4 py-20 text-center md:py-28">
<Badge variant="outline" className="mb-6 gap-1.5 bg-background/60">
<Gamepad2 className="size-3 text-primary"/> Game Log
</Badge>
<h1 className="text-4xl font-bold tracking-tight md:text-6xl">{t('MAIN.WELCOME')}</h1>
<p className="mx-auto mt-6 max-w-2xl text-lg text-muted-foreground">{t('MAIN.DESCRIPTION')}</p>
<Button asChild size="lg" className="mt-10">
{getValidToken() ? (
<Link to="/dashboard">{t('NAV.TO_DASHBOARD')}<ArrowRight/></Link>
) : (
<Link to="/login">{t('MAIN.GET_STARTED')}<ArrowRight/></Link>
)}
</Button>
</div>
</section>
<section className="w-full max-w-5xl mb-16">
<h2 className="text-2xl font-bold text-slate-900 dark:text-white text-center mb-6">
{t('LANDING.STATS_TITLE')}
</h2>
{isLoading ? (
<div className="text-center text-slate-500 dark:text-slate-400">
{t('LANDING.STATS_LOADING')}
</div>
) : (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<StatCard
label={t('LANDING.TOTAL_USERS')}
value={auth?.totalUsers ?? 0}
icon="mdi-account-group"
/>
<StatCard
label={t('LANDING.STEAM_LINKED')}
value={auth?.usersWithSteamLinked ?? 0}
icon="mdi-steam"
/>
<StatCard
label={t('LANDING.TOTAL_GAMES')}
value={core?.totalLibraryEntries ?? 0}
icon="mdi-controller"
/>
<StatCard
label={t('LANDING.GAMES_PLAYED')}
value={core?.totalGamesPlayed ?? 0}
icon="mdi-gamepad-variant"
/>
<StatCard
label={t('LANDING.DISTINCT_GAMES')}
value={core?.totalDistinctGames ?? 0}
icon="mdi-shape"
/>
<StatCard
label={t('LANDING.TOTAL_PLAYTIME_DAYS')}
value={core?.totalPlaytimeDays ?? 0}
icon="mdi-clock-outline"
suffix={` ${t('LANDING.DAYS_SUFFIX')}`}
/>
<StatCard
label={t('LANDING.BIGGEST_LIBRARY')}
value={core?.biggestLibrary ?? 0}
icon="mdi-bookshelf"
/>
<StatCard
label={t('LANDING.BIGGEST_BACKLOG')}
value={core?.biggestBacklog ?? 0}
icon="mdi-archive"
/>
</div>
)}
<section className="mx-auto w-full max-w-5xl space-y-8 px-4 py-12 md:px-8 md:py-16">
<h2 className="text-center text-2xl font-bold tracking-tight">{t('LANDING.STATS_TITLE')}</h2>
{core?.topPlayedGames?.length > 0 && (
<div className="mt-10 grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="bg-white dark:bg-slate-800 rounded-xl shadow-sm border border-slate-200 dark:border-slate-700 p-6">
<h3 className="text-lg font-bold text-slate-900 dark:text-white mb-4">
{t('LANDING.TOP_PLAYED')}
</h3>
<ol className="space-y-2">
{core.topPlayedGames.map((g, idx) => (
<li key={g.appid} className="flex items-center gap-3">
<span className="w-6 text-sm font-bold text-slate-400 tabular-nums">{idx + 1}</span>
{iconUrl(g.appid, g.imgIconUrl) ? (
<img src={iconUrl(g.appid, g.imgIconUrl)} alt="" className="w-8 h-8 rounded"/>
) : (
<div className="w-8 h-8 rounded bg-slate-200 dark:bg-slate-700"/>
)}
<span className="flex-1 truncate text-slate-900 dark:text-white">
{g.name ?? `App ${g.appid}`}
</span>
<span className="text-xs text-slate-500 dark:text-slate-400 tabular-nums">
{Math.round(g.value / 60).toLocaleString()}h
</span>
</li>
))}
</ol>
</div>
<div className="bg-white dark:bg-slate-800 rounded-xl shadow-sm border border-slate-200 dark:border-slate-700 p-6">
<h3 className="text-lg font-bold text-slate-900 dark:text-white mb-4">
{t('LANDING.TOP_OWNED')}
</h3>
<ol className="space-y-2">
{core.topOwnedGames.map((g, idx) => (
<li key={g.appid} className="flex items-center gap-3">
<span className="w-6 text-sm font-bold text-slate-400 tabular-nums">{idx + 1}</span>
{iconUrl(g.appid, g.imgIconUrl) ? (
<img src={iconUrl(g.appid, g.imgIconUrl)} alt="" className="w-8 h-8 rounded"/>
) : (
<div className="w-8 h-8 rounded bg-slate-200 dark:bg-slate-700"/>
)}
<span className="flex-1 truncate text-slate-900 dark:text-white">
{g.name ?? `App ${g.appid}`}
</span>
<span className="text-xs text-slate-500 dark:text-slate-400 tabular-nums">
{g.value.toLocaleString()}
</span>
</li>
))}
</ol>
</div>
</div>
)}
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
{isLoading
? stats.map(([label]) => <Skeleton key={label} className="h-36 rounded-xl"/>)
: stats.map(([label, value, icon, suffix]) => (
<StatCard key={label} label={label} value={value ?? 0} icon={icon} suffix={suffix}/>
))}
</div>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
{core?.topPlayedGames?.length > 0 && (
<RankedGames
title={t('LANDING.TOP_PLAYED')}
games={core.topPlayedGames}
formatValue={(minutes) => `${Math.round(minutes / 60).toLocaleString(i18n.language)}h`}
/>
)}
{core?.topOwnedGames?.length > 0 && (
<RankedGames
title={t('LANDING.TOP_OWNED')}
games={core.topOwnedGames}
formatValue={(count) => count.toLocaleString(i18n.language)}
/>
)}
</div>
</section>
</div>
);