Files
ToBePlayed-Frontend/src/pages/AdminNewsPage.jsx
T
lobo b811255c16
Build, Test & Push Frontend / quality-check (push) Successful in 25s
Build, Test & Push Frontend / docker (push) Successful in 39s
feat: add What's new feature
Dashboard dialog shows unseen news entries. Users mark them seen with "Got it"
or revisit via the /whats-new page. Admins publish, edit, and delete entries
on /admin/news. Full i18n support and test coverage included.
2026-10-01 10:03:39 +02:00

185 lines
8.1 KiB
React

import {useCallback, useEffect, useState} from 'react';
import {useTranslation} from 'react-i18next';
import {Loader2, Pencil, Send, Trash2} from 'lucide-react';
import {toast} from 'sonner';
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 NewsEntry from '../components/NewsEntry.jsx';
import PageContainer from '../components/PageContainer.jsx';
import {MAX_NEWS_TEXT_LENGTH, MAX_NEWS_TITLE_LENGTH} from '../utils/limits.js';
import {createNews, deleteNews, fetchAdminNews, updateNews} from '../utils/news.js';
const emptyForm = () => ({titleEn: '', titleDe: '', textEn: '', textDe: ''});
/**
* PLATFORM_ADMIN only: publish, preview, edit and delete the "What's new" entries. Auth checks the role.
*/
export default function AdminNewsPage() {
const {t} = useTranslation();
const [entries, setEntries] = useState([]);
const [status, setStatus] = useState('loading');
const [form, setForm] = useState(emptyForm);
const [editing, setEditing] = useState(null);
const [busy, setBusy] = useState(false);
const load = useCallback(async () => {
try {
const response = await fetchAdminNews();
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 news 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 {
toast.error(t('ADMIN_NEWS.ERROR'));
}
} catch (err) {
console.error('Admin news action error:', err);
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
} finally {
setBusy(false);
}
};
const trimmed = () => ({
titleEn: form.titleEn.trim(),
titleDe: form.titleDe.trim(),
textEn: form.textEn.trim(),
textDe: form.textDe.trim(),
});
const reset = () => {
setForm(emptyForm());
setEditing(null);
};
const submit = (event) => {
event.preventDefault();
const news = trimmed();
if (editing == null) run(() => createNews(news), 'ADMIN_NEWS.CREATED', reset);
else run(() => updateNews(editing, news), 'ADMIN_NEWS.UPDATED', reset);
};
const edit = (entry) => {
setEditing(entry.id);
setForm({titleEn: entry.titleEn, titleDe: entry.titleDe ?? '', textEn: entry.textEn, textDe: entry.textDe ?? ''});
};
const remove = (entry) => {
if (!window.confirm(t('ADMIN_NEWS.CONFIRM_DELETE'))) return;
run(() => deleteNews(entry.id), 'ADMIN_NEWS.DELETED', () => editing === entry.id && reset());
};
const change = (field) => (event) => setForm({...form, [field]: event.target.value});
if (status === 'forbidden') {
return (
<PageContainer className="px-4 py-6 md:px-8">
<p className="text-sm text-muted-foreground">{t('ADMIN_NEWS.NO_ACCESS')}</p>
</PageContainer>
);
}
return (
<PageContainer className="space-y-4 px-4 py-6 md:px-8">
<Card>
<CardHeader>
<CardTitle>{t(editing == null ? 'ADMIN_NEWS.NEW' : 'ADMIN_NEWS.EDIT')}</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={submit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="newsTitleEn">{t('ADMIN_NEWS.TITLE_EN')}</Label>
<Input id="newsTitleEn" required maxLength={MAX_NEWS_TITLE_LENGTH} value={form.titleEn} onChange={change('titleEn')}/>
</div>
<div className="space-y-2">
<Label htmlFor="newsTextEn">{t('ADMIN_NEWS.TEXT_EN')}</Label>
<Textarea id="newsTextEn" required maxLength={MAX_NEWS_TEXT_LENGTH} value={form.textEn} onChange={change('textEn')}/>
</div>
<div className="space-y-2">
<Label htmlFor="newsTitleDe">{t('ADMIN_NEWS.TITLE_DE')}</Label>
<Input id="newsTitleDe" maxLength={MAX_NEWS_TITLE_LENGTH} value={form.titleDe} onChange={change('titleDe')}/>
</div>
<div className="space-y-2">
<Label htmlFor="newsTextDe">{t('ADMIN_NEWS.TEXT_DE')}</Label>
<Textarea id="newsTextDe" maxLength={MAX_NEWS_TEXT_LENGTH} value={form.textDe} onChange={change('textDe')}/>
<p className="text-xs text-muted-foreground">{t('ADMIN_NEWS.TEXT_HELP')}</p>
</div>
{form.titleEn.trim() && form.textEn.trim() && (
<div className="space-y-2">
<p className="text-sm font-medium">{t('ADMIN_NEWS.PREVIEW')}</p>
<NewsEntry entry={{...trimmed(), publishedAt: new Date().toISOString()}}
className="max-w-md rounded-lg border p-4"/>
</div>
)}
<div className="flex gap-2">
<Button type="submit" disabled={busy}>
{busy ? <Loader2 className="animate-spin"/> : <Send/>}
{t(editing == null ? 'ADMIN_NEWS.CREATE' : 'ADMIN_NEWS.SAVE')}
</Button>
{editing != null && (
<Button type="button" variant="outline" onClick={reset}>{t('ADMIN_NEWS.CANCEL')}</Button>
)}
</div>
</form>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{t('ADMIN_NEWS.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_NEWS.LOAD_ERROR')}</p>}
{status === 'ready' && entries.length === 0 && <p className="text-sm text-muted-foreground">{t('ADMIN_NEWS.EMPTY')}</p>}
{status === 'ready' && entries.length > 0 && (
<ul className="divide-y">
{entries.map((entry) => (
<li key={entry.id} className="flex flex-wrap items-start gap-3 py-3">
<NewsEntry entry={entry} className="min-w-0 flex-1"/>
<Button type="button" variant="outline" size="sm" disabled={busy} onClick={() => edit(entry)}>
<Pencil/>{t('ADMIN_NEWS.EDIT_ACTION')}
</Button>
<Button type="button" variant="outline" size="sm" disabled={busy} onClick={() => remove(entry)}>
<Trash2/>{t('ADMIN_NEWS.DELETE')}
</Button>
</li>
))}
</ul>
)}
</CardContent>
</Card>
</PageContainer>
);
}