Public Access
feat(ui): modern shadcn/ui theme with violet accent across all pages
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:
1 parent
76a9ac3e6f
commit
e480369a98
43 files changed
+3804
-979
No files matched your search
+119
-136
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user