Public Access
147 lines
6.6 KiB
React
147 lines
6.6 KiB
React
import {useEffect, useState} from 'react';
|
|
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 {NO_PRIORITY, saveBacklogEntry, toBacklogRequest} from '../utils/backlog.js';
|
|
import {GAME_PRIORITIES, GAME_STATUSES, MAX_NOTES_LENGTH} from '../utils/limits.js';
|
|
|
|
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 [saved, setSaved] = useState(() => toForm(game));
|
|
const [form, setForm] = useState(saved);
|
|
|
|
// The PlayingHint can change the status from outside; keep the other unsaved edits.
|
|
useEffect(() => {
|
|
setSaved((current) => (current.status === game.status ? current : {...current, status: game.status}));
|
|
setForm((current) => (current.status === game.status ? current : {...current, status: game.status}));
|
|
}, [game.status]);
|
|
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 () => {
|
|
setBusy(true);
|
|
try {
|
|
const response = await saveBacklogEntry(game.appid, toBacklogRequest(form));
|
|
if (response.status === 401) 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} / {MAX_NOTES_LENGTH}</span>
|
|
</div>
|
|
<Textarea
|
|
id="backlog-notes"
|
|
rows={3}
|
|
maxLength={MAX_NOTES_LENGTH}
|
|
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>
|
|
);
|
|
}
|