feat(dashboard): show admin hints and add the hints admin page
Build, Test & Push Frontend / quality-check (push) Successful in 18s
Build, Test & Push Frontend / docker (push) Successful in 25s

This commit is contained in:
lobo committed 2026-09-28 10:07:49 +02:00
1 parent 6601277961
commit 01ae372b2d
12 files changed
+700 -6

No files matched your search

+9 -4
View File
@@ -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}) {
<Link to="/settings"><Settings/>{t('NAV.SETTINGS')}</Link>
</DropdownMenuItem>
{isAdmin && !viewingAs && (
<DropdownMenuItem asChild>
<Link to="/admin/users"><Shield/>{t('NAV.ADMIN')}</Link>
</DropdownMenuItem>
<>
<DropdownMenuItem asChild>
<Link to="/admin/users"><Shield/>{t('NAV.ADMIN')}</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/admin/hints"><Megaphone/>{t('NAV.ADMIN_HINTS')}</Link>
</DropdownMenuItem>
</>
)}
{isAdmin && (
<DropdownMenuSub>
+78
View File
@@ -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 (
<HintCard key={hint.id} hint={hint} hideForever={hideForever} onHideForeverChange={setHideForever}
onClose={(event) => 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"/>
);
}
+127
View File
@@ -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(<DashboardHint/>);
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(<DashboardHint/>);
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(<DashboardHint/>);
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(<DashboardHint/>);
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);
});
});
+39
View File
@@ -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 (
<Card role="status" className={cn('gap-3 p-4 shadow-lg', className)}>
<div className="flex items-start gap-3">
<Megaphone className="mt-0.5 size-5 shrink-0 text-primary"/>
<p className="flex-1 whitespace-pre-line break-words text-sm">{hintText(hint, i18n.language)}</p>
{hint.kind === 'DISMISS' && (
<Button type="button" variant="ghost" size="icon" className="-mr-2 -mt-2 size-8"
aria-label={t('HINT.CLOSE')} onClick={onClose}>
<X/>
</Button>
)}
</div>
<div className="flex items-center justify-end gap-2">
{hint.kind === 'DISMISS' ? (
<label htmlFor={checkboxId} className="flex items-center gap-2 text-xs text-muted-foreground">
<input id={checkboxId} type="checkbox" className="accent-primary" checked={hideForever}
onChange={(event) => onHideForeverChange(event.target.checked)}/>
{t('HINT.DO_NOT_SHOW_AGAIN')}
</label>
) : (
<Button type="button" size="sm" onClick={onAccept}>{t('HINT.OK')}</Button>
)}
</div>
</Card>
);
}