feat(dashboard): show admin hints and add the hints admin page
Build, Test & Push Frontend / quality-check (push) Successful in 18s
Build, Test & Push Frontend / docker (push) Successful in 25s

This commit is contained in:
lobo committed 2026-09-28 10:07:49 +02:00
1 parent 6601277961
commit 01ae372b2d
12 files changed
+700 -6

No files matched your search

+211
View File
@@ -0,0 +1,211 @@
import {useCallback, useEffect, useState} from 'react';
import {useNavigate} from 'react-router-dom';
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 PageContainer from '../components/PageContainer.jsx';
import useSessionExpired from '../hooks/useSessionExpired.js';
import {hasSession} from '../utils/auth.js';
import {createHint, endHint, fetchAdminHints} from '../utils/hints.js';
const MAX_TEXT = 500;
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))};
};
/**
* PLATFORM_ADMIN only: write, preview and end the dashboard hints. Auth checks the role.
*/
export default function AdminHintsPage() {
const {t, i18n} = useTranslation();
const navigate = useNavigate();
const expireSession = useSessionExpired();
const [hints, setHints] = 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) {
expireSession();
return;
}
if (response.status === 403) {
setStatus('forbidden');
return;
}
if (!response.ok) {
setStatus('error');
return;
}
setHints((await response.json()).results);
setStatus('ready');
} catch (err) {
console.error('Admin hints error:', err);
setStatus('error');
}
}, [expireSession]);
useEffect(() => {
if (!hasSession()) {
navigate('/login');
return;
}
load();
}, [load, navigate]);
const run = async (request, doneKey, onOk) => {
setBusy(true);
try {
const response = await request();
if (response.status === 401) {
expireSession();
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(),
}), 'ADMIN_HINTS.CREATED', () => setForm(emptyForm()));
};
const change = (field) => (event) => setForm({...form, [field]: event.target.value});
const formatDate = (value) => new Date(value).toLocaleString(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_TEXT} 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_TEXT} 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>
<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.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}),
].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>
);
}