Public Access
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.
This commit is contained in:
1 parent
79b086c525
commit
b811255c16
17 files changed
+690
-1
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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\".",
|
||||||
|
|||||||
@@ -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\".",
|
||||||
|
|||||||
@@ -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/>}/>
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
@@ -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);
|
||||||
Reference in new issue
Block a user