Public Access
feat(library): add the game data correction dialog
This commit is contained in:
1 parent
afe00ceae9
commit
6601277961
9 files changed
+434
-3
No files matched your search
@@ -0,0 +1,158 @@
|
||||
import {useEffect, useRef, useState} from 'react';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {Loader2, RotateCcw, Save} from 'lucide-react';
|
||||
import {toast} from 'sonner';
|
||||
|
||||
import {Badge} from '@/components/ui/badge';
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle} from '@/components/ui/dialog';
|
||||
import {Input} from '@/components/ui/input';
|
||||
import {Label} from '@/components/ui/label';
|
||||
import useSessionExpired from '../hooks/useSessionExpired.js';
|
||||
import {CORRECTABLE_MOODS, IGDB_GENRES, resetCorrection, saveCorrection} from '../utils/corrections.js';
|
||||
import {genreKey} from '../utils/stats.js';
|
||||
|
||||
const ENDLESS_CHOICES = [[null, 'UNKNOWN'], [true, 'YES'], [false, 'NO']];
|
||||
// Core refuses more.
|
||||
const MAX_GENRES = 10;
|
||||
|
||||
const initialValues = (game) => ({
|
||||
name: game.name ?? '',
|
||||
image: game.imageUrl ?? '',
|
||||
speedrun: game.speedrunUrl ?? '',
|
||||
moods: game.moods ?? [],
|
||||
endless: game.endless ?? null,
|
||||
genres: game.genres ?? [],
|
||||
});
|
||||
|
||||
// An empty text field means "none": the Steam header, or no speedrun.
|
||||
const toValue = (value) => (typeof value === 'string' ? value.trim() || null : value);
|
||||
|
||||
const toggled = (list, item) => (list.includes(item) ? list.filter((each) => each !== item) : [...list, item]);
|
||||
|
||||
/**
|
||||
* AUDITOR or higher: corrects the shared data of one game for every user. Each field saves on its own;
|
||||
* onChanged loads the game again.
|
||||
*/
|
||||
export default function GameCorrectionDialog({game, open, onOpenChange, onChanged}) {
|
||||
const {t} = useTranslation();
|
||||
const expireSession = useSessionExpired();
|
||||
const [values, setValues] = useState(() => initialValues(game));
|
||||
const [busy, setBusy] = useState(null);
|
||||
// The field whose save or reset the next game from GameDetail answers; the other fields keep their edits.
|
||||
const sentField = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) setValues(initialValues(game));
|
||||
// Only when the dialog opens; a reload after a save is handled below.
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
const field = sentField.current;
|
||||
if (field) {
|
||||
sentField.current = null;
|
||||
setValues((v) => ({...v, [field]: initialValues(game)[field]}));
|
||||
}
|
||||
}, [game]);
|
||||
|
||||
const corrected = new Set((game.corrections ?? []).map((c) => c.field.toLowerCase()));
|
||||
const set = (field, value) => setValues((v) => ({...v, [field]: value}));
|
||||
|
||||
const send = async (field, request) => {
|
||||
setBusy(field);
|
||||
try {
|
||||
const response = await request();
|
||||
if (response.status === 401) {
|
||||
expireSession();
|
||||
return;
|
||||
}
|
||||
if (response.ok) {
|
||||
toast.success(t('CORRECTION.SAVED'));
|
||||
sentField.current = field;
|
||||
onChanged?.();
|
||||
return;
|
||||
}
|
||||
const data = await response.json().catch(() => ({}));
|
||||
toast.error(data.code === 'INVALID_CORRECTION' ? t('CORRECTION.INVALID', {message: data.message})
|
||||
: response.status === 403 ? t('CORRECTION.NO_ACCESS') : t('CORRECTION.ERROR'));
|
||||
} catch (err) {
|
||||
console.error('Correction error:', err);
|
||||
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const row = (field, input) => (
|
||||
<div key={field} className="space-y-2 border-b pb-4 last:border-b-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor={`correction-${field}`}>{t(`CORRECTION.FIELD.${field.toUpperCase()}`)}</Label>
|
||||
{corrected.has(field) && <Badge variant="outline">{t('CORRECTION.CORRECTED')}</Badge>}
|
||||
</div>
|
||||
{input}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="button" size="sm" disabled={busy !== null}
|
||||
onClick={() => send(field, () => saveCorrection(game.appid, field, toValue(values[field])))}>
|
||||
{busy === field ? <Loader2 className="animate-spin"/> : <Save/>}{t('CORRECTION.SAVE')}
|
||||
</Button>
|
||||
{corrected.has(field) && (
|
||||
<Button type="button" size="sm" variant="outline" disabled={busy !== null}
|
||||
onClick={() => send(field, () => resetCorrection(game.appid, field))}>
|
||||
<RotateCcw/>{t('CORRECTION.BACK_TO_SOURCE')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const choices = (field, options, label, max = Infinity) => (
|
||||
<div id={`correction-${field}`} className="flex flex-wrap gap-2">
|
||||
{options.map((option) => (
|
||||
<Button key={option} type="button" size="sm" aria-pressed={values[field].includes(option)}
|
||||
disabled={!values[field].includes(option) && values[field].length >= max}
|
||||
variant={values[field].includes(option) ? 'default' : 'outline'}
|
||||
onClick={() => set(field, toggled(values[field], option))}>
|
||||
{label(option)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
// A genre IGDB added later still shows, so it can be removed.
|
||||
const genreOptions = [...IGDB_GENRES, ...values.genres.filter((g) => !IGDB_GENRES.includes(g))];
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('CORRECTION.TITLE')}</DialogTitle>
|
||||
<DialogDescription>{t('CORRECTION.TEXT')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
{row('name', <Input id="correction-name" value={values.name} maxLength={512}
|
||||
onChange={(e) => set('name', e.target.value)}/>)}
|
||||
{row('image', <Input id="correction-image" value={values.image} maxLength={512}
|
||||
placeholder={t('CORRECTION.IMAGE_HINT')}
|
||||
onChange={(e) => set('image', e.target.value)}/>)}
|
||||
{row('speedrun', <Input id="correction-speedrun" value={values.speedrun} maxLength={512}
|
||||
placeholder={t('CORRECTION.SPEEDRUN_HINT')}
|
||||
onChange={(e) => set('speedrun', e.target.value)}/>)}
|
||||
{row('moods', choices('moods', CORRECTABLE_MOODS, (mood) => t(`PLAY_NEXT.MOOD.${mood}`)))}
|
||||
{row('endless', (
|
||||
<div id="correction-endless" className="flex flex-wrap gap-2">
|
||||
{ENDLESS_CHOICES.map(([choice, key]) => (
|
||||
<Button key={key} type="button" size="sm" aria-pressed={values.endless === choice}
|
||||
variant={values.endless === choice ? 'default' : 'outline'}
|
||||
onClick={() => set('endless', choice)}>
|
||||
{t(`CORRECTION.ENDLESS.${key}`)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
{row('genres', choices('genres', genreOptions,
|
||||
(genre) => t(`STATS.GENRE.${genreKey(genre)}`, {defaultValue: genre}), MAX_GENRES))}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user