Public Access
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.
185 lines
8.1 KiB
React
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>
|
|
);
|
|
}
|