feat(library): backlog status, rating, notes and priority in the UI
Build, Test & Push Frontend / quality-check (push) Successful in 7s
Build, Test & Push Frontend / docker (push) Successful in 31s

Detail page gets an edit card. The library gets status badges, status filter
chips and a debounced name search, both kept in the URL.
This commit is contained in:
lobo committed 2026-09-24 11:04:42 +02:00
1 parent 7b676f0a1e
commit f94e0aff15
9 files changed
+394 -13

No files matched your search

+62 -7
View File
@@ -5,18 +5,22 @@ import useLibrarySync from '../hooks/useLibrarySync.js';
import SyncStatusBadge from '../components/SyncStatusBadge.jsx';
import useForceSync from '../hooks/useForceSync.js';
import {formatPlaytime} from '../utils/games.js';
import {ArrowUpDown, ChevronLeft, ChevronRight, Clock, Library, RefreshCw} from 'lucide-react';
import {GAME_STATUSES, libraryQuery, parseStatus} from '../utils/backlog.js';
import {ArrowUpDown, ChevronLeft, ChevronRight, Clock, Library, RefreshCw, Search, SearchX} from 'lucide-react';
import {Badge} from '@/components/ui/badge';
import {Button} from '@/components/ui/button';
import {Card} from '@/components/ui/card';
import {Input} from '@/components/ui/input';
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/components/ui/select';
import {Skeleton} from '@/components/ui/skeleton';
import GameCover from '../components/GameCover.jsx';
import GameStatusBadge from '../components/GameStatusBadge.jsx';
const PAGE_SIZES = [20, 40, 80];
const DEFAULT_SIZE = 20;
const DEFAULT_SORT = 'playtimeMinutes,desc';
const SEARCH_DEBOUNCE_MS = 300;
const SORT_OPTIONS = [
{value: 'playtimeMinutes,desc', labelKey: 'LIBRARY.SORT_PLAYTIME_DESC'},
{value: 'playtimeMinutes,asc', labelKey: 'LIBRARY.SORT_PLAYTIME_ASC'},
@@ -34,7 +38,11 @@ export default function GameLibrary() {
const size = PAGE_SIZES.includes(sizeParam) ? sizeParam : DEFAULT_SIZE;
const sortParam = searchParams.get('sort') ?? DEFAULT_SORT;
const sort = SORT_OPTIONS.some((o) => o.value === sortParam) ? sortParam : DEFAULT_SORT;
const status = parseStatus(searchParams.get('status'));
const q = searchParams.get('q') ?? '';
const filtered = Boolean(status || q.trim());
const librarySearch = searchParams.toString();
const [search, setSearch] = useState(q);
const [data, setData] = useState({content: [], page: 0, size, totalElements: 0, totalPages: 0});
const [isLoading, setIsLoading] = useState(true);
@@ -49,7 +57,7 @@ export default function GameLibrary() {
setIsLoading(true);
setError(null);
try {
const url = `${import.meta.env.VITE_CORE_API_URL}/api/library/games?page=${page}&size=${size}&sort=${encodeURIComponent(sort)}`;
const url = `${import.meta.env.VITE_CORE_API_URL}/api/library/games?${libraryQuery({page, size, sort, status, q})}`;
const response = await fetch(url, {
headers: {
'Authorization': `Bearer ${token}`,
@@ -76,13 +84,13 @@ export default function GameLibrary() {
} finally {
setIsLoading(false);
}
}, [navigate, page, size, sort, t]);
}, [navigate, page, size, sort, status, q, t]);
useEffect(() => {
load();
}, [load]);
const expectInitialSync = !isLoading && !error && data.totalElements === 0;
const expectInitialSync = !isLoading && !error && !filtered && data.totalElements === 0;
const sync = useLibrarySync({
enabled: true,
expectInitialSync,
@@ -91,10 +99,26 @@ export default function GameLibrary() {
const updateParams = (next) => {
const params = new URLSearchParams(searchParams);
Object.entries(next).forEach(([k, v]) => params.set(k, String(v)));
Object.entries(next).forEach(([k, v]) => (v === null || v === '' ? params.delete(k) : params.set(k, String(v))));
setSearchParams(params);
};
// Back/forward navigation changes q without typing. Keeps a trailing space the user is still typing.
useEffect(() => {
setSearch((current) => (current.trim() === q ? current : q));
}, [q]);
// searchParams is a dependency, so a status click during the delay restarts the timer with fresh params.
useEffect(() => {
if (search.trim() === q) return undefined;
const timer = setTimeout(() => updateParams({q: search.trim(), page: 0}), SEARCH_DEBOUNCE_MS);
return () => clearTimeout(timer);
}, [search, q, searchParams]);
const onStatusChange = (value) => {
updateParams({status: value, page: 0});
};
const onSizeChange = (value) => {
updateParams({size: value, page: 0});
};
@@ -155,6 +179,35 @@ export default function GameLibrary() {
</div>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div role="group" aria-label={t('BACKLOG.STATUS_LABEL')} className="flex flex-wrap gap-1.5">
{[null, ...GAME_STATUSES].map((s) => (
<Button
key={s ?? 'ALL'}
size="sm"
variant={status === s ? 'default' : 'outline'}
aria-pressed={status === s}
className="h-7 rounded-full px-3 text-xs"
onClick={() => onStatusChange(s)}
>
{s ? t(`BACKLOG.STATUS.${s}`) : t('LIBRARY.FILTER_ALL')}
</Button>
))}
</div>
<div className="relative sm:w-64">
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"/>
<Input
type="search"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t('LIBRARY.SEARCH_PLACEHOLDER')}
aria-label={t('LIBRARY.SEARCH_PLACEHOLDER')}
maxLength={100}
className="pl-8"
/>
</div>
</div>
{isLoading && (
<Card className="gap-0 py-0 divide-y">
{Array.from({length: 8}, (_, i) => (
@@ -171,9 +224,10 @@ export default function GameLibrary() {
{ready && data.content.length === 0 && (
<Card className="items-center py-16 text-center text-muted-foreground">
<Library className="size-10"/>
{filtered ? <SearchX className="size-10"/> : <Library className="size-10"/>}
<p className="max-w-sm text-sm">
{sync.isRunning ? t('LIBRARY.SYNCING_FIRST_TIME') : t('LIBRARY.EMPTY')}
{filtered ? t('LIBRARY.NO_MATCH')
: sync.isRunning ? t('LIBRARY.SYNCING_FIRST_TIME') : t('LIBRARY.EMPTY')}
</p>
</Card>
)}
@@ -192,6 +246,7 @@ export default function GameLibrary() {
<span className="min-w-0 flex-1 truncate font-medium group-hover:text-primary transition-colors">
{g.name ?? `App ${g.appid}`}
</span>
<GameStatusBadge status={g.status} compact/>
<Badge variant="secondary" className="tabular-nums">
<Clock className="size-3"/>
{formatPlaytime(g.playtimeMinutes)}