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
+391
-10
No files matched your search
+31
-1
@@ -109,7 +109,10 @@
|
||||
"SYNCING": "Synchronisiere mit Steam …",
|
||||
"SYNCING_FIRST_TIME": "Erste Synchronisation mit Steam läuft. Deine Spiele erscheinen gleich.",
|
||||
"SYNC_FAILED": "Synchronisation fehlgeschlagen. Bitte später erneut versuchen.",
|
||||
"LAST_SYNCED": "Zuletzt synchronisiert {{when}}"
|
||||
"LAST_SYNCED": "Zuletzt synchronisiert {{when}}",
|
||||
"FILTER_ALL": "Alle",
|
||||
"SEARCH_PLACEHOLDER": "Spiel suchen …",
|
||||
"NO_MATCH": "Keine Spiele für diesen Filter."
|
||||
},
|
||||
"GAME_DETAIL": {
|
||||
"BACK": "Zurück",
|
||||
@@ -157,5 +160,32 @@
|
||||
"LANGUAGE": "Sprache",
|
||||
"DARK_MODE": "Dunkles Design",
|
||||
"LIGHT_MODE": "Helles Design"
|
||||
},
|
||||
"BACKLOG": {
|
||||
"TITLE": "Mein Backlog",
|
||||
"STATUS_LABEL": "Status",
|
||||
"PRIORITY_LABEL": "Priorität",
|
||||
"RATING_LABEL": "Bewertung",
|
||||
"RATING_STARS_one": "{{count}} Stern",
|
||||
"RATING_STARS_other": "{{count}} Sterne",
|
||||
"NOTES_LABEL": "Notizen",
|
||||
"NOTES_PLACEHOLDER": "Was willst du dir zu diesem Spiel merken?",
|
||||
"SAVE": "Speichern",
|
||||
"RESET": "Verwerfen",
|
||||
"SAVED": "Gespeichert",
|
||||
"SAVE_FAILED": "Speichern fehlgeschlagen. Bitte erneut versuchen.",
|
||||
"STATUS": {
|
||||
"BACKLOG": "Backlog",
|
||||
"PLAYING": "Spiele ich",
|
||||
"FINISHED": "Durchgespielt",
|
||||
"COMPLETED": "100 %",
|
||||
"DROPPED": "Abgebrochen"
|
||||
},
|
||||
"PRIORITY": {
|
||||
"NONE": "Keine",
|
||||
"HIGH": "Hoch",
|
||||
"MEDIUM": "Mittel",
|
||||
"LOW": "Niedrig"
|
||||
}
|
||||
}
|
||||
}
|
||||
+31
-1
@@ -109,7 +109,10 @@
|
||||
"SYNCING": "Syncing with Steam …",
|
||||
"SYNCING_FIRST_TIME": "First sync with Steam in progress. Your games will appear here shortly.",
|
||||
"SYNC_FAILED": "Sync failed. Please try again later.",
|
||||
"LAST_SYNCED": "Last synced {{when}}"
|
||||
"LAST_SYNCED": "Last synced {{when}}",
|
||||
"FILTER_ALL": "All",
|
||||
"SEARCH_PLACEHOLDER": "Search games …",
|
||||
"NO_MATCH": "No games match this filter."
|
||||
},
|
||||
"GAME_DETAIL": {
|
||||
"BACK": "Back",
|
||||
@@ -157,5 +160,32 @@
|
||||
"LANGUAGE": "Language",
|
||||
"DARK_MODE": "Dark mode",
|
||||
"LIGHT_MODE": "Light mode"
|
||||
},
|
||||
"BACKLOG": {
|
||||
"TITLE": "My backlog",
|
||||
"STATUS_LABEL": "Status",
|
||||
"PRIORITY_LABEL": "Priority",
|
||||
"RATING_LABEL": "Rating",
|
||||
"RATING_STARS_one": "{{count}} star",
|
||||
"RATING_STARS_other": "{{count}} stars",
|
||||
"NOTES_LABEL": "Notes",
|
||||
"NOTES_PLACEHOLDER": "What do you want to remember about this game?",
|
||||
"SAVE": "Save",
|
||||
"RESET": "Discard",
|
||||
"SAVED": "Saved",
|
||||
"SAVE_FAILED": "Saving failed. Please try again.",
|
||||
"STATUS": {
|
||||
"BACKLOG": "Backlog",
|
||||
"PLAYING": "Playing",
|
||||
"FINISHED": "Finished",
|
||||
"COMPLETED": "100%",
|
||||
"DROPPED": "Dropped"
|
||||
},
|
||||
"PRIORITY": {
|
||||
"NONE": "None",
|
||||
"HIGH": "High",
|
||||
"MEDIUM": "Medium",
|
||||
"LOW": "Low"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import {useState} from 'react';
|
||||
import {useNavigate} from 'react-router-dom';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {toast} from 'sonner';
|
||||
import {ListTodo, Loader2, Star} from 'lucide-react';
|
||||
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {Card} from '@/components/ui/card';
|
||||
import {Label} from '@/components/ui/label';
|
||||
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/components/ui/select';
|
||||
import {Textarea} from '@/components/ui/textarea';
|
||||
import {cn} from '@/lib/utils';
|
||||
import {STATUS_META} from './GameStatusBadge.jsx';
|
||||
import {GAME_PRIORITIES, GAME_STATUSES, NO_PRIORITY, toBacklogRequest} from '../utils/backlog.js';
|
||||
|
||||
const NOTES_MAX = 2000;
|
||||
|
||||
const toForm = (entry) => ({
|
||||
status: entry.status ?? 'BACKLOG',
|
||||
rating: entry.rating ?? 0,
|
||||
notes: entry.notes ?? '',
|
||||
priority: entry.priority ?? NO_PRIORITY,
|
||||
});
|
||||
|
||||
export default function BacklogCard({game, onSaved}) {
|
||||
const {t} = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [saved, setSaved] = useState(() => toForm(game));
|
||||
const [form, setForm] = useState(saved);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const dirty = JSON.stringify(form) !== JSON.stringify(saved);
|
||||
const set = (field) => (value) => setForm((f) => ({...f, [field]: value}));
|
||||
|
||||
const save = async () => {
|
||||
const token = localStorage.getItem('tbp_jwt');
|
||||
if (!token) {
|
||||
navigate('/login');
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
const response = await fetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/games/${game.appid}/backlog`, {
|
||||
method: 'PUT',
|
||||
headers: {'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(toBacklogRequest(form)),
|
||||
});
|
||||
if (response.status === 401) {
|
||||
localStorage.removeItem('tbp_jwt');
|
||||
navigate('/login');
|
||||
return;
|
||||
}
|
||||
if (!response.ok) {
|
||||
toast.error(t('BACKLOG.SAVE_FAILED'));
|
||||
return;
|
||||
}
|
||||
const entry = await response.json();
|
||||
const next = toForm(entry);
|
||||
setSaved(next);
|
||||
onSaved?.(entry);
|
||||
setForm(next);
|
||||
toast.success(t('BACKLOG.SAVED'));
|
||||
} catch (err) {
|
||||
console.error('backlog save error:', err);
|
||||
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="p-6 gap-5">
|
||||
<h2 className="flex items-center gap-2 text-lg font-semibold">
|
||||
<ListTodo className="size-4 text-primary"/>
|
||||
{t('BACKLOG.TITLE')}
|
||||
</h2>
|
||||
|
||||
<div className="grid gap-5 sm:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="backlog-status">{t('BACKLOG.STATUS_LABEL')}</Label>
|
||||
<Select value={form.status} onValueChange={set('status')}>
|
||||
<SelectTrigger id="backlog-status" className="w-full"><SelectValue/></SelectTrigger>
|
||||
<SelectContent>
|
||||
{GAME_STATUSES.map((s) => {
|
||||
const {icon: Icon, text} = STATUS_META[s];
|
||||
return (
|
||||
<SelectItem key={s} value={s}>
|
||||
<Icon className={text}/>
|
||||
{t(`BACKLOG.STATUS.${s}`)}
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="backlog-priority">{t('BACKLOG.PRIORITY_LABEL')}</Label>
|
||||
<Select value={form.priority} onValueChange={set('priority')}>
|
||||
<SelectTrigger id="backlog-priority" className="w-full"><SelectValue/></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NO_PRIORITY}>{t('BACKLOG.PRIORITY.NONE')}</SelectItem>
|
||||
{GAME_PRIORITIES.map((p) => (
|
||||
<SelectItem key={p} value={p}>{t(`BACKLOG.PRIORITY.${p}`)}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label id="backlog-rating">{t('BACKLOG.RATING_LABEL')}</Label>
|
||||
<div role="group" aria-labelledby="backlog-rating" className="flex h-9 items-center gap-1">
|
||||
{[1, 2, 3, 4, 5].map((n) => (
|
||||
<button
|
||||
key={n}
|
||||
type="button"
|
||||
aria-label={t('BACKLOG.RATING_STARS', {count: n})}
|
||||
aria-pressed={form.rating === n}
|
||||
onClick={() => set('rating')(form.rating === n ? 0 : n)}
|
||||
className="rounded-sm p-0.5 outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
|
||||
>
|
||||
<Star className={cn('size-6 transition-colors',
|
||||
n <= form.rating ? 'fill-amber-400 text-amber-400' : 'text-muted-foreground/40 hover:text-amber-400')}/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<Label htmlFor="backlog-notes">{t('BACKLOG.NOTES_LABEL')}</Label>
|
||||
<span className="text-xs text-muted-foreground tabular-nums">{form.notes.length} / {NOTES_MAX}</span>
|
||||
</div>
|
||||
<Textarea
|
||||
id="backlog-notes"
|
||||
rows={3}
|
||||
maxLength={NOTES_MAX}
|
||||
placeholder={t('BACKLOG.NOTES_PLACEHOLDER')}
|
||||
value={form.notes}
|
||||
onChange={(e) => set('notes')(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="ghost" onClick={() => setForm(saved)} disabled={!dirty || busy}>
|
||||
{t('BACKLOG.RESET')}
|
||||
</Button>
|
||||
<Button onClick={save} disabled={!dirty || busy}>
|
||||
{busy && <Loader2 className="animate-spin"/>}
|
||||
{t('BACKLOG.SAVE')}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {Ban, CircleCheck, Gamepad2, Inbox, Trophy} from 'lucide-react';
|
||||
|
||||
import {Badge} from '@/components/ui/badge';
|
||||
import {cn} from '@/lib/utils';
|
||||
|
||||
export const STATUS_META = {
|
||||
BACKLOG: {icon: Inbox, text: 'text-muted-foreground', badge: 'bg-secondary text-secondary-foreground'},
|
||||
PLAYING: {icon: Gamepad2, text: 'text-primary', badge: 'bg-primary/15 text-primary'},
|
||||
FINISHED: {icon: CircleCheck, text: 'text-emerald-600 dark:text-emerald-400', badge: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400'},
|
||||
COMPLETED: {icon: Trophy, text: 'text-amber-600 dark:text-amber-400', badge: 'bg-amber-500/15 text-amber-600 dark:text-amber-400'},
|
||||
DROPPED: {icon: Ban, text: 'text-rose-600 dark:text-rose-400', badge: 'bg-rose-500/15 text-rose-600 dark:text-rose-400'},
|
||||
};
|
||||
|
||||
/**
|
||||
* @param compact shows only the icon below the sm breakpoint
|
||||
*/
|
||||
export default function GameStatusBadge({status, compact = false, className}) {
|
||||
const {t} = useTranslation();
|
||||
const meta = STATUS_META[status];
|
||||
if (!meta) return null;
|
||||
const Icon = meta.icon;
|
||||
const label = t(`BACKLOG.STATUS.${status}`);
|
||||
return (
|
||||
<Badge className={cn(meta.badge, className)} title={label}>
|
||||
<Icon/>
|
||||
<span className={compact ? 'hidden sm:inline' : undefined}>{label}</span>
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Textarea({ className, ...props }) {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"flex field-sizing-content min-h-16 w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:aria-invalid:ring-destructive/40",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Textarea }
|
||||
@@ -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">
|
||||
<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)}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
export const GAME_STATUSES = ['BACKLOG', 'PLAYING', 'FINISHED', 'COMPLETED', 'DROPPED'];
|
||||
export const GAME_PRIORITIES = ['HIGH', 'MEDIUM', 'LOW'];
|
||||
|
||||
// Radix Select has no empty value, so "no priority" needs its own value in the form.
|
||||
export const NO_PRIORITY = 'NONE';
|
||||
|
||||
export const parseStatus = (value) => (GAME_STATUSES.includes(value) ? value : null);
|
||||
|
||||
export const toBacklogRequest = ({status, rating, notes, priority}) => ({
|
||||
status,
|
||||
rating: rating || null,
|
||||
notes: notes?.trim() ? notes : null,
|
||||
priority: priority === NO_PRIORITY ? null : priority,
|
||||
});
|
||||
|
||||
export const libraryQuery = ({page, size, sort, status, q}) => {
|
||||
const params = new URLSearchParams({page: String(page), size: String(size), sort});
|
||||
if (status) params.set('status', status);
|
||||
if (q?.trim()) params.set('q', q.trim());
|
||||
return params.toString();
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {libraryQuery, NO_PRIORITY, parseStatus, toBacklogRequest} from './backlog.js';
|
||||
|
||||
describe('toBacklogRequest', () => {
|
||||
it('sends every field', () => {
|
||||
expect(toBacklogRequest({status: 'FINISHED', rating: 4, notes: 'Great', priority: 'HIGH'}))
|
||||
.toEqual({status: 'FINISHED', rating: 4, notes: 'Great', priority: 'HIGH'});
|
||||
});
|
||||
|
||||
it('sends null for cleared fields', () => {
|
||||
expect(toBacklogRequest({status: 'BACKLOG', rating: 0, notes: ' ', priority: NO_PRIORITY}))
|
||||
.toEqual({status: 'BACKLOG', rating: null, notes: null, priority: null});
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseStatus', () => {
|
||||
it('accepts a known status only', () => {
|
||||
expect(parseStatus('PLAYING')).toBe('PLAYING');
|
||||
expect(parseStatus('playing')).toBeNull();
|
||||
expect(parseStatus(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('libraryQuery', () => {
|
||||
it('leaves out empty filters', () => {
|
||||
expect(libraryQuery({page: 0, size: 20, sort: 'playtimeMinutes,desc', status: null, q: ' '}))
|
||||
.toBe('page=0&size=20&sort=playtimeMinutes%2Cdesc');
|
||||
});
|
||||
|
||||
it('adds status and the trimmed, encoded search', () => {
|
||||
expect(libraryQuery({page: 1, size: 40, sort: 'appid,asc', status: 'DROPPED', q: ' 100% & more '}))
|
||||
.toBe('page=1&size=40&sort=appid%2Casc&status=DROPPED&q=100%25+%26+more');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user