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

+156
View File
@@ -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>
);
}