diff --git a/README.md b/README.md index 49dc36c..672b299 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,9 @@ To Be Played is split into three repositories: - **Admin** (PLATFORM_ADMIN only, `/admin/users`): search the users, block, unblock and delete them (the email is typed to confirm a delete). "View as" in the menu switches the own session to User or Auditor and back; a badge shows the lower role. Auth checks every right on the server. +- **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). - **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. - **Delete my account** in the settings: a dialog asks for the own email and the password. Auth blocks the diff --git a/public/i18n/de.json b/public/i18n/de.json index ca3a1f4..e49f562 100644 --- a/public/i18n/de.json +++ b/public/i18n/de.json @@ -284,7 +284,8 @@ "MENU": "Ansehen als", "BADGE": "Ansicht als {{role}}", "ERROR": "Die Rolle konnte nicht gewechselt werden." - } + }, + "ADMIN_HINTS": "Hinweise" }, "BACKLOG": { "TITLE": "Mein Backlog", @@ -545,5 +546,39 @@ "YES": "Ja", "NO": "Nein" } + }, + "HINT": { + "CLOSE": "Schließen", + "DO_NOT_SHOW_AGAIN": "Nicht mehr anzeigen", + "OK": "OK", + "SAVE_FAILED": "Der Hinweis konnte nicht gespeichert werden." + }, + "ADMIN_HINTS": { + "NEW": "Neuer Hinweis", + "TEXT_EN": "Text (Englisch)", + "TEXT_DE": "Text (Deutsch, optional)", + "TEXT_HELP": "Nur Text, höchstens 500 Zeichen. Ohne deutschen Text sehen deutsche Nutzer den englischen.", + "KIND": "Art", + "KIND_DISMISS": "Schließen: geht nach 30 s oder mit X zu", + "KIND_ACCEPT": "Bestätigen: bleibt, bis der Nutzer OK klickt", + "STARTS_AT": "Start", + "ENDS_AT": "Ende", + "PREVIEW": "Vorschau", + "CREATE": "Veröffentlichen", + "CREATED": "Hinweis veröffentlicht.", + "INVALID": "Das Ende muss nach dem Start und in der Zukunft liegen.", + "ERROR": "Die Aktion ist fehlgeschlagen.", + "LIST": "Alle Hinweise", + "EMPTY": "Noch keine Hinweise.", + "LOAD_ERROR": "Die Hinweise konnten nicht geladen werden.", + "NO_ACCESS": "Nur Admins können diese Seite öffnen.", + "END": "Beenden", + "ENDED": "Hinweis beendet.", + "STATUS_PLANNED": "Geplant", + "STATUS_ACTIVE": "Aktiv", + "STATUS_ENDED": "Beendet", + "RANGE": "{{start}} bis {{end}}", + "DONE_COUNT_one": "Von {{count}} Nutzer für immer geschlossen", + "DONE_COUNT_other": "Von {{count}} Nutzern für immer geschlossen" } } diff --git a/public/i18n/en.json b/public/i18n/en.json index 3f79c69..5b3984f 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -284,7 +284,8 @@ "MENU": "View as", "BADGE": "Viewing as {{role}}", "ERROR": "The role could not be switched." - } + }, + "ADMIN_HINTS": "Hints" }, "BACKLOG": { "TITLE": "My backlog", @@ -545,5 +546,39 @@ "YES": "Yes", "NO": "No" } + }, + "HINT": { + "CLOSE": "Close", + "DO_NOT_SHOW_AGAIN": "Do not show again", + "OK": "OK", + "SAVE_FAILED": "The hint could not be saved." + }, + "ADMIN_HINTS": { + "NEW": "New hint", + "TEXT_EN": "Text (English)", + "TEXT_DE": "Text (German, optional)", + "TEXT_HELP": "Plain text, max 500 characters. Without a German text, German users see the English one.", + "KIND": "Type", + "KIND_DISMISS": "Dismiss: closes after 30 s or with X", + "KIND_ACCEPT": "Accept: stays until the user clicks OK", + "STARTS_AT": "Start", + "ENDS_AT": "End", + "PREVIEW": "Preview", + "CREATE": "Publish", + "CREATED": "Hint published.", + "INVALID": "The end must be after the start and in the future.", + "ERROR": "The action failed.", + "LIST": "All hints", + "EMPTY": "No hints yet.", + "LOAD_ERROR": "The hints could not be loaded.", + "NO_ACCESS": "Only admins can open this page.", + "END": "End", + "ENDED": "Hint ended.", + "STATUS_PLANNED": "Planned", + "STATUS_ACTIVE": "Active", + "STATUS_ENDED": "Ended", + "RANGE": "{{start}} to {{end}}", + "DONE_COUNT_one": "Closed for good by {{count}} user", + "DONE_COUNT_other": "Closed for good by {{count}} users" } } diff --git a/src/App.jsx b/src/App.jsx index dbfe2ad..9388997 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -8,6 +8,7 @@ import ImprintPage from './pages/ImprintPage'; 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 AdminUsersPage from './pages/AdminUsersPage.jsx'; import GameLibrary from './pages/GameLibrary.jsx'; import GameDetail from './pages/GameDetail.jsx'; @@ -73,6 +74,7 @@ function App() { }/> }/> }/> + }/> }/> diff --git a/src/components/AppHeader.jsx b/src/components/AppHeader.jsx index e295832..a44d602 100644 --- a/src/components/AppHeader.jsx +++ b/src/components/AppHeader.jsx @@ -1,6 +1,6 @@ import {Link, NavLink, useLocation, useNavigate} from 'react-router-dom'; import {useTranslation} from 'react-i18next'; -import {ChartColumn, Dices, Eye, Languages, LayoutDashboard, Library, LogOut, Moon, Settings, Shield, Sun} from 'lucide-react'; +import {ChartColumn, Dices, Eye, Languages, LayoutDashboard, Library, LogOut, Megaphone, Moon, Settings, Shield, Sun} from 'lucide-react'; import {toast} from 'sonner'; import {Badge} from '@/components/ui/badge'; @@ -112,9 +112,14 @@ export default function AppHeader({isAuthenticated, onLogout}) { {t('NAV.SETTINGS')} {isAdmin && !viewingAs && ( - - {t('NAV.ADMIN')} - + <> + + {t('NAV.ADMIN')} + + + {t('NAV.ADMIN_HINTS')} + + )} {isAdmin && ( diff --git a/src/components/DashboardHint.jsx b/src/components/DashboardHint.jsx new file mode 100644 index 0000000..6514fd4 --- /dev/null +++ b/src/components/DashboardHint.jsx @@ -0,0 +1,78 @@ +import {useCallback, useEffect, useState} from 'react'; +import {useTranslation} from 'react-i18next'; +import {toast} from 'sonner'; + +import HintCard from './HintCard.jsx'; +import {fetchHints, markHintDone} from '../utils/hints.js'; + +export const AUTO_CLOSE_MS = 30_000; +const CLOSED_KEY = 'tbp_closed_hints'; + +// Hints closed in this tab, so going back to the dashboard does not show them again. +const closedIds = () => { + try { + return JSON.parse(sessionStorage.getItem(CLOSED_KEY)) ?? []; + } catch { + return []; + } +}; + +const rememberClosed = (hintId) => { + try { + sessionStorage.setItem(CLOSED_KEY, JSON.stringify([...closedIds(), hintId])); + } catch { + // Storage blocked: the hint shows again on the next dashboard visit. + } +}; + +/** + * The admin hints on the dashboard, one at a time, newest first. A DISMISS hint closed without "Do not show again" + * comes back in the next browser session. + */ +export default function DashboardHint() { + const {t} = useTranslation(); + const [hints, setHints] = useState([]); + const [hideForever, setHideForever] = useState(false); + const hint = hints[0]; + + useEffect(() => { + // A failed load shows nothing, not an error. + fetchHints() + .then((response) => (response.ok ? response.json() : null)) + .then((data) => { + const closed = closedIds(); + setHints((data?.results ?? []).filter((hint) => !closed.includes(hint.id))); + }) + .catch(() => undefined); + }, []); + + // A failed save only shows an error: the hint comes back in the next browser session. + const close = useCallback(async (done) => { + rememberClosed(hint.id); + setHints((current) => current.slice(1)); + setHideForever(false); + if (!done) return; + try { + const response = await markHintDone(hint.id); + if (!response.ok) toast.error(t('HINT.SAVE_FAILED')); + } catch { + toast.error(t('INFO.ERROR.NETWORK_FAILURE')); + } + }, [hint, t]); + + useEffect(() => { + if (hint?.kind !== 'DISMISS') return undefined; + const timer = setTimeout(() => close(hideForever), AUTO_CLOSE_MS); + return () => clearTimeout(timer); + }, [hint, hideForever, close]); + + if (!hint) return null; + + // The next card shows under the same pointer: the second click of a double click must not close it unseen. + return ( + event.detail < 2 && close(hideForever)} + onAccept={(event) => event.detail < 2 && close(true)} + className="fixed inset-x-4 bottom-4 z-50 mx-auto max-w-md"/> + ); +} diff --git a/src/components/DashboardHint.test.jsx b/src/components/DashboardHint.test.jsx new file mode 100644 index 0000000..04e684b --- /dev/null +++ b/src/components/DashboardHint.test.jsx @@ -0,0 +1,127 @@ +// @vitest-environment jsdom +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {act, cleanup, fireEvent, render, screen} from '@testing-library/react'; +import {toast} from 'sonner'; +import DashboardHint, {AUTO_CLOSE_MS} from './DashboardHint.jsx'; +import {hintText} from '../utils/hints.js'; + +vi.mock('react-i18next', () => import('../test/i18n-mock.js')); +vi.mock('sonner', () => ({toast: {error: vi.fn()}})); + +const DISMISS = {id: 2, textEn: 'Server update tonight', textDe: 'Heute Nacht Update', kind: 'DISMISS'}; +const ACCEPT = {id: 1, textEn: 'Link your Steam account', textDe: null, kind: 'ACCEPT'}; + +const answer = (status, body) => ({ok: status < 300, status, headers: new Headers(), json: async () => body}); +const doneCalls = () => fetch.mock.calls.filter(([, init]) => init?.method === 'PUT').map(([url]) => url); + +const showHints = async (...hints) => { + globalThis.fetch = vi.fn(async (url, init) => (init?.method ? answer(204) : answer(200, {results: hints}))); + render(); + await act(async () => undefined); +}; + +describe('DashboardHint', () => { + beforeEach(() => vi.useFakeTimers({shouldAdvanceTime: true})); + + afterEach(() => { + cleanup(); + sessionStorage.clear(); + vi.useRealTimers(); + vi.clearAllMocks(); + }); + + it('shows one hint at a time and the next after closing', async () => { + await showHints(DISMISS, ACCEPT); + + expect(screen.getByText('Heute Nacht Update')).toBeTruthy(); + expect(screen.queryByText(ACCEPT.textEn)).toBeNull(); + + fireEvent.click(screen.getByLabelText('HINT.CLOSE')); + + expect(screen.getByText(ACCEPT.textEn)).toBeTruthy(); + expect(doneCalls()).toEqual([]); + }); + + it('does not show a hint closed in this tab again when the dashboard mounts again', async () => { + await showHints(DISMISS, ACCEPT); + fireEvent.click(screen.getByLabelText('HINT.CLOSE')); + cleanup(); + + render(); + await act(async () => undefined); + + expect(screen.queryByText(DISMISS.textDe)).toBeNull(); + expect(screen.getByText(ACCEPT.textEn)).toBeTruthy(); + expect(doneCalls()).toEqual([]); + }); + + it('stores a dismiss hint only when "Do not show again" is ticked', async () => { + await showHints(DISMISS); + + fireEvent.click(screen.getByLabelText('HINT.DO_NOT_SHOW_AGAIN')); + await act(async () => fireEvent.click(screen.getByLabelText('HINT.CLOSE'))); + + expect(screen.queryByRole('status')).toBeNull(); + expect(doneCalls()).toEqual([expect.stringContaining('/api/hints/2/done')]); + }); + + it('closes a dismiss hint by itself after 30 s without storing it', async () => { + await showHints(DISMISS); + + await act(async () => vi.advanceTimersByTime(AUTO_CLOSE_MS)); + + expect(screen.queryByRole('status')).toBeNull(); + expect(doneCalls()).toEqual([]); + }); + + it('keeps an accept hint until OK and stores it', async () => { + await showHints(ACCEPT); + + await act(async () => vi.advanceTimersByTime(AUTO_CLOSE_MS * 2)); + expect(screen.getByText(ACCEPT.textEn)).toBeTruthy(); + expect(screen.queryByLabelText('HINT.CLOSE')).toBeNull(); + + await act(async () => fireEvent.click(screen.getByText('HINT.OK'))); + + expect(screen.queryByRole('status')).toBeNull(); + expect(doneCalls()).toEqual([expect.stringContaining('/api/hints/1/done')]); + }); + + it('shows an error when the answer is not stored', async () => { + globalThis.fetch = vi.fn(async (url, init) => (init?.method ? answer(500) : answer(200, {results: [ACCEPT]}))); + render(); + await act(async () => undefined); + + await act(async () => fireEvent.click(screen.getByText('HINT.OK'))); + + expect(toast.error).toHaveBeenCalledWith('HINT.SAVE_FAILED'); + }); + + it('does not let a double click on OK accept the next hint unseen', async () => { + await showHints(ACCEPT, {...ACCEPT, id: 3, textEn: 'Rate your games'}); + + await act(async () => fireEvent.click(screen.getByText('HINT.OK'), {detail: 1})); + // The second click of the same double click lands on the next card's OK button. + await act(async () => fireEvent.click(screen.getByText('HINT.OK'), {detail: 2})); + + expect(doneCalls()).toEqual([expect.stringContaining('/api/hints/1/done')]); + expect(screen.getByText('Rate your games')).toBeTruthy(); + }); + + it('shows nothing when the hints do not load', async () => { + globalThis.fetch = vi.fn(async () => answer(500)); + render(); + await act(async () => undefined); + + expect(screen.queryByRole('status')).toBeNull(); + expect(toast.error).not.toHaveBeenCalled(); + }); +}); + +describe('hintText', () => { + it('takes the German text for a German UI only when there is one', () => { + expect(hintText(DISMISS, 'de-DE')).toBe(DISMISS.textDe); + expect(hintText(DISMISS, 'en')).toBe(DISMISS.textEn); + expect(hintText(ACCEPT, 'de')).toBe(ACCEPT.textEn); + }); +}); diff --git a/src/components/HintCard.jsx b/src/components/HintCard.jsx new file mode 100644 index 0000000..b20cbc1 --- /dev/null +++ b/src/components/HintCard.jsx @@ -0,0 +1,39 @@ +import {useId} from 'react'; +import {useTranslation} from 'react-i18next'; +import {Megaphone, X} from 'lucide-react'; + +import {Button} from '@/components/ui/button'; +import {Card} from '@/components/ui/card'; +import {cn} from '@/lib/utils'; +import {hintText} from '../utils/hints.js'; + +export default function HintCard({hint, hideForever, onHideForeverChange, onClose, onAccept, className}) { + const {t, i18n} = useTranslation(); + const checkboxId = useId(); + + return ( + +
+ +

{hintText(hint, i18n.language)}

+ {hint.kind === 'DISMISS' && ( + + )} +
+
+ {hint.kind === 'DISMISS' ? ( + + ) : ( + + )} +
+
+ ); +} diff --git a/src/pages/AdminHintsPage.jsx b/src/pages/AdminHintsPage.jsx new file mode 100644 index 0000000..8431aa5 --- /dev/null +++ b/src/pages/AdminHintsPage.jsx @@ -0,0 +1,211 @@ +import {useCallback, useEffect, useState} from 'react'; +import {useNavigate} from 'react-router-dom'; +import {useTranslation} from 'react-i18next'; +import {Loader2, Send, Square} from 'lucide-react'; +import {toast} from 'sonner'; + +import {Badge} from '@/components/ui/badge'; +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 HintCard from '../components/HintCard.jsx'; +import PageContainer from '../components/PageContainer.jsx'; +import useSessionExpired from '../hooks/useSessionExpired.js'; +import {hasSession} from '../utils/auth.js'; +import {createHint, endHint, fetchAdminHints} from '../utils/hints.js'; + +const MAX_TEXT = 500; +const WEEK_MS = 7 * 24 * 60 * 60 * 1000; + +// The value of a datetime-local input: local time, no seconds. +const toLocalInput = (date) => { + const local = new Date(date.getTime() - date.getTimezoneOffset() * 60_000); + return local.toISOString().slice(0, 16); +}; + +const emptyForm = () => { + const now = new Date(); + return {textEn: '', textDe: '', kind: 'DISMISS', startsAt: toLocalInput(now), endsAt: toLocalInput(new Date(now.getTime() + WEEK_MS))}; +}; + +/** + * PLATFORM_ADMIN only: write, preview and end the dashboard hints. Auth checks the role. + */ +export default function AdminHintsPage() { + const {t, i18n} = useTranslation(); + const navigate = useNavigate(); + const expireSession = useSessionExpired(); + const [hints, setHints] = useState([]); + const [status, setStatus] = useState('loading'); + const [form, setForm] = useState(emptyForm); + const [busy, setBusy] = useState(false); + + const load = useCallback(async () => { + try { + const response = await fetchAdminHints(); + if (response.status === 401) { + expireSession(); + return; + } + if (response.status === 403) { + setStatus('forbidden'); + return; + } + if (!response.ok) { + setStatus('error'); + return; + } + setHints((await response.json()).results); + setStatus('ready'); + } catch (err) { + console.error('Admin hints error:', err); + setStatus('error'); + } + }, [expireSession]); + + useEffect(() => { + if (!hasSession()) { + navigate('/login'); + return; + } + load(); + }, [load, navigate]); + + const run = async (request, doneKey, onOk) => { + setBusy(true); + try { + const response = await request(); + if (response.status === 401) { + expireSession(); + return; + } + if (response.ok) { + toast.success(t(doneKey)); + onOk?.(); + await load(); + } else { + const data = await response.json().catch(() => ({})); + toast.error(t(data.code === 'INVALID_HINT' ? 'ADMIN_HINTS.INVALID' : 'ADMIN_HINTS.ERROR')); + } + } catch (err) { + console.error('Admin hint action error:', err); + toast.error(t('INFO.ERROR.NETWORK_FAILURE')); + } finally { + setBusy(false); + } + }; + + const submit = (event) => { + event.preventDefault(); + run(() => createHint({ + textEn: form.textEn.trim(), + textDe: form.textDe.trim(), + kind: form.kind, + startsAt: new Date(form.startsAt).toISOString(), + endsAt: new Date(form.endsAt).toISOString(), + }), 'ADMIN_HINTS.CREATED', () => setForm(emptyForm())); + }; + + const change = (field) => (event) => setForm({...form, [field]: event.target.value}); + const formatDate = (value) => new Date(value).toLocaleString(i18n.language, {dateStyle: 'short', timeStyle: 'short'}); + + if (status === 'forbidden') { + return ( + +

{t('ADMIN_HINTS.NO_ACCESS')}

+
+ ); + } + + return ( + + + + {t('ADMIN_HINTS.NEW')} + + +
+
+ +