Files
ToBePlayed-Frontend/src/pages/AdminHintsPage.jsx
T
lobo 8598024bce feat(hint): targets, automatic hints and formatting
Target fields on the admin hints page. The dashboard shows the four
automatic hints after admin hints when their condition is true, using
data Core already serves. HintText renders **bold** and safe links (app
path or https) as React elements, never HTML, in hints and news.
Auto-close without tick hides for this session, X for 7 days, tick + X
for good.
2026-10-02 14:36:49 +02:00

264 lines
14 KiB
React

import {useCallback, useEffect, useState} from 'react';
import {useTranslation} from 'react-i18next';
import {Loader2, Send, Square} from 'lucide-react';
import {toast} from 'sonner';
import {Badge} from '@/components/ui/badge';
import {Button} from '@/components/ui/button';
import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
import {Input} from '@/components/ui/input';
import {Label} from '@/components/ui/label';
import {Textarea} from '@/components/ui/textarea';
import HintCard from '../components/HintCard.jsx';
import {formatDateTime} from '../utils/format.js';
import PageContainer from '../components/PageContainer.jsx';
import {createHint, endHint, fetchAdminHints, setAutoHintEnabled} from '../utils/hints.js';
import {ROLES} from '../utils/admin.js';
import {MAX_HINT_LENGTH} from '../utils/limits.js';
const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
// The value of a datetime-local input: local time, no seconds.
const toLocalInput = (date) => {
const local = new Date(date.getTime() - date.getTimezoneOffset() * 60_000);
return local.toISOString().slice(0, 16);
};
const emptyForm = () => {
const now = new Date();
return {textEn: '', textDe: '', kind: 'DISMISS', startsAt: toLocalInput(now), endsAt: toLocalInput(new Date(now.getTime() + WEEK_MS)),
targetNoSteam: false, targetRole: '', targetNewDays: '', targetEmptyLibrary: false};
};
const targetSummary = (hint, t) => {
const groups = [
hint.targetNoSteam && t('ADMIN_HINTS.TARGET_SUMMARY_NO_STEAM'),
hint.targetRole && t('ADMIN_HINTS.TARGET_SUMMARY_ROLE', {role: t(`ROLES.${hint.targetRole}`)}),
hint.targetNewDays && t('ADMIN_HINTS.TARGET_SUMMARY_NEW', {count: hint.targetNewDays}),
hint.targetEmptyLibrary && t('ADMIN_HINTS.TARGET_SUMMARY_EMPTY_LIBRARY'),
].filter(Boolean);
return groups.length ? groups.join(' + ') : t('ADMIN_HINTS.TARGET_ALL');
};
/**
* PLATFORM_ADMIN only: write, preview and end the dashboard hints. Auth checks the role.
*/
export default function AdminHintsPage() {
const {t, i18n} = useTranslation();
const [hints, setHints] = useState([]);
const [automatic, setAutomatic] = useState([]);
const [status, setStatus] = useState('loading');
const [form, setForm] = useState(emptyForm);
const [busy, setBusy] = useState(false);
const load = useCallback(async () => {
try {
const response = await fetchAdminHints();
if (response.status === 401) return;
if (response.status === 403) {
setStatus('forbidden');
return;
}
if (!response.ok) {
setStatus('error');
return;
}
const data = await response.json();
setHints(data.results);
setAutomatic(data.automatic ?? []);
setStatus('ready');
} catch (err) {
console.error('Admin hints error:', err);
setStatus('error');
}
}, []);
useEffect(() => {
load();
}, [load]);
const run = async (request, doneKey, onOk) => {
setBusy(true);
try {
const response = await request();
if (response.status === 401) return;
if (response.ok) {
toast.success(t(doneKey));
onOk?.();
await load();
} else {
const data = await response.json().catch(() => ({}));
toast.error(t(data.code === 'INVALID_HINT' ? 'ADMIN_HINTS.INVALID' : 'ADMIN_HINTS.ERROR'));
}
} catch (err) {
console.error('Admin hint action error:', err);
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
} finally {
setBusy(false);
}
};
const submit = (event) => {
event.preventDefault();
run(() => createHint({
textEn: form.textEn.trim(),
textDe: form.textDe.trim(),
kind: form.kind,
startsAt: new Date(form.startsAt).toISOString(),
endsAt: new Date(form.endsAt).toISOString(),
targetNoSteam: form.targetNoSteam,
targetRole: form.targetRole || null,
targetNewDays: form.targetNewDays ? Number(form.targetNewDays) : null,
targetEmptyLibrary: form.targetEmptyLibrary,
}), 'ADMIN_HINTS.CREATED', () => setForm(emptyForm()));
};
const change = (field) => (event) => setForm({...form, [field]: event.target.value});
const tick = (field) => (event) => setForm({...form, [field]: event.target.checked});
const formatDate = (value) => formatDateTime(value, i18n.language, {dateStyle: 'short', timeStyle: 'short'});
if (status === 'forbidden') {
return (
<PageContainer className="px-4 py-6 md:px-8">
<p className="text-sm text-muted-foreground">{t('ADMIN_HINTS.NO_ACCESS')}</p>
</PageContainer>
);
}
return (
<PageContainer className="space-y-4 px-4 py-6 md:px-8">
<Card>
<CardHeader>
<CardTitle>{t('ADMIN_HINTS.NEW')}</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={submit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="hintTextEn">{t('ADMIN_HINTS.TEXT_EN')}</Label>
<Textarea id="hintTextEn" required maxLength={MAX_HINT_LENGTH} value={form.textEn} onChange={change('textEn')}/>
</div>
<div className="space-y-2">
<Label htmlFor="hintTextDe">{t('ADMIN_HINTS.TEXT_DE')}</Label>
<Textarea id="hintTextDe" maxLength={MAX_HINT_LENGTH} value={form.textDe} onChange={change('textDe')}/>
<p className="text-xs text-muted-foreground">{t('ADMIN_HINTS.TEXT_HELP')}</p>
</div>
<fieldset className="space-y-2">
<legend className="text-sm font-medium">{t('ADMIN_HINTS.KIND')}</legend>
{['DISMISS', 'ACCEPT'].map((kind) => (
<label key={kind} className="flex items-center gap-2 text-sm">
<input type="radio" name="hintKind" className="accent-primary" value={kind}
checked={form.kind === kind} onChange={change('kind')}/>
{t(`ADMIN_HINTS.KIND_${kind}`)}
</label>
))}
</fieldset>
<fieldset className="space-y-2">
<legend className="text-sm font-medium">{t('ADMIN_HINTS.TARGET')}</legend>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" className="accent-primary" checked={form.targetNoSteam}
onChange={tick('targetNoSteam')}/>
{t('ADMIN_HINTS.TARGET_NO_STEAM')}
</label>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" className="accent-primary" checked={form.targetEmptyLibrary}
onChange={tick('targetEmptyLibrary')}/>
{t('ADMIN_HINTS.TARGET_EMPTY_LIBRARY')}
</label>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="hintTargetRole">{t('ADMIN_HINTS.TARGET_ROLE')}</Label>
<select id="hintTargetRole" value={form.targetRole} onChange={change('targetRole')}
className="h-9 w-full rounded-md border bg-transparent px-3 text-sm">
<option value="">{t('ADMIN_HINTS.TARGET_ROLE_ALL')}</option>
{ROLES.map((role) => <option key={role} value={role}>{t(`ROLES.${role}`)}</option>)}
</select>
</div>
<div className="space-y-2">
<Label htmlFor="hintTargetNewDays">{t('ADMIN_HINTS.TARGET_NEW_DAYS')}</Label>
<Input id="hintTargetNewDays" type="number" min={1} max={365} value={form.targetNewDays}
onChange={change('targetNewDays')}/>
</div>
</div>
<p className="text-xs text-muted-foreground">{t('ADMIN_HINTS.TARGET_HELP')}</p>
</fieldset>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="hintStartsAt">{t('ADMIN_HINTS.STARTS_AT')}</Label>
<Input id="hintStartsAt" type="datetime-local" required value={form.startsAt} onChange={change('startsAt')}/>
</div>
<div className="space-y-2">
<Label htmlFor="hintEndsAt">{t('ADMIN_HINTS.ENDS_AT')}</Label>
<Input id="hintEndsAt" type="datetime-local" required value={form.endsAt} onChange={change('endsAt')}/>
</div>
</div>
{form.textEn.trim() && (
<div className="space-y-2">
<p className="text-sm font-medium">{t('ADMIN_HINTS.PREVIEW')}</p>
<HintCard hint={{...form, textDe: form.textDe.trim()}} hideForever={false} onHideForeverChange={() => undefined}
onClose={() => undefined} onAccept={() => undefined} className="max-w-md"/>
</div>
)}
<Button type="submit" disabled={busy}>
{busy ? <Loader2 className="animate-spin"/> : <Send/>}
{t('ADMIN_HINTS.CREATE')}
</Button>
</form>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{t('ADMIN_HINTS.AUTO_TITLE')}</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{automatic.map(({key, enabled}) => (
<label key={key} className="flex items-center gap-2 text-sm">
<input type="checkbox" className="accent-primary" checked={enabled} disabled={busy}
onChange={(event) => run(() => setAutoHintEnabled(key, event.target.checked),
'ADMIN_HINTS.AUTO_SAVED')}/>
{t(`ADMIN_HINTS.AUTO_${key}`)}
</label>
))}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{t('ADMIN_HINTS.LIST')}</CardTitle>
</CardHeader>
<CardContent>
{status === 'loading' && <Loader2 className="size-5 animate-spin text-muted-foreground"/>}
{status === 'error' && <p className="text-sm text-destructive">{t('ADMIN_HINTS.LOAD_ERROR')}</p>}
{status === 'ready' && hints.length === 0 && <p className="text-sm text-muted-foreground">{t('ADMIN_HINTS.EMPTY')}</p>}
{status === 'ready' && hints.length > 0 && (
<ul className="divide-y">
{hints.map((hint) => (
<li key={hint.id} className="flex flex-wrap items-center gap-3 py-3">
<div className="min-w-0 flex-1 space-y-1">
<p className="whitespace-pre-line break-words text-sm">{hint.textEn}</p>
{hint.textDe && <p className="whitespace-pre-line break-words text-sm text-muted-foreground">{hint.textDe}</p>}
<p className="text-xs text-muted-foreground">
{[t('ADMIN_HINTS.RANGE', {start: formatDate(hint.startsAt), end: formatDate(hint.endsAt)}),
t(`ADMIN_HINTS.KIND_${hint.kind}`),
t('ADMIN_HINTS.DONE_COUNT', {count: hint.doneCount}),
targetSummary(hint, t),
].join(' · ')}
</p>
</div>
<Badge variant={hint.status === 'ACTIVE' ? 'default' : 'outline'}>{t(`ADMIN_HINTS.STATUS_${hint.status}`)}</Badge>
{hint.status !== 'ENDED' && (
<Button type="button" variant="outline" size="sm" disabled={busy}
onClick={() => run(() => endHint(hint.id), 'ADMIN_HINTS.ENDED')}>
<Square/>{t('ADMIN_HINTS.END')}
</Button>
)}
</li>
))}
</ul>
)}
</CardContent>
</Card>
</PageContainer>
);
}