diff --git a/README.md b/README.md index 75b5d70..51d758a 100644 --- a/README.md +++ b/README.md @@ -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 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). +- **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, 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 diff --git a/public/i18n/de.json b/public/i18n/de.json index ff71564..2eb1213 100644 --- a/public/i18n/de.json +++ b/public/i18n/de.json @@ -297,6 +297,8 @@ "ERROR": "Die Rolle konnte nicht gewechselt werden." }, "ADMIN_HINTS": "Hinweise", + "WHATS_NEW": "Neuigkeiten", + "ADMIN_NEWS": "Neuigkeiten verwalten", "PENDING_REQUESTS_one": "{{count}} Registrierung wartet", "PENDING_REQUESTS_other": "{{count}} Registrierungen warten" }, @@ -626,6 +628,37 @@ "DONE_COUNT_one": "Von {{count}} Nutzer 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": { "CUSTOMIZE": "Anpassen", "EDIT_HINT": "Verschiebe, vergrößere oder verstecke die Kacheln. Gespeichert wird erst mit \"Fertig\".", diff --git a/public/i18n/en.json b/public/i18n/en.json index daf91be..2464089 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -297,6 +297,8 @@ "ERROR": "The role could not be switched." }, "ADMIN_HINTS": "Hints", + "WHATS_NEW": "What's new", + "ADMIN_NEWS": "News", "PENDING_REQUESTS_one": "{{count}} registration waits", "PENDING_REQUESTS_other": "{{count}} registrations wait" }, @@ -626,6 +628,37 @@ "DONE_COUNT_one": "Closed for good by {{count}} user", "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": { "CUSTOMIZE": "Customize", "EDIT_HINT": "Move, resize or hide the tiles. Nothing is saved before \"Done\".", diff --git a/src/App.jsx b/src/App.jsx index f99c11c..3b1c5c7 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -9,6 +9,8 @@ import Dashboard from "./pages/Dashboard.jsx"; import RegistrationPage from './pages/RegistrationPage.jsx'; import SettingsPage from './pages/SettingsPage.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 GameLibrary from './pages/GameLibrary.jsx'; import GameDetail from './pages/GameDetail.jsx'; @@ -71,8 +73,10 @@ function App() { }/> }/> }/> + }/> }/> }/> + }/> }/> }/> diff --git a/src/components/AppHeader.jsx b/src/components/AppHeader.jsx index a795591..459aa58 100644 --- a/src/components/AppHeader.jsx +++ b/src/components/AppHeader.jsx @@ -1,7 +1,7 @@ import {useEffect, useState} from 'react'; import {Link, NavLink, useLocation, useNavigate} from 'react-router-dom'; 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 {Badge} from '@/components/ui/badge'; @@ -144,6 +144,9 @@ export default function AppHeader({isAuthenticated, onLogout}) { {t('NAV.SETTINGS')} + + {t('NAV.WHATS_NEW')} + {seesAdminPage && ( <> @@ -155,6 +158,9 @@ export default function AppHeader({isAuthenticated, onLogout}) { {t('NAV.ADMIN_HINTS')} + + {t('NAV.ADMIN_NEWS')} + )} {isAdmin && ( diff --git a/src/components/AppHeader.test.jsx b/src/components/AppHeader.test.jsx index 50c7fe5..bcb3ac6 100644 --- a/src/components/AppHeader.test.jsx +++ b/src/components/AppHeader.test.jsx @@ -54,6 +54,7 @@ describe('AppHeader', () => { openMenu(); expect(await screen.findByText('NAV.ADMIN')).toBeTruthy(); + expect(screen.getByText('NAV.ADMIN_NEWS')).toBeTruthy(); expect(screen.getByText('NAV.VIEW_AS.MENU')).toBeTruthy(); expect(screen.queryByText('NAV.VIEW_AS.BADGE')).toBeNull(); expect(screen.queryByLabelText('NAV.PENDING_REQUESTS:0')).toBeNull(); @@ -92,7 +93,9 @@ describe('AppHeader', () => { openMenu(); 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_NEWS')).toBeNull(); expect(screen.queryByText('NAV.VIEW_AS.MENU')).toBeNull(); }); }); diff --git a/src/components/NewsEntry.jsx b/src/components/NewsEntry.jsx new file mode 100644 index 0000000..9bb8d91 --- /dev/null +++ b/src/components/NewsEntry.jsx @@ -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 ( +
+

{formatDate(entry.publishedAt, i18n.language, {dateStyle: 'medium'})}

+

{newsTitle(entry, i18n.language)}

+

{newsText(entry, i18n.language)}

+
+ ); +} diff --git a/src/components/WhatsNewDialog.jsx b/src/components/WhatsNewDialog.jsx new file mode 100644 index 0000000..832537d --- /dev/null +++ b/src/components/WhatsNewDialog.jsx @@ -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 ( + + + + {t('NEWS.TITLE')} + +
+ {unseen.map((entry) => )} +
+ + + +
+
+ ); +} diff --git a/src/components/WhatsNewDialog.test.jsx b/src/components/WhatsNewDialog.test.jsx new file mode 100644 index 0000000..8f97bdc --- /dev/null +++ b/src/components/WhatsNewDialog.test.jsx @@ -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(); + 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(); + 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(); + 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'); + }); +}); diff --git a/src/pages/AdminNewsPage.jsx b/src/pages/AdminNewsPage.jsx new file mode 100644 index 0000000..feeb51a --- /dev/null +++ b/src/pages/AdminNewsPage.jsx @@ -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 ( + +

{t('ADMIN_NEWS.NO_ACCESS')}

+
+ ); + } + + return ( + + + + {t(editing == null ? 'ADMIN_NEWS.NEW' : 'ADMIN_NEWS.EDIT')} + + +
+
+ + +
+
+ +