Public Access
feat(dashboard): show admin hints and add the hints admin page
This commit is contained in:
1 parent
6601277961
commit
01ae372b2d
12 files changed
+700
-6
No files matched your search
@@ -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 (
|
||||
<PageContainer className="px-4 py-6 md:px-8">
|
||||
<p className="text-sm text-muted-foreground">{t('ADMIN_HINTS.NO_ACCESS')}</p>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageContainer className="space-y-4 px-4 py-6 md:px-8">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('ADMIN_HINTS.NEW')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={submit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="hintTextEn">{t('ADMIN_HINTS.TEXT_EN')}</Label>
|
||||
<Textarea id="hintTextEn" required maxLength={MAX_TEXT} value={form.textEn} onChange={change('textEn')}/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="hintTextDe">{t('ADMIN_HINTS.TEXT_DE')}</Label>
|
||||
<Textarea id="hintTextDe" maxLength={MAX_TEXT} value={form.textDe} onChange={change('textDe')}/>
|
||||
<p className="text-xs text-muted-foreground">{t('ADMIN_HINTS.TEXT_HELP')}</p>
|
||||
</div>
|
||||
<fieldset className="space-y-2">
|
||||
<legend className="text-sm font-medium">{t('ADMIN_HINTS.KIND')}</legend>
|
||||
{['DISMISS', 'ACCEPT'].map((kind) => (
|
||||
<label key={kind} className="flex items-center gap-2 text-sm">
|
||||
<input type="radio" name="hintKind" className="accent-primary" value={kind}
|
||||
checked={form.kind === kind} onChange={change('kind')}/>
|
||||
{t(`ADMIN_HINTS.KIND_${kind}`)}
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="hintStartsAt">{t('ADMIN_HINTS.STARTS_AT')}</Label>
|
||||
<Input id="hintStartsAt" type="datetime-local" required value={form.startsAt} onChange={change('startsAt')}/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="hintEndsAt">{t('ADMIN_HINTS.ENDS_AT')}</Label>
|
||||
<Input id="hintEndsAt" type="datetime-local" required value={form.endsAt} onChange={change('endsAt')}/>
|
||||
</div>
|
||||
</div>
|
||||
{form.textEn.trim() && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">{t('ADMIN_HINTS.PREVIEW')}</p>
|
||||
<HintCard hint={{...form, textDe: form.textDe.trim()}} hideForever={false} onHideForeverChange={() => undefined}
|
||||
onClose={() => undefined} onAccept={() => undefined} className="max-w-md"/>
|
||||
</div>
|
||||
)}
|
||||
<Button type="submit" disabled={busy}>
|
||||
{busy ? <Loader2 className="animate-spin"/> : <Send/>}
|
||||
{t('ADMIN_HINTS.CREATE')}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('ADMIN_HINTS.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_HINTS.LOAD_ERROR')}</p>}
|
||||
{status === 'ready' && hints.length === 0 && <p className="text-sm text-muted-foreground">{t('ADMIN_HINTS.EMPTY')}</p>}
|
||||
{status === 'ready' && hints.length > 0 && (
|
||||
<ul className="divide-y">
|
||||
{hints.map((hint) => (
|
||||
<li key={hint.id} className="flex flex-wrap items-center gap-3 py-3">
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<p className="whitespace-pre-line break-words text-sm">{hint.textEn}</p>
|
||||
{hint.textDe && <p className="whitespace-pre-line break-words text-sm text-muted-foreground">{hint.textDe}</p>}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{[t('ADMIN_HINTS.RANGE', {start: formatDate(hint.startsAt), end: formatDate(hint.endsAt)}),
|
||||
t(`ADMIN_HINTS.KIND_${hint.kind}`),
|
||||
t('ADMIN_HINTS.DONE_COUNT', {count: hint.doneCount}),
|
||||
].join(' · ')}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant={hint.status === 'ACTIVE' ? 'default' : 'outline'}>{t(`ADMIN_HINTS.STATUS_${hint.status}`)}</Badge>
|
||||
{hint.status !== 'ENDED' && (
|
||||
<Button type="button" variant="outline" size="sm" disabled={busy}
|
||||
onClick={() => run(() => endHint(hint.id), 'ADMIN_HINTS.ENDED')}>
|
||||
<Square/>{t('ADMIN_HINTS.END')}
|
||||
</Button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
// @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 AdminHintsPage from './AdminHintsPage.jsx';
|
||||
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
|
||||
|
||||
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const at = (offsetDays) => new Date(Date.now() + offsetDays * DAY_MS).toISOString();
|
||||
const ACTIVE = {id: 2, textEn: 'Server update tonight', textDe: null, kind: 'DISMISS', startsAt: at(-1), endsAt: at(1), status: 'ACTIVE',
|
||||
createdBy: 1, createdAt: at(-1), doneCount: 3};
|
||||
const ENDED = {...ACTIVE, id: 1, textEn: 'Old news', startsAt: at(-3), endsAt: at(-2), status: 'ENDED', doneCount: 0};
|
||||
|
||||
const answer = (status, body) => ({ok: status < 300, status, headers: new Headers(), json: async () => body});
|
||||
const calls = (method) => fetch.mock.calls.filter(([, init]) => (init?.method ?? 'GET') === method);
|
||||
|
||||
const renderPage = () => render(<MemoryRouter><PreferencesProvider><AdminHintsPage/></PreferencesProvider></MemoryRouter>);
|
||||
|
||||
describe('AdminHintsPage', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.setItem('tbp_session', '1');
|
||||
globalThis.fetch = vi.fn(async (url, init) => {
|
||||
if (init?.method === 'POST') return answer(201, {id: 3});
|
||||
if (init?.method === 'PUT') return answer(204);
|
||||
return answer(200, {results: [ACTIVE, ENDED]});
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
localStorage.clear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('lists the hints and offers "End" only on a running one', async () => {
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByText('Server update tonight')).toBeTruthy();
|
||||
expect(screen.getByText('ADMIN_HINTS.STATUS_ACTIVE')).toBeTruthy();
|
||||
expect(screen.getByText('ADMIN_HINTS.STATUS_ENDED')).toBeTruthy();
|
||||
expect(screen.getAllByText('ADMIN_HINTS.END')).toHaveLength(1);
|
||||
|
||||
fireEvent.click(screen.getByText('ADMIN_HINTS.END'));
|
||||
|
||||
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('ADMIN_HINTS.ENDED'));
|
||||
expect(calls('PUT')[0][0]).toContain('/api/admin/hints/2/end');
|
||||
});
|
||||
|
||||
it('publishes a trimmed hint and shows a preview', async () => {
|
||||
renderPage();
|
||||
await screen.findByText('Server update tonight');
|
||||
|
||||
fireEvent.change(screen.getByLabelText('ADMIN_HINTS.TEXT_EN'), {target: {value: ' Link your Steam account '}});
|
||||
fireEvent.change(screen.getByLabelText('ADMIN_HINTS.TEXT_DE'), {target: {value: ' '}});
|
||||
fireEvent.click(screen.getByLabelText('ADMIN_HINTS.KIND_ACCEPT'));
|
||||
expect(screen.getByText('HINT.OK')).toBeTruthy();
|
||||
fireEvent.click(screen.getByText('ADMIN_HINTS.CREATE'));
|
||||
|
||||
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('ADMIN_HINTS.CREATED'));
|
||||
const body = JSON.parse(calls('POST')[0][1].body);
|
||||
expect(body).toMatchObject({textEn: 'Link your Steam account', textDe: '', kind: 'ACCEPT'});
|
||||
expect(new Date(body.endsAt) - new Date(body.startsAt)).toBe(7 * DAY_MS);
|
||||
expect(screen.getByLabelText('ADMIN_HINTS.TEXT_EN').value).toBe('');
|
||||
});
|
||||
|
||||
it('explains an invalid time range', async () => {
|
||||
fetch.mockImplementation(async (url, init) =>
|
||||
(init?.method === 'POST' ? answer(400, {code: 'INVALID_HINT'}) : answer(200, {results: []})));
|
||||
renderPage();
|
||||
await screen.findByText('ADMIN_HINTS.EMPTY');
|
||||
|
||||
fireEvent.change(screen.getByLabelText('ADMIN_HINTS.TEXT_EN'), {target: {value: 'News'}});
|
||||
fireEvent.click(screen.getByText('ADMIN_HINTS.CREATE'));
|
||||
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('ADMIN_HINTS.INVALID'));
|
||||
});
|
||||
|
||||
it('takes the status from the server, not from the browser clock', async () => {
|
||||
// The server ended the hint by its own clock; by the browser clock the end is still 5 s away.
|
||||
const endedOnServer = {...ACTIVE, endsAt: new Date(Date.now() + 5_000).toISOString(), status: 'ENDED'};
|
||||
fetch.mockImplementation(async () => answer(200, {results: [endedOnServer]}));
|
||||
renderPage();
|
||||
|
||||
await screen.findByText('Server update tonight');
|
||||
|
||||
expect(screen.queryByText('ADMIN_HINTS.STATUS_ACTIVE')).toBeNull();
|
||||
expect(screen.queryByText('ADMIN_HINTS.END')).toBeNull();
|
||||
});
|
||||
|
||||
it('previews the English text when the German one is only spaces', async () => {
|
||||
renderPage();
|
||||
await screen.findByText('Server update tonight');
|
||||
|
||||
fireEvent.change(screen.getByLabelText('ADMIN_HINTS.TEXT_EN'), {target: {value: 'Link your Steam account'}});
|
||||
fireEvent.change(screen.getByLabelText('ADMIN_HINTS.TEXT_DE'), {target: {value: ' '}});
|
||||
|
||||
// The mocked UI language is German.
|
||||
expect(document.querySelector('[role="status"] p').textContent).toBe('Link your Steam account');
|
||||
});
|
||||
|
||||
it('shows "no access" to a non-admin', async () => {
|
||||
fetch.mockImplementation(async () => answer(403));
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByText('ADMIN_HINTS.NO_ACCESS')).toBeTruthy();
|
||||
expect(screen.queryByText('ADMIN_HINTS.CREATE')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ADMIN_HINTS.DONE_COUNT', () => {
|
||||
it('uses the singular for one user', async () => {
|
||||
const {default: i18next} = await import('i18next');
|
||||
const en = (await import('../../public/i18n/en.json')).default;
|
||||
const i18n = i18next.createInstance();
|
||||
await i18n.init({lng: 'en', resources: {en: {translation: en}}});
|
||||
|
||||
expect(i18n.t('ADMIN_HINTS.DONE_COUNT', {count: 1})).not.toContain('users');
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@ import {Skeleton} from '@/components/ui/skeleton';
|
||||
import AchievementsCard from '../components/AchievementsCard.jsx';
|
||||
import ActivityCard from '../components/ActivityCard.jsx';
|
||||
import CurrentlyPlayingCard from '../components/CurrentlyPlayingCard.jsx';
|
||||
import DashboardHint from '../components/DashboardHint.jsx';
|
||||
import GameCover from '../components/GameCover.jsx';
|
||||
import PileOfShameCard from '../components/PileOfShameCard.jsx';
|
||||
import SteamGameCount from '../components/SteamGameCount';
|
||||
@@ -365,6 +366,8 @@ const Dashboard = () => {
|
||||
|
||||
{hasSteamId && activity && <ActivityCard activity={activity}/>}
|
||||
{hasSteamId && achievements && <AchievementsCard summary={achievements}/>}
|
||||
{/* Last child: space-y gives every other child a bottom margin, which would move the fixed card. */}
|
||||
<DashboardHint/>
|
||||
</PageContainer>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user