Public Access
Display and game names shrink to 0.875rem, then end with an ellipsis (FitText + fit-text utility, CSS container units, full text in title). Rows and buttons wrap at large fonts, header points chip can shrink. Guard: scripts/check-overflow.js.
264 lines
14 KiB
React
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="min-w-0 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="min-w-0 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>
|
|
);
|
|
}
|