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
@@ -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
|
- **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
|
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.
|
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,
|
- **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.
|
||||||
- **Delete my account** in the settings: a dialog asks for the own email and the password. Auth blocks the
|
- **Delete my account** in the settings: a dialog asks for the own email and the password. Auth blocks the
|
||||||
|
|||||||
+36
-1
@@ -284,7 +284,8 @@
|
|||||||
"MENU": "Ansehen als",
|
"MENU": "Ansehen als",
|
||||||
"BADGE": "Ansicht als {{role}}",
|
"BADGE": "Ansicht als {{role}}",
|
||||||
"ERROR": "Die Rolle konnte nicht gewechselt werden."
|
"ERROR": "Die Rolle konnte nicht gewechselt werden."
|
||||||
}
|
},
|
||||||
|
"ADMIN_HINTS": "Hinweise"
|
||||||
},
|
},
|
||||||
"BACKLOG": {
|
"BACKLOG": {
|
||||||
"TITLE": "Mein Backlog",
|
"TITLE": "Mein Backlog",
|
||||||
@@ -545,5 +546,39 @@
|
|||||||
"YES": "Ja",
|
"YES": "Ja",
|
||||||
"NO": "Nein"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+36
-1
@@ -284,7 +284,8 @@
|
|||||||
"MENU": "View as",
|
"MENU": "View as",
|
||||||
"BADGE": "Viewing as {{role}}",
|
"BADGE": "Viewing as {{role}}",
|
||||||
"ERROR": "The role could not be switched."
|
"ERROR": "The role could not be switched."
|
||||||
}
|
},
|
||||||
|
"ADMIN_HINTS": "Hints"
|
||||||
},
|
},
|
||||||
"BACKLOG": {
|
"BACKLOG": {
|
||||||
"TITLE": "My backlog",
|
"TITLE": "My backlog",
|
||||||
@@ -545,5 +546,39 @@
|
|||||||
"YES": "Yes",
|
"YES": "Yes",
|
||||||
"NO": "No"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -8,6 +8,7 @@ import ImprintPage from './pages/ImprintPage';
|
|||||||
import Dashboard from "./pages/Dashboard.jsx";
|
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 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';
|
||||||
@@ -73,6 +74,7 @@ function App() {
|
|||||||
<Route path="/u/:name" element={<PublicProfilePage/>}/>
|
<Route path="/u/:name" element={<PublicProfilePage/>}/>
|
||||||
<Route path="/settings" element={<SettingsPage/>}/>
|
<Route path="/settings" element={<SettingsPage/>}/>
|
||||||
<Route path="/admin/users" element={<AdminUsersPage/>}/>
|
<Route path="/admin/users" element={<AdminUsersPage/>}/>
|
||||||
|
<Route path="/admin/hints" element={<AdminHintsPage/>}/>
|
||||||
<Route path="/impressum" element={<ImprintPage/>}/>
|
<Route path="/impressum" element={<ImprintPage/>}/>
|
||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
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, 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 {toast} from 'sonner';
|
||||||
|
|
||||||
import {Badge} from '@/components/ui/badge';
|
import {Badge} from '@/components/ui/badge';
|
||||||
@@ -112,9 +112,14 @@ export default function AppHeader({isAuthenticated, onLogout}) {
|
|||||||
<Link to="/settings"><Settings/>{t('NAV.SETTINGS')}</Link>
|
<Link to="/settings"><Settings/>{t('NAV.SETTINGS')}</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
{isAdmin && !viewingAs && (
|
{isAdmin && !viewingAs && (
|
||||||
<DropdownMenuItem asChild>
|
<>
|
||||||
<Link to="/admin/users"><Shield/>{t('NAV.ADMIN')}</Link>
|
<DropdownMenuItem asChild>
|
||||||
</DropdownMenuItem>
|
<Link to="/admin/users"><Shield/>{t('NAV.ADMIN')}</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link to="/admin/hints"><Megaphone/>{t('NAV.ADMIN_HINTS')}</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<DropdownMenuSub>
|
<DropdownMenuSub>
|
||||||
|
|||||||
@@ -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"/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 AchievementsCard from '../components/AchievementsCard.jsx';
|
||||||
import ActivityCard from '../components/ActivityCard.jsx';
|
import ActivityCard from '../components/ActivityCard.jsx';
|
||||||
import CurrentlyPlayingCard from '../components/CurrentlyPlayingCard.jsx';
|
import CurrentlyPlayingCard from '../components/CurrentlyPlayingCard.jsx';
|
||||||
|
import DashboardHint from '../components/DashboardHint.jsx';
|
||||||
import GameCover from '../components/GameCover.jsx';
|
import GameCover from '../components/GameCover.jsx';
|
||||||
import PileOfShameCard from '../components/PileOfShameCard.jsx';
|
import PileOfShameCard from '../components/PileOfShameCard.jsx';
|
||||||
import SteamGameCount from '../components/SteamGameCount';
|
import SteamGameCount from '../components/SteamGameCount';
|
||||||
@@ -365,6 +366,8 @@ const Dashboard = () => {
|
|||||||
|
|
||||||
{hasSteamId && activity && <ActivityCard activity={activity}/>}
|
{hasSteamId && activity && <ActivityCard activity={activity}/>}
|
||||||
{hasSteamId && achievements && <AchievementsCard summary={achievements}/>}
|
{hasSteamId && achievements && <AchievementsCard summary={achievements}/>}
|
||||||
|
{/* Last child: space-y gives every other child a bottom margin, which would move the fixed card. */}
|
||||||
|
<DashboardHint/>
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import {apiFetch} from './api.js';
|
||||||
|
|
||||||
|
const AUTH = () => import.meta.env.VITE_API_URL;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/hints: {results: [{id, textEn, textDe, kind, startsAt, endsAt}]}, the running hints the user has not
|
||||||
|
* closed for good, newest first.
|
||||||
|
*/
|
||||||
|
export const fetchHints = () => apiFetch(`${AUTH()}/api/hints`);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The hint never comes back for this user.
|
||||||
|
*/
|
||||||
|
export const markHintDone = (hintId) => apiFetch(`${AUTH()}/api/hints/${hintId}/done`, {method: 'PUT'});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PLATFORM_ADMIN only (403 otherwise): {results: [...]}, every hint with doneCount, newest first.
|
||||||
|
*/
|
||||||
|
export const fetchAdminHints = () => apiFetch(`${AUTH()}/api/admin/hints`);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 201 {id}, or 400 (code INVALID_HINT when the end is not after the start or not in the future).
|
||||||
|
*/
|
||||||
|
export const createHint = (hint) =>
|
||||||
|
apiFetch(`${AUTH()}/api/admin/hints`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify(hint),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const endHint = (hintId) => apiFetch(`${AUTH()}/api/admin/hints/${hintId}/end`, {method: 'PUT'});
|
||||||
|
|
||||||
|
export const hintText = (hint, language) => (language?.startsWith('de') && hint.textDe ? hint.textDe : hint.textEn);
|
||||||
Reference in new issue
Block a user