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
@@ -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 }
|
||||
Reference in new issue
Block a user