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.
324 lines
16 KiB
React
324 lines
16 KiB
React
import {useCallback, useEffect, useState} from 'react';
|
|
import {useTranslation} from 'react-i18next';
|
|
import {Loader2, Search, Send, X} from 'lucide-react';
|
|
import {toast} from 'sonner';
|
|
|
|
import {Button} from '@/components/ui/button';
|
|
import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
|
|
import {
|
|
Dialog,
|
|
DialogClose,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from '@/components/ui/dialog';
|
|
import {Input} from '@/components/ui/input';
|
|
import {Label} from '@/components/ui/label';
|
|
import {Textarea} from '@/components/ui/textarea';
|
|
import PageContainer from '../components/PageContainer.jsx';
|
|
import FitText from '../components/FitText.jsx';
|
|
import {fetchUsers} from '../utils/admin.js';
|
|
import {fetchBroadcasts, fetchRecipientCount, sendBroadcast, sendTestBroadcast} from '../utils/broadcasts.js';
|
|
import {formatDateTime} from '../utils/format.js';
|
|
import {MAX_BROADCAST_BODY_LENGTH, MAX_BROADCAST_SUBJECT_LENGTH} from '../utils/limits.js';
|
|
|
|
const emptyForm = () => ({subjectEn: '', subjectDe: '', bodyEn: '', bodyDe: ''});
|
|
const SEARCH_SIZE = 20;
|
|
|
|
/**
|
|
* PLATFORM_ADMIN only: write a mail in both languages, send it to all active users or to picked ones, and see the
|
|
* history with the counts. Auth checks the role.
|
|
*/
|
|
export default function AdminBroadcastPage() {
|
|
const {t, i18n} = useTranslation();
|
|
const [entries, setEntries] = useState([]);
|
|
const [status, setStatus] = useState('loading');
|
|
const [form, setForm] = useState(emptyForm);
|
|
const [mode, setMode] = useState('all');
|
|
const [picked, setPicked] = useState([]);
|
|
const [query, setQuery] = useState('');
|
|
const [found, setFound] = useState([]);
|
|
const [confirm, setConfirm] = useState(null);
|
|
const [busy, setBusy] = useState(false);
|
|
|
|
const load = useCallback(async () => {
|
|
try {
|
|
const response = await fetchBroadcasts();
|
|
if (response.status === 401) return;
|
|
if (response.status === 403) {
|
|
setStatus('forbidden');
|
|
return;
|
|
}
|
|
if (!response.ok) {
|
|
setStatus('error');
|
|
return;
|
|
}
|
|
setEntries((await response.json()).results);
|
|
setStatus('ready');
|
|
} catch (err) {
|
|
console.error('Admin broadcast error:', err);
|
|
setStatus('error');
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
load();
|
|
}, [load]);
|
|
|
|
const trimmed = () => ({
|
|
subjectEn: form.subjectEn.trim(),
|
|
subjectDe: form.subjectDe.trim(),
|
|
bodyEn: form.bodyEn.trim(),
|
|
bodyDe: form.bodyDe.trim(),
|
|
});
|
|
|
|
const incomplete = Object.values(trimmed()).some((value) => !value);
|
|
const userIds = () => (mode === 'all' ? null : picked.map((user) => user.id));
|
|
const change = (field) => (event) => setForm({...form, [field]: event.target.value});
|
|
|
|
const sendTest = async () => {
|
|
setBusy(true);
|
|
try {
|
|
const response = await sendTestBroadcast(trimmed());
|
|
if (response.status === 401) return;
|
|
if (response.ok) toast.success(t('ADMIN_BROADCAST.TEST_SENT'));
|
|
else toast.error(t('ADMIN_BROADCAST.ERROR'));
|
|
} catch (err) {
|
|
console.error('Admin broadcast test error:', err);
|
|
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
const submit = async (event) => {
|
|
event.preventDefault();
|
|
setConfirm({count: null});
|
|
try {
|
|
const response = await fetchRecipientCount(userIds());
|
|
if (response.status === 401) return;
|
|
if (!response.ok) throw new Error(`count ${response.status}`);
|
|
const {count} = await response.json();
|
|
setConfirm((current) => (current ? {count} : current));
|
|
} catch (err) {
|
|
console.error('Admin broadcast count error:', err);
|
|
toast.error(t('ADMIN_BROADCAST.ERROR'));
|
|
setConfirm(null);
|
|
}
|
|
};
|
|
|
|
const send = async () => {
|
|
setBusy(true);
|
|
try {
|
|
const response = await sendBroadcast({...trimmed(), userIds: userIds()});
|
|
if (response.status === 401) return;
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
toast.success(t('ADMIN_BROADCAST.SENT', {count: data.recipientCount}));
|
|
setConfirm(null);
|
|
setForm(emptyForm());
|
|
setPicked([]);
|
|
setFound([]);
|
|
await load();
|
|
} else {
|
|
toast.error(t(response.status === 409 ? 'ADMIN_BROADCAST.NO_RECIPIENTS' : 'ADMIN_BROADCAST.ERROR'));
|
|
}
|
|
} catch (err) {
|
|
console.error('Admin broadcast send error:', err);
|
|
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
const search = async () => {
|
|
try {
|
|
const response = await fetchUsers(query.trim(), 'ACTIVE', 0, SEARCH_SIZE);
|
|
if (response.status === 401) return;
|
|
if (!response.ok) throw new Error(`search ${response.status}`);
|
|
const {content} = await response.json();
|
|
setFound(content.filter((user) => !user.blocked && !user.deletedAt));
|
|
} catch (err) {
|
|
console.error('Admin broadcast search error:', err);
|
|
toast.error(t('ADMIN_BROADCAST.ERROR'));
|
|
}
|
|
};
|
|
|
|
const toggle = (user) => setPicked((current) => (current.some((p) => p.id === user.id)
|
|
? current.filter((p) => p.id !== user.id)
|
|
: [...current, {id: user.id, email: user.email, displayName: user.displayName}]));
|
|
|
|
if (status === 'forbidden') {
|
|
return (
|
|
<PageContainer className="px-4 py-6 md:px-8">
|
|
<p className="text-sm text-muted-foreground">{t('ADMIN_BROADCAST.NO_ACCESS')}</p>
|
|
</PageContainer>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<PageContainer className="space-y-4 px-4 py-6 md:px-8">
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>{t('ADMIN_BROADCAST.NEW')}</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<form onSubmit={submit} className="space-y-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="broadcastSubjectEn">{t('ADMIN_BROADCAST.SUBJECT_EN')}</Label>
|
|
<Input id="broadcastSubjectEn" required maxLength={MAX_BROADCAST_SUBJECT_LENGTH}
|
|
value={form.subjectEn} onChange={change('subjectEn')}/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="broadcastBodyEn">{t('ADMIN_BROADCAST.BODY_EN')}</Label>
|
|
<Textarea id="broadcastBodyEn" required maxLength={MAX_BROADCAST_BODY_LENGTH}
|
|
value={form.bodyEn} onChange={change('bodyEn')}/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="broadcastSubjectDe">{t('ADMIN_BROADCAST.SUBJECT_DE')}</Label>
|
|
<Input id="broadcastSubjectDe" required maxLength={MAX_BROADCAST_SUBJECT_LENGTH}
|
|
value={form.subjectDe} onChange={change('subjectDe')}/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="broadcastBodyDe">{t('ADMIN_BROADCAST.BODY_DE')}</Label>
|
|
<Textarea id="broadcastBodyDe" required maxLength={MAX_BROADCAST_BODY_LENGTH}
|
|
value={form.bodyDe} onChange={change('bodyDe')}/>
|
|
<p className="text-xs text-muted-foreground">{t('ADMIN_BROADCAST.TEXT_HELP')}</p>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<p className="text-sm font-medium">{t('ADMIN_BROADCAST.RECIPIENTS')}</p>
|
|
<div className="flex flex-wrap gap-2">
|
|
{['all', 'picked'].map((value) => (
|
|
<Button key={value} type="button" variant={mode === value ? 'default' : 'outline'}
|
|
aria-pressed={mode === value} onClick={() => setMode(value)}>
|
|
{t(`ADMIN_BROADCAST.${value.toUpperCase()}`)}
|
|
</Button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{mode === 'picked' && (
|
|
<div className="space-y-3">
|
|
<div className="flex gap-2">
|
|
<Input aria-label={t('ADMIN_BROADCAST.SEARCH')} placeholder={t('ADMIN_BROADCAST.SEARCH')}
|
|
value={query} onChange={(event) => setQuery(event.target.value)}
|
|
onKeyDown={(event) => {
|
|
if (event.key === 'Enter') {
|
|
event.preventDefault();
|
|
search();
|
|
}
|
|
}}/>
|
|
<Button type="button" variant="outline" size="icon"
|
|
aria-label={t('ADMIN_BROADCAST.SEARCH')} onClick={search}>
|
|
<Search/>
|
|
</Button>
|
|
</div>
|
|
{found.length > 0 && (
|
|
<ul className="space-y-1">
|
|
{found.map((user) => (
|
|
<li key={user.id} className="@container">
|
|
<label className="flex min-w-0 items-center gap-2 text-sm">
|
|
<input type="checkbox" className="shrink-0 accent-primary"
|
|
checked={picked.some((p) => p.id === user.id)}
|
|
onChange={() => toggle(user)}/>
|
|
<FitText className="min-w-0 flex-1" text={[user.email, user.displayName].filter(Boolean).join(' · ')}/>
|
|
</label>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
<p className="text-sm text-muted-foreground">
|
|
{t('ADMIN_BROADCAST.PICKED_COUNT', {count: picked.length})}
|
|
</p>
|
|
{picked.length > 0 && (
|
|
<ul className="flex flex-wrap gap-2">
|
|
{picked.map((user) => (
|
|
<li key={user.id} className="flex min-w-0 max-w-full items-center gap-1 rounded-md border px-2 py-1 text-sm">
|
|
<span className="min-w-0 truncate" title={user.email}>{user.email}</span>
|
|
<button type="button" className="shrink-0" aria-label={`${t('ADMIN_BROADCAST.REMOVE')} ${user.email}`}
|
|
onClick={() => toggle(user)}>
|
|
<X className="size-3"/>
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex flex-wrap gap-2">
|
|
<Button type="button" variant="outline" disabled={busy || incomplete} onClick={sendTest}>
|
|
{t('ADMIN_BROADCAST.TEST')}
|
|
</Button>
|
|
<Button type="submit" disabled={busy || incomplete || (mode === 'picked' && picked.length === 0)}>
|
|
<Send/>{t('ADMIN_BROADCAST.SEND')}
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>{t('ADMIN_BROADCAST.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_BROADCAST.LOAD_ERROR')}</p>}
|
|
{status === 'ready' && entries.length === 0 && (
|
|
<p className="text-sm text-muted-foreground">{t('ADMIN_BROADCAST.EMPTY')}</p>
|
|
)}
|
|
{status === 'ready' && entries.length > 0 && (
|
|
<ul className="divide-y">
|
|
{entries.map((entry) => (
|
|
<li key={entry.id} className="space-y-1 py-3">
|
|
<p className="text-xs text-muted-foreground">
|
|
{formatDateTime(entry.createdAt, i18n.language)}
|
|
{' · '}
|
|
{t('ADMIN_BROADCAST.COUNTS', {
|
|
recipients: entry.recipientCount, sent: entry.sentCount, failed: entry.failedCount,
|
|
})}
|
|
</p>
|
|
<p className="font-medium">{entry.subjectEn}</p>
|
|
<p className="font-medium">{entry.subjectDe}</p>
|
|
<p className="whitespace-pre-line break-words text-sm text-muted-foreground">{entry.bodyEn}</p>
|
|
<p className="whitespace-pre-line break-words text-sm text-muted-foreground">{entry.bodyDe}</p>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Dialog open={!!confirm} onOpenChange={(open) => { if (!open && !busy) setConfirm(null); }}>
|
|
{confirm && (
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>{t('ADMIN_BROADCAST.CONFIRM_TITLE')}</DialogTitle>
|
|
<DialogDescription asChild>
|
|
<div>
|
|
{confirm.count === null && <Loader2 className="size-5 animate-spin"/>}
|
|
{confirm.count === 0 && t('ADMIN_BROADCAST.NO_RECIPIENTS')}
|
|
{confirm.count > 0 && t('ADMIN_BROADCAST.CONFIRM_TEXT', {count: confirm.count})}
|
|
</div>
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<DialogFooter>
|
|
<DialogClose asChild>
|
|
<Button type="button" variant="outline">{t('ADMIN_BROADCAST.CANCEL')}</Button>
|
|
</DialogClose>
|
|
<Button type="button" disabled={busy || !confirm.count} onClick={send}>
|
|
{busy && <Loader2 className="animate-spin"/>}
|
|
{t('ADMIN_BROADCAST.CONFIRM')}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
)}
|
|
</Dialog>
|
|
</PageContainer>
|
|
);
|
|
}
|