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

+119 -136
View File
@@ -4,7 +4,15 @@ import {useTranslation} from 'react-i18next';
import useLibrarySync from '../hooks/useLibrarySync.js';
import SyncStatusBadge from '../components/SyncStatusBadge.jsx';
import useForceSync from '../hooks/useForceSync.js';
import {formatPlaytime, iconUrl} from '../utils/games.js';
import {formatPlaytime} from '../utils/games.js';
import {ArrowUpDown, ChevronLeft, ChevronRight, Clock, Library, RefreshCw} from 'lucide-react';
import {Badge} from '@/components/ui/badge';
import {Button} from '@/components/ui/button';
import {Card} from '@/components/ui/card';
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/components/ui/select';
import {Skeleton} from '@/components/ui/skeleton';
import GameCover from '../components/GameCover.jsx';
const PAGE_SIZES = [20, 40, 80];
const DEFAULT_SIZE = 20;
@@ -87,12 +95,12 @@ export default function GameLibrary() {
setSearchParams(params);
};
const onSizeChange = (event) => {
updateParams({size: event.target.value, page: 0});
const onSizeChange = (value) => {
updateParams({size: value, page: 0});
};
const onSortChange = (event) => {
updateParams({sort: event.target.value, page: 0});
const onSortChange = (value) => {
updateParams({sort: value, page: 0});
};
const goToPage = (target) => {
@@ -100,142 +108,117 @@ export default function GameLibrary() {
updateParams({page: target});
};
const {trigger: triggerSync, busy: syncBusy, message: syncMessage} = useForceSync(load);
const {trigger: triggerSync, busy: syncBusy} = useForceSync(load);
const syncing = syncBusy || sync.isRunning;
const ready = !isLoading && !error;
return (
<div className="min-h-screen bg-slate-50 dark:bg-slate-900 p-8">
<div className="max-w-4xl mx-auto space-y-6">
<div className="flex items-center justify-between flex-wrap gap-4">
<div className="flex flex-col gap-1">
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">
{t('LIBRARY.TITLE')}
</h1>
<div className="flex items-center gap-3 flex-wrap">
<SyncStatusBadge
status={sync.status}
lastSyncedAt={sync.lastSyncedAt}
isRunning={sync.isRunning}
/>
<button
type="button"
onClick={triggerSync}
disabled={syncBusy || sync.isRunning}
className="inline-flex items-center gap-1 text-xs font-bold text-blue-600 dark:text-blue-400 hover:text-blue-500 disabled:opacity-50"
>
<i className={`mdi mdi-sync ${syncBusy || sync.isRunning ? 'mdi-spin' : ''}`}></i>
{t('LIBRARY.RESYNC')}
</button>
{syncMessage && (
<span className="text-xs text-slate-500 dark:text-slate-400">{syncMessage}</span>
)}
</div>
</div>
<div className="flex items-center gap-4 text-sm text-slate-600 dark:text-slate-300 flex-wrap">
<div className="flex items-center gap-2">
<label htmlFor="sort">{t('LIBRARY.SORT_BY')}</label>
<select
id="sort"
value={sort}
onChange={onSortChange}
className="px-3 py-1 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-800 text-slate-900 dark:text-white"
>
{SORT_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>{t(opt.labelKey)}</option>
))}
</select>
</div>
<div className="flex items-center gap-2">
<label htmlFor="pageSize">{t('LIBRARY.PER_PAGE')}</label>
<select
id="pageSize"
value={size}
onChange={onSizeChange}
className="px-3 py-1 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-800 text-slate-900 dark:text-white"
>
{PAGE_SIZES.map((n) => (
<option key={n} value={n}>{n}</option>
))}
</select>
</div>
<div className="w-full max-w-5xl mx-auto p-4 md:p-8 space-y-6">
<div className="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
<div className="space-y-2">
<h1 className="text-3xl font-bold tracking-tight">{t('LIBRARY.TITLE')}</h1>
<div className="flex flex-wrap items-center gap-3">
<SyncStatusBadge
status={sync.status}
lastSyncedAt={sync.lastSyncedAt}
isRunning={sync.isRunning}
/>
<Button variant="outline" size="sm" onClick={triggerSync} disabled={syncing}>
<RefreshCw className={syncing ? 'animate-spin' : ''}/>
{t('LIBRARY.RESYNC')}
</Button>
</div>
</div>
{isLoading && (
<div className="text-center text-slate-600 dark:text-slate-300 py-12">
{t('LIBRARY.LOADING')}
</div>
)}
{error && (
<div className="text-center text-red-500 py-12">{error}</div>
)}
{!isLoading && !error && data.content.length === 0 && (
<div className="text-center text-slate-500 dark:text-slate-400 py-12">
{sync.isRunning ? t('LIBRARY.SYNCING_FIRST_TIME') : t('LIBRARY.EMPTY')}
</div>
)}
{!isLoading && !error && data.content.length > 0 && (
<ul className="bg-white dark:bg-slate-800 rounded-xl shadow-sm border border-slate-200 dark:border-slate-700 divide-y divide-slate-200 dark:divide-slate-700">
{data.content.map((g) => {
const icon = iconUrl(g.appid, g.imgIconUrl);
return (
<li key={g.appid}>
<Link
to={`/library/${g.appid}`}
state={{librarySearch}}
className="flex items-center gap-4 px-4 py-3 hover:bg-slate-50 dark:hover:bg-slate-700/50 transition-colors"
>
{icon ? (
<img
src={icon}
alt=""
className="w-8 h-8 rounded"
loading="lazy"
onError={(e) => { e.currentTarget.style.visibility = 'hidden'; }}
/>
) : (
<div className="w-8 h-8 rounded bg-slate-200 dark:bg-slate-700"/>
)}
<span className="flex-1 text-slate-900 dark:text-white truncate">
{g.name ?? `App ${g.appid}`}
</span>
<span className="text-sm text-slate-500 dark:text-slate-400 tabular-nums">
{formatPlaytime(g.playtimeMinutes)}
</span>
<i className="mdi mdi-chevron-right text-slate-400 dark:text-slate-500"></i>
</Link>
</li>
);
})}
</ul>
)}
{!isLoading && !error && data.totalPages > 1 && (
<div className="flex items-center justify-between text-sm text-slate-600 dark:text-slate-300">
<button
onClick={() => goToPage(page - 1)}
disabled={page <= 0}
className="px-4 py-2 rounded-lg bg-white dark:bg-slate-800 border border-slate-300 dark:border-slate-600 disabled:opacity-40 hover:border-blue-500 disabled:hover:border-slate-300 dark:disabled:hover:border-slate-600 transition-colors"
>
{t('LIBRARY.PREV')}
</button>
<span>
{t('LIBRARY.PAGE_OF', {current: data.page + 1, total: data.totalPages})}
{' · '}
{data.totalElements} {t('LIBRARY.GAMES')}
</span>
<button
onClick={() => goToPage(page + 1)}
disabled={page >= data.totalPages - 1}
className="px-4 py-2 rounded-lg bg-white dark:bg-slate-800 border border-slate-300 dark:border-slate-600 disabled:opacity-40 hover:border-blue-500 disabled:hover:border-slate-300 dark:disabled:hover:border-slate-600 transition-colors"
>
{t('LIBRARY.NEXT')}
</button>
</div>
)}
<div className="flex flex-wrap items-center gap-3">
<Select value={sort} onValueChange={onSortChange}>
<SelectTrigger className="w-52" aria-label={t('LIBRARY.SORT_BY')}>
<ArrowUpDown className="text-muted-foreground"/>
<SelectValue/>
</SelectTrigger>
<SelectContent>
{SORT_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>{t(opt.labelKey)}</SelectItem>
))}
</SelectContent>
</Select>
<Select value={String(size)} onValueChange={onSizeChange}>
<SelectTrigger className="w-24" aria-label={t('LIBRARY.PER_PAGE')}>
<SelectValue/>
</SelectTrigger>
<SelectContent>
{PAGE_SIZES.map((n) => (
<SelectItem key={n} value={String(n)}>{n}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{isLoading && (
<Card className="gap-0 py-0 divide-y">
{Array.from({length: 8}, (_, i) => (
<div key={i} className="flex items-center gap-4 px-5 py-3">
<Skeleton className="h-11 w-24 rounded-md"/>
<Skeleton className="h-4 flex-1"/>
<Skeleton className="h-4 w-12"/>
</div>
))}
</Card>
)}
{error && <div className="py-12 text-center text-destructive">{error}</div>}
{ready && data.content.length === 0 && (
<Card className="items-center py-16 text-center text-muted-foreground">
<Library className="size-10"/>
<p className="max-w-sm text-sm">
{sync.isRunning ? t('LIBRARY.SYNCING_FIRST_TIME') : t('LIBRARY.EMPTY')}
</p>
</Card>
)}
{ready && data.content.length > 0 && (
<Card className="gap-0 py-0">
<ul className="divide-y">
{data.content.map((g) => (
<li key={g.appid}>
<Link
to={`/library/${g.appid}`}
state={{librarySearch}}
className="group flex items-center gap-3 sm:gap-4 px-4 sm:px-5 py-3 transition-colors hover:bg-muted/50"
>
<GameCover appid={g.appid} className="w-16 sm:w-24 shrink-0"/>
<span className="min-w-0 flex-1 truncate font-medium group-hover:text-primary transition-colors">
{g.name ?? `App ${g.appid}`}
</span>
<Badge variant="secondary" className="tabular-nums">
<Clock className="size-3"/>
{formatPlaytime(g.playtimeMinutes)}
</Badge>
<ChevronRight className="size-4 text-muted-foreground"/>
</Link>
</li>
))}
</ul>
</Card>
)}
{ready && data.totalPages > 1 && (
<div className="flex items-center justify-between gap-2 text-sm text-muted-foreground">
<Button variant="outline" size="sm" onClick={() => goToPage(page - 1)} disabled={page <= 0}>
<ChevronLeft/>{t('LIBRARY.PREV')}
</Button>
<span className="text-center">
{t('LIBRARY.PAGE_OF', {current: data.page + 1, total: data.totalPages})}
{' · '}
{data.totalElements} {t('LIBRARY.GAMES')}
</span>
<Button variant="outline" size="sm" onClick={() => goToPage(page + 1)} disabled={page >= data.totalPages - 1}>
{t('LIBRARY.NEXT')}<ChevronRight/>
</Button>
</div>
)}
</div>
);
}