Public Access
F16 F17 F18 F19 F20 F21: import polling backs off and pauses in a hidden tab, one date format module, fake timers in the import tests, error codes instead of raw messages, one safe storage wrapper, dead code.
157 lines
7.6 KiB
React
157 lines
7.6 KiB
React
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 {CORRECTABLE_MOODS, IGDB_GENRES, resetCorrection, saveCorrection} from '../utils/corrections.js';
|
|
import {genreKey} from '../utils/stats.js';
|
|
import {MAX_CORRECTION_GENRES, MAX_CORRECTION_LENGTH} from '../utils/limits.js';
|
|
|
|
const REFUSAL_KEY = {INVALID_CORRECTION: 'CORRECTION.INVALID'};
|
|
|
|
const ENDLESS_CHOICES = [[null, 'UNKNOWN'], [true, 'YES'], [false, 'NO']];
|
|
|
|
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 [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.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [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) return;
|
|
if (response.ok) {
|
|
toast.success(t('CORRECTION.SAVED'));
|
|
sentField.current = field;
|
|
onChanged?.();
|
|
return;
|
|
}
|
|
const data = await response.json().catch(() => ({}));
|
|
// Core's message is English and for developers, so it goes to the console only.
|
|
if (data.message) console.warn('Correction refused:', data.code, data.message);
|
|
toast.error(t(REFUSAL_KEY[data.code] ?? (response.status === 403 ? 'CORRECTION.NO_ACCESS' : '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={MAX_CORRECTION_LENGTH}
|
|
onChange={(e) => set('name', e.target.value)}/>)}
|
|
{row('image', <Input id="correction-image" value={values.image} maxLength={MAX_CORRECTION_LENGTH}
|
|
placeholder={t('CORRECTION.IMAGE_HINT')}
|
|
onChange={(e) => set('image', e.target.value)}/>)}
|
|
{row('speedrun', <Input id="correction-speedrun" value={values.speedrun} maxLength={MAX_CORRECTION_LENGTH}
|
|
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_CORRECTION_GENRES))}
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|