Public Access
feat(library): backlog status, rating, notes and priority in the UI
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:
1 parent
7b676f0a1e
commit
f94e0aff15
9 files changed
+394
-13
No files matched your search
@@ -8,7 +8,9 @@ import {Button} from '@/components/ui/button';
|
||||
import {Card} from '@/components/ui/card';
|
||||
import {Skeleton} from '@/components/ui/skeleton';
|
||||
import {cn} from '@/lib/utils';
|
||||
import BacklogCard from '../components/BacklogCard.jsx';
|
||||
import GameCover from '../components/GameCover.jsx';
|
||||
import GameStatusBadge from '../components/GameStatusBadge.jsx';
|
||||
import {formatPlaytime} from '../utils/games.js';
|
||||
|
||||
const formatUnlockedAt = (instant, locale) => {
|
||||
@@ -123,10 +125,13 @@ export default function GameDetail() {
|
||||
<h1 className="text-2xl md:text-3xl font-bold tracking-tight">
|
||||
{data.name ?? `App ${data.appid}`}
|
||||
</h1>
|
||||
<Badge variant="secondary" className="mt-3 tabular-nums">
|
||||
<Clock className="size-3"/>
|
||||
{t('GAME_DETAIL.PLAYTIME', {time: formatPlaytime(data.playtimeMinutes)})}
|
||||
</Badge>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<Badge variant="secondary" className="tabular-nums">
|
||||
<Clock className="size-3"/>
|
||||
{t('GAME_DETAIL.PLAYTIME', {time: formatPlaytime(data.playtimeMinutes)})}
|
||||
</Badge>
|
||||
<GameStatusBadge status={data.status}/>
|
||||
</div>
|
||||
</div>
|
||||
<Button asChild variant="outline" size="sm" className="self-start">
|
||||
<a href={`https://store.steampowered.com/app/${data.appid}`} target="_blank" rel="noopener noreferrer">
|
||||
@@ -136,6 +141,8 @@ export default function GameDetail() {
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<BacklogCard key={data.appid} game={data} onSaved={(entry) => setData((d) => ({...d, ...entry}))}/>
|
||||
|
||||
{!data.hasAchievements && (
|
||||
<Card className="items-center py-10 text-center text-sm text-muted-foreground">
|
||||
<Trophy className="size-8"/>
|
||||
|
||||
@@ -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)}
|
||||
|
||||
Reference in new issue
Block a user