feat: add What's new feature
Build, Test & Push Frontend / quality-check (push) Successful in 25s
Build, Test & Push Frontend / docker (push) Successful in 39s

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.
This commit is contained in:
lobo committed 2026-10-01 10:03:39 +02:00
1 parent 79b086c525
commit b811255c16
17 files changed
+690 -1

No files matched your search

+184
View File
@@ -0,0 +1,184 @@
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>
);
}
+108
View File
@@ -0,0 +1,108 @@
// @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
import {MemoryRouter} from 'react-router-dom';
import {toast} from 'sonner';
import AdminNewsPage from './AdminNewsPage.jsx';
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
const FIRST = {id: 2, titleEn: 'New feature', titleDe: null, textEn: 'It is here', textDe: null,
publishedAt: '2026-10-01T10:00:00Z', createdBy: 1, createdAt: '2026-10-01T10:00:00Z'};
const SECOND = {...FIRST, id: 1, titleEn: 'Old feature', titleDe: 'Alte Funktion', textDe: 'Alter Text'};
const withRoutes = (routes) => fetchRoutes({
'GET /api/admin/news': json(200, {results: [FIRST, SECOND]}),
'POST /api/admin/news': json(201, {id: 3}),
'PUT /api/admin/news/2': json(204),
'DELETE /api/admin/news/2': json(204),
...routes,
});
const calls = (method) => fetch.mock.calls.filter(([, init]) => (init?.method ?? 'GET') === method);
const renderPage = () => render(<MemoryRouter><PreferencesProvider><AdminNewsPage/></PreferencesProvider></MemoryRouter>);
describe('AdminNewsPage', () => {
beforeEach(() => {
localStorage.setItem('tbp_session', '1');
withRoutes({});
});
afterEach(() => {
cleanup();
localStorage.clear();
vi.restoreAllMocks();
vi.clearAllMocks();
});
it('lists the entries', async () => {
renderPage();
expect(await screen.findByText('New feature')).toBeTruthy();
expect(screen.getByText('Alte Funktion')).toBeTruthy();
});
it('publishes a trimmed entry, shows a preview and resets the form', async () => {
renderPage();
await screen.findByText('New feature');
fireEvent.change(screen.getByLabelText('ADMIN_NEWS.TITLE_EN'), {target: {value: ' Big news '}});
fireEvent.change(screen.getByLabelText('ADMIN_NEWS.TEXT_EN'), {target: {value: ' Line one\nline two '}});
fireEvent.change(screen.getByLabelText('ADMIN_NEWS.TITLE_DE'), {target: {value: ' '}});
expect(screen.getByText('ADMIN_NEWS.PREVIEW')).toBeTruthy();
expect(screen.getByText('Big news')).toBeTruthy();
fireEvent.click(screen.getByText('ADMIN_NEWS.CREATE'));
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('ADMIN_NEWS.CREATED'));
expect(JSON.parse(calls('POST')[0][1].body)).toEqual(
{titleEn: 'Big news', titleDe: '', textEn: 'Line one\nline two', textDe: ''});
expect(screen.getByLabelText('ADMIN_NEWS.TITLE_EN').value).toBe('');
});
it('edits an entry and resets the form', async () => {
renderPage();
await screen.findByText('New feature');
fireEvent.click(screen.getAllByText('ADMIN_NEWS.EDIT_ACTION')[0]);
expect(screen.getByLabelText('ADMIN_NEWS.TITLE_EN').value).toBe('New feature');
fireEvent.change(screen.getByLabelText('ADMIN_NEWS.TITLE_EN'), {target: {value: 'Changed'}});
fireEvent.click(screen.getByText('ADMIN_NEWS.SAVE'));
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('ADMIN_NEWS.UPDATED'));
expect(calls('PUT')[0][0]).toContain('/api/admin/news/2');
expect(JSON.parse(calls('PUT')[0][1].body)).toMatchObject({titleEn: 'Changed', textEn: 'It is here'});
expect(screen.getByLabelText('ADMIN_NEWS.TITLE_EN').value).toBe('');
});
it('deletes after a confirmation', async () => {
vi.spyOn(window, 'confirm').mockReturnValue(true);
renderPage();
await screen.findByText('New feature');
fireEvent.click(screen.getAllByText('ADMIN_NEWS.DELETE')[0]);
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('ADMIN_NEWS.DELETED'));
expect(calls('DELETE')[0][0]).toContain('/api/admin/news/2');
});
it('deletes nothing when the confirmation is refused', async () => {
vi.spyOn(window, 'confirm').mockReturnValue(false);
renderPage();
await screen.findByText('New feature');
fireEvent.click(screen.getAllByText('ADMIN_NEWS.DELETE')[0]);
expect(calls('DELETE')).toEqual([]);
});
it('shows "no access" to a non-admin', async () => {
withRoutes({'GET /api/admin/news': json(403, {})});
renderPage();
expect(await screen.findByText('ADMIN_NEWS.NO_ACCESS')).toBeTruthy();
expect(screen.queryByText('ADMIN_NEWS.CREATE')).toBeNull();
});
});
+2
View File
@@ -14,6 +14,7 @@ import DashboardWelcomeCard from '../components/DashboardWelcomeCard.jsx';
import MostPlayedCard from '../components/MostPlayedCard.jsx';
import PileOfShameCard from '../components/PileOfShameCard.jsx';
import SteamGameCount from '../components/SteamGameCount';
import WhatsNewDialog from '../components/WhatsNewDialog.jsx';
import useDashboardData from '../hooks/useDashboardData.js';
import useLibrarySync from '../hooks/useLibrarySync.js';
import useForceSync from '../hooks/useForceSync.js';
@@ -130,6 +131,7 @@ const Dashboard = () => {
<TileGrid tiles={DASHBOARD_TILES} titles={DASHBOARD_TITLES} layout={layout} renderTile={renderTile} editing={editing}
defaultLayout={DEFAULT_DASHBOARD} onSave={saveLayout} onCancel={() => setEditing(false)}/>
<WhatsNewDialog/>
{/* Last child: space-y gives every other child a bottom margin, which would move the fixed card. */}
<DashboardHint/>
</PageContainer>
+1
View File
@@ -16,6 +16,7 @@ const mostPlayed = (url) => url.includes('sort=playtimeMinutes') && !url.include
const quietRoutes = {
'GET /api/library/summary': json(200, empty),
'GET /api/hints': json(200, {results: []}),
'GET /api/news': json(200, {results: []}),
};
// Every request of the page with a Steam ID; the test of the 401 answers all but the user with 401.
+57
View File
@@ -0,0 +1,57 @@
import {useEffect, useState} from 'react';
import {useTranslation} from 'react-i18next';
import {Loader2} from 'lucide-react';
import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
import NewsEntry from '../components/NewsEntry.jsx';
import PageContainer from '../components/PageContainer.jsx';
import {fetchNews, markNewsSeen} from '../utils/news.js';
/**
* Every news entry, newest first. Opening the page marks them seen.
*/
export default function WhatsNewPage() {
const {t} = useTranslation();
const [entries, setEntries] = useState([]);
const [status, setStatus] = useState('loading');
useEffect(() => {
(async () => {
try {
const response = await fetchNews();
if (response.status === 401) return;
if (!response.ok) {
setStatus('error');
return;
}
setEntries((await response.json()).results);
setStatus('ready');
// A failed mark is silent: the dashboard dialog shows the entries again.
markNewsSeen().catch(() => undefined);
} catch (err) {
console.error('News error:', err);
setStatus('error');
}
})();
}, []);
return (
<PageContainer className="space-y-4 px-4 py-6 md:px-8">
<Card>
<CardHeader>
<CardTitle>{t('NEWS.TITLE')}</CardTitle>
</CardHeader>
<CardContent>
{status === 'loading' && <Loader2 className="size-5 animate-spin text-muted-foreground"/>}
{status === 'error' && <p className="text-sm text-destructive">{t('NEWS.LOAD_ERROR')}</p>}
{status === 'ready' && entries.length === 0 && <p className="text-sm text-muted-foreground">{t('NEWS.EMPTY')}</p>}
{status === 'ready' && entries.length > 0 && (
<ul className="divide-y">
{entries.map((entry) => <li key={entry.id} className="py-3"><NewsEntry entry={entry}/></li>)}
</ul>
)}
</CardContent>
</Card>
</PageContainer>
);
}
+45
View File
@@ -0,0 +1,45 @@
// @vitest-environment jsdom
import {afterEach, describe, expect, it, vi} from 'vitest';
import {cleanup, screen} from '@testing-library/react';
import WhatsNewPage from './WhatsNewPage.jsx';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
import {renderWithProviders} from '../test/renderWithProviders.jsx';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
const entry = (id, title) => ({id, titleEn: title, titleDe: null, textEn: `${title} text`, textDe: null,
publishedAt: '2026-10-01T10:00:00Z', seen: false});
const putCalls = () => fetch.mock.calls.filter(([, init]) => init?.method === 'PUT');
describe('WhatsNewPage', () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
it('lists the entries in the given order and marks them seen once', async () => {
fetchRoutes({'GET /api/news': json(200, {results: [entry(2, 'Second'), entry(1, 'First')]}),
'PUT /api/news/seen': json(204)});
renderWithProviders(<WhatsNewPage/>);
await screen.findByText('Second');
expect(screen.getAllByRole('heading', {level: 3}).map((heading) => heading.textContent)).toEqual(['Second', 'First']);
expect(putCalls()).toHaveLength(1);
});
it('says so when there are no entries', async () => {
fetchRoutes({'GET /api/news': json(200, {results: []}), 'PUT /api/news/seen': json(204)});
renderWithProviders(<WhatsNewPage/>);
expect(await screen.findByText('NEWS.EMPTY')).toBeTruthy();
});
it('shows an error and marks nothing when the load fails', async () => {
fetchRoutes({'GET /api/news': json(500, {})});
renderWithProviders(<WhatsNewPage/>);
expect(await screen.findByText('NEWS.LOAD_ERROR')).toBeTruthy();
expect(putCalls()).toEqual([]);
});
});