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

+3
View File
@@ -99,6 +99,9 @@ To Be Played is split into three repositories:
- **Dashboard hints**: news from the admin at the bottom of the dashboard, one at a time. A "Dismiss" hint closes - **Dashboard hints**: news from the admin at the bottom of the dashboard, one at a time. A "Dismiss" hint closes
after 30 s or with X and comes back in the next browser session unless "Do not show again" is ticked; an after 30 s or with X and comes back in the next browser session unless "Do not show again" is ticked; an
"Accept" hint stays until "OK". Admins write, preview and end hints on `/admin/hints` (Auth stores them). "Accept" hint stays until "OK". Admins write, preview and end hints on `/admin/hints` (Auth stores them).
- **What's new**: a dialog on the dashboard shows the news entries the user has not seen yet, newest first. "Got it"
marks them seen in Auth; closing the dialog any other way shows them again on the next dashboard visit. `/whats-new`
(menu) lists all entries and marks them seen. Admins publish, preview, edit and delete entries on `/admin/news`.
- **Correct game data** (AUDITOR or higher): "Correct data" on the game detail page changes name, header image, - **Correct game data** (AUDITOR or higher): "Correct data" on the game detail page changes name, header image,
speedrun link, moods, endless flag and genres for every user; each field goes back to the source on its own. speedrun link, moods, endless flag and genres for every user; each field goes back to the source on its own.
"Merge duplicate" joins two entries of the same game (e.g. the Steam game and the same game added by hand) for "Merge duplicate" joins two entries of the same game (e.g. the Steam game and the same game added by hand) for
+33
View File
@@ -297,6 +297,8 @@
"ERROR": "Die Rolle konnte nicht gewechselt werden." "ERROR": "Die Rolle konnte nicht gewechselt werden."
}, },
"ADMIN_HINTS": "Hinweise", "ADMIN_HINTS": "Hinweise",
"WHATS_NEW": "Neuigkeiten",
"ADMIN_NEWS": "Neuigkeiten verwalten",
"PENDING_REQUESTS_one": "{{count}} Registrierung wartet", "PENDING_REQUESTS_one": "{{count}} Registrierung wartet",
"PENDING_REQUESTS_other": "{{count}} Registrierungen warten" "PENDING_REQUESTS_other": "{{count}} Registrierungen warten"
}, },
@@ -626,6 +628,37 @@
"DONE_COUNT_one": "Von {{count}} Nutzer für immer geschlossen", "DONE_COUNT_one": "Von {{count}} Nutzer für immer geschlossen",
"DONE_COUNT_other": "Von {{count}} Nutzern für immer geschlossen" "DONE_COUNT_other": "Von {{count}} Nutzern für immer geschlossen"
}, },
"NEWS": {
"TITLE": "Neuigkeiten",
"GOT_IT": "Verstanden",
"SAVE_FAILED": "Das Gelesen konnte nicht gespeichert werden.",
"LOAD_ERROR": "Die Neuigkeiten konnten nicht geladen werden.",
"EMPTY": "Noch nichts Neues."
},
"ADMIN_NEWS": {
"NEW": "Neuer Eintrag",
"EDIT": "Eintrag bearbeiten",
"TITLE_EN": "Titel (Englisch)",
"TITLE_DE": "Titel (Deutsch, optional)",
"TEXT_EN": "Text (Englisch)",
"TEXT_DE": "Text (Deutsch, optional)",
"TEXT_HELP": "Nur Text. Titel höchstens 100, Text höchstens 2000 Zeichen. Ohne deutschen Text sehen deutsche Nutzer den englischen.",
"PREVIEW": "Vorschau",
"CREATE": "Veröffentlichen",
"SAVE": "Speichern",
"CANCEL": "Abbrechen",
"CREATED": "Eintrag veröffentlicht.",
"UPDATED": "Eintrag gespeichert.",
"DELETED": "Eintrag gelöscht.",
"EDIT_ACTION": "Bearbeiten",
"DELETE": "Löschen",
"CONFIRM_DELETE": "Diesen Eintrag für alle löschen?",
"ERROR": "Die Aktion ist fehlgeschlagen.",
"LIST": "Alle Einträge",
"EMPTY": "Noch keine Einträge.",
"LOAD_ERROR": "Die Einträge konnten nicht geladen werden.",
"NO_ACCESS": "Nur Admins können diese Seite öffnen."
},
"TILES": { "TILES": {
"CUSTOMIZE": "Anpassen", "CUSTOMIZE": "Anpassen",
"EDIT_HINT": "Verschiebe, vergrößere oder verstecke die Kacheln. Gespeichert wird erst mit \"Fertig\".", "EDIT_HINT": "Verschiebe, vergrößere oder verstecke die Kacheln. Gespeichert wird erst mit \"Fertig\".",
+33
View File
@@ -297,6 +297,8 @@
"ERROR": "The role could not be switched." "ERROR": "The role could not be switched."
}, },
"ADMIN_HINTS": "Hints", "ADMIN_HINTS": "Hints",
"WHATS_NEW": "What's new",
"ADMIN_NEWS": "News",
"PENDING_REQUESTS_one": "{{count}} registration waits", "PENDING_REQUESTS_one": "{{count}} registration waits",
"PENDING_REQUESTS_other": "{{count}} registrations wait" "PENDING_REQUESTS_other": "{{count}} registrations wait"
}, },
@@ -626,6 +628,37 @@
"DONE_COUNT_one": "Closed for good by {{count}} user", "DONE_COUNT_one": "Closed for good by {{count}} user",
"DONE_COUNT_other": "Closed for good by {{count}} users" "DONE_COUNT_other": "Closed for good by {{count}} users"
}, },
"NEWS": {
"TITLE": "What's new",
"GOT_IT": "Got it",
"SAVE_FAILED": "Could not save that you read the news.",
"LOAD_ERROR": "The news could not be loaded.",
"EMPTY": "Nothing new yet."
},
"ADMIN_NEWS": {
"NEW": "New entry",
"EDIT": "Edit entry",
"TITLE_EN": "Title (English)",
"TITLE_DE": "Title (German, optional)",
"TEXT_EN": "Text (English)",
"TEXT_DE": "Text (German, optional)",
"TEXT_HELP": "Plain text. Title max 100, text max 2000 characters. Without a German text, German users see the English one.",
"PREVIEW": "Preview",
"CREATE": "Publish",
"SAVE": "Save",
"CANCEL": "Cancel",
"CREATED": "Entry published.",
"UPDATED": "Entry saved.",
"DELETED": "Entry deleted.",
"EDIT_ACTION": "Edit",
"DELETE": "Delete",
"CONFIRM_DELETE": "Delete this entry for everyone?",
"ERROR": "The action failed.",
"LIST": "All entries",
"EMPTY": "No entries yet.",
"LOAD_ERROR": "The entries could not be loaded.",
"NO_ACCESS": "Only admins can open this page."
},
"TILES": { "TILES": {
"CUSTOMIZE": "Customize", "CUSTOMIZE": "Customize",
"EDIT_HINT": "Move, resize or hide the tiles. Nothing is saved before \"Done\".", "EDIT_HINT": "Move, resize or hide the tiles. Nothing is saved before \"Done\".",
+4
View File
@@ -9,6 +9,8 @@ import Dashboard from "./pages/Dashboard.jsx";
import RegistrationPage from './pages/RegistrationPage.jsx'; import RegistrationPage from './pages/RegistrationPage.jsx';
import SettingsPage from './pages/SettingsPage.jsx'; import SettingsPage from './pages/SettingsPage.jsx';
import AdminHintsPage from './pages/AdminHintsPage.jsx'; import AdminHintsPage from './pages/AdminHintsPage.jsx';
import AdminNewsPage from './pages/AdminNewsPage.jsx';
import WhatsNewPage from './pages/WhatsNewPage.jsx';
import AdminUsersPage from './pages/AdminUsersPage.jsx'; import AdminUsersPage from './pages/AdminUsersPage.jsx';
import GameLibrary from './pages/GameLibrary.jsx'; import GameLibrary from './pages/GameLibrary.jsx';
import GameDetail from './pages/GameDetail.jsx'; import GameDetail from './pages/GameDetail.jsx';
@@ -71,8 +73,10 @@ function App() {
<Route path="/stats" element={<StatsPage/>}/> <Route path="/stats" element={<StatsPage/>}/>
<Route path="/import" element={<ImportPage/>}/> <Route path="/import" element={<ImportPage/>}/>
<Route path="/settings" element={<SettingsPage/>}/> <Route path="/settings" element={<SettingsPage/>}/>
<Route path="/whats-new" element={<WhatsNewPage/>}/>
<Route path="/admin/users" element={<AdminUsersPage/>}/> <Route path="/admin/users" element={<AdminUsersPage/>}/>
<Route path="/admin/hints" element={<AdminHintsPage/>}/> <Route path="/admin/hints" element={<AdminHintsPage/>}/>
<Route path="/admin/news" element={<AdminNewsPage/>}/>
</Route> </Route>
<Route path="/u/:name" element={<PublicProfilePage/>}/> <Route path="/u/:name" element={<PublicProfilePage/>}/>
<Route path="/impressum" element={<ImprintPage/>}/> <Route path="/impressum" element={<ImprintPage/>}/>
+7 -1
View File
@@ -1,7 +1,7 @@
import {useEffect, useState} from 'react'; import {useEffect, useState} from 'react';
import {Link, NavLink, useLocation, useNavigate} from 'react-router-dom'; import {Link, NavLink, useLocation, useNavigate} from 'react-router-dom';
import {useTranslation} from 'react-i18next'; import {useTranslation} from 'react-i18next';
import {ChartColumn, Dices, Eye, Languages, LayoutDashboard, Library, LogOut, Megaphone, Moon, Settings, Shield, Sun} from 'lucide-react'; import {ChartColumn, Dices, Eye, Languages, LayoutDashboard, Library, LogOut, Megaphone, Moon, Newspaper, Settings, Shield, Sun} from 'lucide-react';
import {toast} from 'sonner'; import {toast} from 'sonner';
import {Badge} from '@/components/ui/badge'; import {Badge} from '@/components/ui/badge';
@@ -144,6 +144,9 @@ export default function AppHeader({isAuthenticated, onLogout}) {
<DropdownMenuItem asChild> <DropdownMenuItem asChild>
<Link to="/settings"><Settings/>{t('NAV.SETTINGS')}</Link> <Link to="/settings"><Settings/>{t('NAV.SETTINGS')}</Link>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/whats-new"><Newspaper/>{t('NAV.WHATS_NEW')}</Link>
</DropdownMenuItem>
{seesAdminPage && ( {seesAdminPage && (
<> <>
<DropdownMenuItem asChild> <DropdownMenuItem asChild>
@@ -155,6 +158,9 @@ export default function AppHeader({isAuthenticated, onLogout}) {
<DropdownMenuItem asChild> <DropdownMenuItem asChild>
<Link to="/admin/hints"><Megaphone/>{t('NAV.ADMIN_HINTS')}</Link> <Link to="/admin/hints"><Megaphone/>{t('NAV.ADMIN_HINTS')}</Link>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/admin/news"><Newspaper/>{t('NAV.ADMIN_NEWS')}</Link>
</DropdownMenuItem>
</> </>
)} )}
{isAdmin && ( {isAdmin && (
+3
View File
@@ -54,6 +54,7 @@ describe('AppHeader', () => {
openMenu(); openMenu();
expect(await screen.findByText('NAV.ADMIN')).toBeTruthy(); expect(await screen.findByText('NAV.ADMIN')).toBeTruthy();
expect(screen.getByText('NAV.ADMIN_NEWS')).toBeTruthy();
expect(screen.getByText('NAV.VIEW_AS.MENU')).toBeTruthy(); expect(screen.getByText('NAV.VIEW_AS.MENU')).toBeTruthy();
expect(screen.queryByText('NAV.VIEW_AS.BADGE')).toBeNull(); expect(screen.queryByText('NAV.VIEW_AS.BADGE')).toBeNull();
expect(screen.queryByLabelText('NAV.PENDING_REQUESTS:0')).toBeNull(); expect(screen.queryByLabelText('NAV.PENDING_REQUESTS:0')).toBeNull();
@@ -92,7 +93,9 @@ describe('AppHeader', () => {
openMenu(); openMenu();
expect(await screen.findByText('NAV.SETTINGS')).toBeTruthy(); expect(await screen.findByText('NAV.SETTINGS')).toBeTruthy();
expect(screen.getByText('NAV.WHATS_NEW')).toBeTruthy();
expect(screen.queryByText('NAV.ADMIN')).toBeNull(); expect(screen.queryByText('NAV.ADMIN')).toBeNull();
expect(screen.queryByText('NAV.ADMIN_NEWS')).toBeNull();
expect(screen.queryByText('NAV.VIEW_AS.MENU')).toBeNull(); expect(screen.queryByText('NAV.VIEW_AS.MENU')).toBeNull();
}); });
}); });
+17
View File
@@ -0,0 +1,17 @@
import {useTranslation} from 'react-i18next';
import {cn} from '@/lib/utils';
import {formatDate} from '../utils/format.js';
import {newsText, newsTitle} from '../utils/news.js';
export default function NewsEntry({entry, className}) {
const {i18n} = useTranslation();
return (
<article className={cn('space-y-1', className)}>
<p className="text-xs text-muted-foreground">{formatDate(entry.publishedAt, i18n.language, {dateStyle: 'medium'})}</p>
<h3 className="font-semibold">{newsTitle(entry, i18n.language)}</h3>
<p className="whitespace-pre-line break-words text-sm">{newsText(entry, i18n.language)}</p>
</article>
);
}
+56
View File
@@ -0,0 +1,56 @@
import {useEffect, useState} from 'react';
import {useTranslation} from 'react-i18next';
import {toast} from 'sonner';
import {Button} from '@/components/ui/button';
import {Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle} from '@/components/ui/dialog';
import NewsEntry from './NewsEntry.jsx';
import {fetchNews, markNewsSeen} from '../utils/news.js';
/**
* The news entries the user has not seen, newest first. Only "Got it" marks them seen: closing the dialog any other
* way shows them again on the next dashboard visit.
*/
export default function WhatsNewDialog() {
const {t} = useTranslation();
const [unseen, setUnseen] = useState([]);
const [open, setOpen] = useState(false);
useEffect(() => {
// A failed load shows nothing, not an error.
fetchNews()
.then((response) => (response.ok ? response.json() : null))
.then((data) => {
const entries = (data?.results ?? []).filter((entry) => !entry.seen);
setUnseen(entries);
setOpen(entries.length > 0);
})
.catch(() => undefined);
}, []);
const gotIt = async () => {
setOpen(false);
try {
const response = await markNewsSeen();
if (!response.ok) toast.error(t('NEWS.SAVE_FAILED'));
} catch {
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
}
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-h-[90vh] overflow-y-auto" aria-describedby={undefined}>
<DialogHeader>
<DialogTitle>{t('NEWS.TITLE')}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
{unseen.map((entry) => <NewsEntry key={entry.id} entry={entry}/>)}
</div>
<DialogFooter>
<Button onClick={gotIt}>{t('NEWS.GOT_IT')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+95
View File
@@ -0,0 +1,95 @@
// @vitest-environment jsdom
import {afterEach, describe, expect, it, vi} from 'vitest';
import {act, cleanup, fireEvent, render, screen} from '@testing-library/react';
import {toast} from 'sonner';
import WhatsNewDialog from './WhatsNewDialog.jsx';
import {newsText, newsTitle} from '../utils/news.js';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
vi.mock('sonner', () => ({toast: {error: vi.fn()}}));
const NEW = {id: 2, titleEn: 'New feature', titleDe: 'Neue Funktion', textEn: 'English text', textDe: 'Deutscher Text',
publishedAt: '2026-10-01T10:00:00Z', seen: false};
const ENGLISH_ONLY = {...NEW, id: 3, titleEn: 'English only', titleDe: null, textEn: 'Only English', textDe: null};
const OLD = {...NEW, id: 1, titleEn: 'Old', titleDe: 'Alt', seen: true};
const newsRoutes = (list, seen = json(204)) => fetchRoutes({'GET /api/news': list, 'PUT /api/news/seen': seen});
const putCalls = () => fetch.mock.calls.filter(([, init]) => init?.method === 'PUT');
const showNews = async (...entries) => {
newsRoutes(json(200, {results: entries}));
render(<WhatsNewDialog/>);
await act(async () => undefined);
};
describe('WhatsNewDialog', () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
it('shows only the unseen entries, German when set and English otherwise', async () => {
await showNews(NEW, ENGLISH_ONLY, OLD);
expect(screen.getByRole('dialog')).toBeTruthy();
expect(screen.getByText('Neue Funktion')).toBeTruthy();
expect(screen.getByText('Deutscher Text')).toBeTruthy();
expect(screen.getByText('English only')).toBeTruthy();
expect(screen.getByText('Only English')).toBeTruthy();
expect(screen.queryByText('Alt')).toBeNull();
});
it('marks the entries seen and closes on "Got it"', async () => {
await showNews(NEW);
fireEvent.click(screen.getByText('NEWS.GOT_IT'));
await act(async () => undefined);
expect(putCalls().map(([url]) => url)).toEqual([expect.stringContaining('/api/news/seen')]);
expect(screen.queryByRole('dialog')).toBeNull();
});
it('closes with X without marking anything seen', async () => {
await showNews(NEW);
fireEvent.click(screen.getByRole('button', {name: 'Close'}));
expect(screen.queryByRole('dialog')).toBeNull();
expect(putCalls()).toEqual([]);
});
it.each([
['all entries are seen', json(200, {results: [OLD]})],
['there are no entries', json(200, {results: []})],
['the load fails', json(500, {})],
])('shows nothing when %s', async (_, answer) => {
newsRoutes(answer);
render(<WhatsNewDialog/>);
await act(async () => undefined);
expect(screen.queryByRole('dialog')).toBeNull();
expect(toast.error).not.toHaveBeenCalled();
});
it('shows an error when the seen mark is not saved', async () => {
newsRoutes(json(200, {results: [NEW]}), json(500, {}));
render(<WhatsNewDialog/>);
await act(async () => undefined);
fireEvent.click(screen.getByText('NEWS.GOT_IT'));
await act(async () => undefined);
expect(toast.error).toHaveBeenCalledWith('NEWS.SAVE_FAILED');
});
});
describe('newsTitle / newsText', () => {
it('pick German only for a German UI and a set German field', () => {
expect(newsTitle(NEW, 'de-DE')).toBe('Neue Funktion');
expect(newsText(NEW, 'de')).toBe('Deutscher Text');
expect(newsTitle(NEW, 'en')).toBe('New feature');
expect(newsText(ENGLISH_ONLY, 'de')).toBe('Only English');
expect(newsTitle(ENGLISH_ONLY, undefined)).toBe('English only');
});
});
+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 MostPlayedCard from '../components/MostPlayedCard.jsx';
import PileOfShameCard from '../components/PileOfShameCard.jsx'; import PileOfShameCard from '../components/PileOfShameCard.jsx';
import SteamGameCount from '../components/SteamGameCount'; import SteamGameCount from '../components/SteamGameCount';
import WhatsNewDialog from '../components/WhatsNewDialog.jsx';
import useDashboardData from '../hooks/useDashboardData.js'; import useDashboardData from '../hooks/useDashboardData.js';
import useLibrarySync from '../hooks/useLibrarySync.js'; import useLibrarySync from '../hooks/useLibrarySync.js';
import useForceSync from '../hooks/useForceSync.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} <TileGrid tiles={DASHBOARD_TILES} titles={DASHBOARD_TITLES} layout={layout} renderTile={renderTile} editing={editing}
defaultLayout={DEFAULT_DASHBOARD} onSave={saveLayout} onCancel={() => setEditing(false)}/> 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. */} {/* Last child: space-y gives every other child a bottom margin, which would move the fixed card. */}
<DashboardHint/> <DashboardHint/>
</PageContainer> </PageContainer>
+1
View File
@@ -16,6 +16,7 @@ const mostPlayed = (url) => url.includes('sort=playtimeMinutes') && !url.include
const quietRoutes = { const quietRoutes = {
'GET /api/library/summary': json(200, empty), 'GET /api/library/summary': json(200, empty),
'GET /api/hints': json(200, {results: []}), '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. // 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([]);
});
});
+3
View File
@@ -30,3 +30,6 @@ export const MAX_CORRECTION_GENRES = 10;
export const MAX_DISPLAY_NAME_LENGTH = 24; export const MAX_DISPLAY_NAME_LENGTH = 24;
// Auth CreateHintRequest @Size(max = 500) // Auth CreateHintRequest @Size(max = 500)
export const MAX_HINT_LENGTH = 500; export const MAX_HINT_LENGTH = 500;
// Auth NewsRequest @Size(max = 100) and @Size(max = 2000)
export const MAX_NEWS_TITLE_LENGTH = 100;
export const MAX_NEWS_TEXT_LENGTH = 2000;
+39
View File
@@ -0,0 +1,39 @@
import {apiFetch} from './api.js';
const AUTH = () => import.meta.env.VITE_API_URL;
const JSON_HEADERS = {'Content-Type': 'application/json'};
/**
* GET /api/news: {results: [{id, titleEn, titleDe, textEn, textDe, publishedAt, seen}]}, every entry, newest first.
*/
export const fetchNews = () => apiFetch(`${AUTH()}/api/news`);
/**
* All entries published until now count as seen for this user.
*/
export const markNewsSeen = () => apiFetch(`${AUTH()}/api/news/seen`, {method: 'PUT'});
/**
* PLATFORM_ADMIN only (403 otherwise): {results: [...]}, every entry with createdBy and createdAt, newest first.
*/
export const fetchAdminNews = () => apiFetch(`${AUTH()}/api/admin/news`);
/**
* 201 {id}, or 400 for a blank English title or text, or a title over 100 or a text over 2000 characters.
*/
export const createNews = (news) =>
apiFetch(`${AUTH()}/api/admin/news`, {method: 'POST', headers: JSON_HEADERS, body: JSON.stringify(news)});
/**
* 204, 404 for an unknown id. The published date stays.
*/
export const updateNews = (newsId, news) =>
apiFetch(`${AUTH()}/api/admin/news/${newsId}`, {method: 'PUT', headers: JSON_HEADERS, body: JSON.stringify(news)});
export const deleteNews = (newsId) => apiFetch(`${AUTH()}/api/admin/news/${newsId}`, {method: 'DELETE'});
const pick = (de, en, language) => (language?.startsWith('de') && de ? de : en);
export const newsTitle = (entry, language) => pick(entry.titleDe, entry.titleEn, language);
export const newsText = (entry, language) => pick(entry.textDe, entry.textEn, language);