From 45f5087227ca29de7a3d87205f304374434a9ddc Mon Sep 17 00:00:00 2001
From: lobo
Date: Wed, 21 Oct 2026 11:09:44 +0200
Subject: [PATCH] feat(library): set status and rating in one step (#56)
Library rows: the status badge is a menu that saves at once.
Game page: status and rating in the top card, saved on click.
Status changes and hiding a game show an Undo toast.
Other game actions moved into a "..." menu; split and unlink ask first.
New sorts: name, last played, time to beat; "last synced" is gone.
Page numbers and 44 px touch targets on phones.
---
README.md | 14 ++-
public/i18n/de.json | 10 +-
public/i18n/en.json | 10 +-
src/components/BacklogCard.jsx | 148 ++++++++-----------------
src/components/BacklogCard.test.jsx | 88 ++++++++++++---
src/components/EditionsCard.jsx | 15 ++-
src/components/EditionsCard.test.jsx | 38 ++++++-
src/components/GameHeroCard.jsx | 127 +++++++++++++--------
src/components/GameHeroCard.test.jsx | 109 ++++++++++++++++++
src/components/ManualGameCard.jsx | 3 +-
src/components/Pagination.jsx | 39 +++++++
src/components/PlayingHint.jsx | 2 +-
src/components/ProfileArtItems.jsx | 38 +++++++
src/components/ProfileArtMenu.jsx | 47 --------
src/components/RatingStars.jsx | 14 ++-
src/components/StatusMenu.jsx | 48 ++++++++
src/components/StatusMenu.test.jsx | 49 ++++++++
src/components/UnlinkDialog.jsx | 9 +-
src/hooks/useSaveBacklogField.js | 45 ++++++++
src/hooks/useSaveBacklogField.test.jsx | 106 ++++++++++++++++++
src/hooks/useSetStatus.js | 34 ++++--
src/hooks/useSetStatus.test.jsx | 46 ++++++++
src/pages/BacklogPage.jsx | 6 +-
src/pages/GameDetail.jsx | 15 +--
src/pages/GameDetail.test.jsx | 68 +++++++-----
src/pages/GameLibrary.jsx | 140 +++++++++++++----------
src/pages/GameLibrary.test.jsx | 112 ++++++++++++++++++-
src/pages/PlayNextPage.jsx | 5 +-
src/pages/PlayNextPage.test.jsx | 17 ++-
src/utils/backlog.js | 3 +
src/utils/pagination.js | 13 +++
src/utils/pagination.test.js | 25 +++++
32 files changed, 1091 insertions(+), 352 deletions(-)
create mode 100644 src/components/GameHeroCard.test.jsx
create mode 100644 src/components/Pagination.jsx
create mode 100644 src/components/ProfileArtItems.jsx
delete mode 100644 src/components/ProfileArtMenu.jsx
create mode 100644 src/components/StatusMenu.jsx
create mode 100644 src/components/StatusMenu.test.jsx
create mode 100644 src/hooks/useSaveBacklogField.js
create mode 100644 src/hooks/useSaveBacklogField.test.jsx
create mode 100644 src/utils/pagination.js
create mode 100644 src/utils/pagination.test.js
diff --git a/README.md b/README.md
index 6b3297f..e1c4612 100644
--- a/README.md
+++ b/README.md
@@ -75,8 +75,10 @@ flowchart LR
```
- **Landing page**: next to the community numbers and the top games, three blocks: games per platform, top genres and
finished games per year of all users.
-- **Library**: status filters, source filter (Steam, PlayStation, manual; a linked set matches on each of its sources), "short games" filter (up to 10 h), search, sorting by playtime, recently seen, newest
- in library, rating or priority, paging. Each row shows the unlocked achievements. A game you own on several
+- **Library**: status filters, source filter (Steam, PlayStation, manual; a linked set matches on each of its sources), "short games" filter (up to 10 h), search, sorting by playtime, newest
+ in library, rating, priority, name, last played or time to beat (games never played or without a time to beat come last), page
+ numbers with Previous / Next. Each row shows the unlocked achievements, and its status badge is a menu: choosing a status saves at once
+ (with an Undo in the toast and the points check). While sorted by rating or priority the row shows them. A game you own on several
platforms is one row (Core's counted edition) with a small badge per platform next to the title (`PlatformIcons`:
ST, PS or the platform name, from `platforms[]`; lucide has no brand icons).
- **Editions**: the editions of one game (Steam and PS5 copy, remaster, port) are linked automatically from IGDB's
@@ -314,7 +316,7 @@ flowchart LR
- `/questions` (`QuestionsPage`, nav item with the open count; the badge is hidden on the page, whose title shows the
live count) lists one card per game with an open question (Start it? / Finished? / Dropped? / Rate it?). Core decides
which question is open; `src/utils/questions.js` only loads the list and sends the answer. A yes to Finished? gets
- the Rate it? card back at once. `409` / `404` reload the list. `RatingStars` is shared with `BacklogCard`.
+ the Rate it? card back at once. `409` / `404` reload the list. `RatingStars` is shared with `GameHeroCard`.
Below the cards, a closed "Removed games" section (`RemovedGamesSection`, `GET /api/library/removed-games`) has a
Restore button per game (`POST /api/library/games/{appid}/restore`). The toast after "Remove from library" on a
manual game has an Undo that restores it the same way.
@@ -339,10 +341,12 @@ flowchart LR
Detail -->|"Same game as... (request)"| Request["Core POST /api/library/games/{appid}/link-requests"]
Detail -->|"Unlink (auditor)"| Unlink["Core DELETE /api/library/games/{appid}/link"]
Split --> Reload["reload the detail"]
- Backlog["BacklogCard save"] -->|"game has editions"| Reload
+ Backlog["GameHeroCard / BacklogCard save"] -->|"game has editions"| Reload
```
+- The game page edits at once: status (menu with Undo) and rating in `GameHeroCard`, priority in `BacklogCard`; only the
+ notes there have a Save button. Background, banner, profile art, hide (with Undo) and the auditor tools sit in the "..." menu of the hero card.
- `EditionsCard` shows "Not the same game" on every game that has other editions in the own library. The call goes
- through `src/utils/links.js` (`splitGame`); `404 NOT_LINKED` shows the generic error toast.
+ through `src/utils/links.js` (`splitGame`) after a confirm dialog (unlink asks too); `404 NOT_LINKED` shows the generic error toast.
```mermaid
flowchart LR
diff --git a/public/i18n/de.json b/public/i18n/de.json
index 8a8e654..1245056 100644
--- a/public/i18n/de.json
+++ b/public/i18n/de.json
@@ -289,7 +289,10 @@
"SORT_BY": "Sortieren:",
"SORT_PLAYTIME_DESC": "Meistgespielt",
"SORT_PLAYTIME_ASC": "Am wenigsten gespielt",
- "SORT_LAST_SEEN": "Zuletzt synchronisiert",
+ "SORT_NAME": "Name A–Z",
+ "SORT_LAST_PLAYED": "Zuletzt gespielt",
+ "SORT_TIME_TO_BEAT": "Kürzeste Spieldauer",
+ "PAGINATION": "Seiten",
"SORT_FIRST_SEEN": "Neueste in Bibliothek",
"PREV": "Zurück",
"NEXT": "Weiter",
@@ -318,6 +321,7 @@
"EDITIONS": "{{count}} Editionen in deiner Bibliothek"
},
"GAME_DETAIL": {
+ "MORE": "Weitere Aktionen",
"BACK": "Zurück",
"BACK_TO_LIBRARY": "Zur Bibliothek",
"LOADING": "Lade Spiel …",
@@ -840,6 +844,10 @@
"NEVER": "nie"
},
"LINKS": {
+ "SPLIT_CONFIRM_TITLE": "Nicht dasselbe Spiel?",
+ "SPLIT_CONFIRM_TEXT": "Das Spiel wird ein eigener Eintrag in deiner Bibliothek mit eigenem Status, eigener Bewertung und eigenen Notizen. Die Verknüpfung für andere Nutzer bleibt.",
+ "UNLINK_CONFIRM_TITLE": "Für alle trennen?",
+ "UNLINK_CONFIRM_TEXT": "Entfernt die Verknüpfung dieser Spiele für alle Nutzer.",
"ALSO_IN_LIBRARY": "Auch in deiner Bibliothek:",
"SAME_AS": "Dasselbe Spiel wie...",
"LINK_WITH": "Verknüpfen mit...",
diff --git a/public/i18n/en.json b/public/i18n/en.json
index 8d892b2..bacd5fb 100644
--- a/public/i18n/en.json
+++ b/public/i18n/en.json
@@ -289,7 +289,10 @@
"SORT_BY": "Sort by:",
"SORT_PLAYTIME_DESC": "Most played",
"SORT_PLAYTIME_ASC": "Least played",
- "SORT_LAST_SEEN": "Last synced",
+ "SORT_NAME": "Name A–Z",
+ "SORT_LAST_PLAYED": "Last played",
+ "SORT_TIME_TO_BEAT": "Shortest to beat",
+ "PAGINATION": "Pages",
"SORT_FIRST_SEEN": "Newest in library",
"PREV": "Previous",
"NEXT": "Next",
@@ -318,6 +321,7 @@
"EDITIONS": "{{count}} editions in your library"
},
"GAME_DETAIL": {
+ "MORE": "More actions",
"BACK": "Back",
"BACK_TO_LIBRARY": "Back to library",
"LOADING": "Loading game …",
@@ -840,6 +844,10 @@
"NEVER": "never"
},
"LINKS": {
+ "SPLIT_CONFIRM_TITLE": "Not the same game?",
+ "SPLIT_CONFIRM_TEXT": "The game becomes a separate entry in your library with its own status, rating and notes. The link for other users stays.",
+ "UNLINK_CONFIRM_TITLE": "Unlink for everyone?",
+ "UNLINK_CONFIRM_TEXT": "Removes the link between these games for every user.",
"ALSO_IN_LIBRARY": "Also in your library:",
"SAME_AS": "Same game as...",
"LINK_WITH": "Link with...",
diff --git a/src/components/BacklogCard.jsx b/src/components/BacklogCard.jsx
index 74bdb4f..1c038c8 100644
--- a/src/components/BacklogCard.jsx
+++ b/src/components/BacklogCard.jsx
@@ -1,6 +1,5 @@
-import {useEffect, useState} from 'react';
+import {useState} from 'react';
import {useTranslation} from 'react-i18next';
-import {toast} from 'sonner';
import {ListTodo, Loader2} from 'lucide-react';
import {Button} from '@/components/ui/button';
@@ -8,58 +7,25 @@ import {Card} from '@/components/ui/card';
import {Label} from '@/components/ui/label';
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/components/ui/select';
import {Textarea} from '@/components/ui/textarea';
-import RatingStars from './RatingStars.jsx';
-import {STATUS_META} from './GameStatusBadge.jsx';
-import {changedFields, NO_PRIORITY, saveBacklogEntry} from '../utils/backlog.js';
-import {GAME_PRIORITIES, GAME_STATUSES, MAX_NOTES_LENGTH} from '../utils/limits.js';
+import {changedFields, NO_PRIORITY} from '../utils/backlog.js';
+import {GAME_PRIORITIES, MAX_NOTES_LENGTH} from '../utils/limits.js';
+import useSaveBacklogField from '../hooks/useSaveBacklogField.js';
import useUnsavedChanges from '../hooks/useUnsavedChanges.js';
-const toForm = (entry) => ({
- status: entry.status ?? 'BACKLOG',
- rating: entry.rating ?? 0,
- notes: entry.notes ?? '',
- priority: entry.priority ?? NO_PRIORITY,
-});
-
+/**
+ * Priority (saved on change) and notes (saved with the Save button). Status and rating live in the hero card.
+ *
+ * @param onSaved gets the saved backlog entry
+ */
export default function BacklogCard({game, onSaved}) {
const {t} = useTranslation();
- const [saved, setSaved] = useState(() => toForm(game));
- const [form, setForm] = useState(saved);
+ const [notes, setNotes] = useState(game.notes ?? '');
+ const {save, pending, busy} = useSaveBacklogField(game.appid, {onSaved});
- // The PlayingHint can change the status from outside; keep the other unsaved edits.
- useEffect(() => {
- setSaved((current) => (current.status === game.status ? current : {...current, status: game.status}));
- setForm((current) => (current.status === game.status ? current : {...current, status: game.status}));
- }, [game.status]);
- const [busy, setBusy] = useState(false);
-
- const changes = changedFields(saved, form);
- const dirty = Object.keys(changes).length > 0;
+ const savingNotes = pending?.field === 'notes';
+ const dirty = 'notes' in changedFields({notes: game.notes}, {notes});
useUnsavedChanges(dirty);
- const set = (field) => (value) => setForm((f) => ({...f, [field]: value}));
-
- const save = async () => {
- setBusy(true);
- try {
- const response = await saveBacklogEntry(game.appid, changes);
- if (response.status === 401) return;
- if (!response.ok) {
- toast.error(t('BACKLOG.SAVE_FAILED'));
- return;
- }
- const entry = await response.json();
- const next = toForm(entry);
- setSaved(next);
- onSaved?.(entry);
- setForm(next);
- toast.success(t('BACKLOG.SAVED'));
- } catch (err) {
- console.error('backlog save error:', err);
- toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
- } finally {
- setBusy(false);
- }
- };
+ const priority = pending?.field === 'priority' ? pending.value : (game.priority ?? NO_PRIORITY);
return (
@@ -68,68 +34,42 @@ export default function BacklogCard({game, onSaved}) {
{t('BACKLOG.TITLE')}
- {/* Locked while saving: the answer replaces the form, so an edit made meanwhile would be lost. */}
-
-
-
- {t('BACKLOG.STATUS_LABEL')}
-
-
-
- {GAME_STATUSES.map((s) => {
- const {icon: Icon, text} = STATUS_META[s];
- return (
-
-
- {t(`BACKLOG.STATUS.${s}`)}
-
- );
- })}
-
-
-
+
+ {t('BACKLOG.PRIORITY_LABEL')}
+ save('priority', value)} disabled={busy}>
+
+
+ {t('BACKLOG.PRIORITY.NONE')}
+ {GAME_PRIORITIES.map((p) => (
+ {t(`BACKLOG.PRIORITY.${p}`)}
+ ))}
+
+
+
-
- {t('BACKLOG.PRIORITY_LABEL')}
-
-
-
- {t('BACKLOG.PRIORITY.NONE')}
- {GAME_PRIORITIES.map((p) => (
- {t(`BACKLOG.PRIORITY.${p}`)}
- ))}
-
-
-
-
-
- {t('BACKLOG.RATING_LABEL')}
-
-
+
+
+ {t('BACKLOG.NOTES_LABEL')}
+ {notes.length} / {MAX_NOTES_LENGTH}
-
-
-
- {t('BACKLOG.NOTES_LABEL')}
- {form.notes.length} / {MAX_NOTES_LENGTH}
-
-
-
+ {/* Locked while saving: an edit made meanwhile would be lost. */}
+
- setForm(saved)} disabled={!dirty || busy}>
+ setNotes(game.notes ?? '')} disabled={!dirty || busy}>
{t('BACKLOG.RESET')}
-
- {busy && }
+ save('notes', notes)} disabled={!dirty || busy}>
+ {savingNotes && }
{t('BACKLOG.SAVE')}
diff --git a/src/components/BacklogCard.test.jsx b/src/components/BacklogCard.test.jsx
index 438445a..712f839 100644
--- a/src/components/BacklogCard.test.jsx
+++ b/src/components/BacklogCard.test.jsx
@@ -2,6 +2,7 @@
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {act, cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
import {MemoryRouter} from 'react-router-dom';
+import {toast} from 'sonner';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
import BacklogCard from './BacklogCard.jsx';
@@ -10,45 +11,88 @@ vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
const game = {appid: 220, status: 'PLAYING', rating: 4, notes: 'old', priority: null};
const saveButton = () => screen.getByRole('button', {name: 'BACKLOG.SAVE'});
+const PATCH = 'PATCH /api/library/games/220/backlog';
+const openPriority = () => fireEvent.keyDown(screen.getByRole('combobox'), {key: 'Enter'});
+const patchBodies = () => fetch.mock.calls.filter(([, init]) => init?.method === 'PATCH').map(([, init]) => JSON.parse(init.body));
describe('BacklogCard', () => {
beforeEach(() => {
+ // jsdom lacks both; the Radix select needs them when it opens.
+ Element.prototype.hasPointerCapture ??= () => false;
+ Element.prototype.scrollIntoView ??= () => {};
localStorage.setItem('tbp_session', '1');
// Core answers the whole entry; the fields not sent come from the server.
- fetchRoutes({'PATCH /api/library/games/220/backlog': (url, init) => json(200, {...game, ...JSON.parse(init.body)})});
+ fetchRoutes({[PATCH]: (url, init) => json(200, {...game, ...JSON.parse(init.body)})});
});
afterEach(() => {
cleanup();
+ vi.clearAllMocks();
localStorage.clear();
});
- it('enables saving only after a change', () => {
+ it('showsNeitherStatusNorRating', () => {
+ render(
);
+
+ expect(screen.queryByText('BACKLOG.STATUS_LABEL')).toBeNull();
+ expect(screen.queryByText('BACKLOG.RATING_LABEL')).toBeNull();
+ expect(screen.queryByRole('button', {name: /BACKLOG.RATING_STARS/})).toBeNull();
+ });
+
+ it('savesAPriorityAtOnceAndOnlyThatField', async () => {
+ const onSaved = vi.fn();
+ render(
);
+
+ openPriority();
+ fireEvent.click(await screen.findByRole('option', {name: 'BACKLOG.PRIORITY.HIGH'}));
+
+ await waitFor(() => expect(onSaved).toHaveBeenCalledWith({...game, priority: 'HIGH'}));
+ expect(patchBodies()).toEqual([{priority: 'HIGH'}]);
+ });
+
+ it('clearsThePriorityWithNull', async () => {
+ const onSaved = vi.fn();
+ render(
);
+
+ openPriority();
+ fireEvent.click(await screen.findByRole('option', {name: 'BACKLOG.PRIORITY.NONE'}));
+
+ await waitFor(() => expect(onSaved).toHaveBeenCalled());
+ expect(patchBodies()).toEqual([{priority: null}]);
+ });
+
+ it('showsTheSavedPriorityAgainWhenSavingFails', async () => {
+ fetchRoutes({[PATCH]: json(500, {})});
+ render(
);
+
+ openPriority();
+ fireEvent.click(await screen.findByRole('option', {name: 'BACKLOG.PRIORITY.HIGH'}));
+
+ await waitFor(() => expect(toast.error).toHaveBeenCalledWith('BACKLOG.SAVE_FAILED'));
+ expect(screen.getByRole('combobox').textContent).toContain('BACKLOG.PRIORITY.LOW');
+ });
+
+ it('enablesSavingOnlyAfterANotesChange', () => {
render(
);
expect(saveButton().disabled).toBe(true);
- fireEvent.click(screen.getByRole('button', {name: 'BACKLOG.RATING_STARS:5'}));
+ fireEvent.change(screen.getByRole('textbox'), {target: {value: 'new notes'}});
expect(saveButton().disabled).toBe(false);
});
- it('clears the rating on a click on the current star and sends blank notes as null', async () => {
+ it('sendsBlankNotesAsNull', async () => {
const onSaved = vi.fn();
render(
);
- fireEvent.click(screen.getByRole('button', {name: 'BACKLOG.RATING_STARS:4'}));
fireEvent.change(screen.getByRole('textbox'), {target: {value: ' '}});
fireEvent.click(saveButton());
await waitFor(() => expect(onSaved).toHaveBeenCalled());
- const [url, init] = fetch.mock.calls[0];
- expect(url).toMatch(/\/api\/library\/games\/220\/backlog$/);
- expect(init.method).toBe('PATCH');
- expect(JSON.parse(init.body)).toEqual({rating: null, notes: null});
- expect(saveButton().disabled).toBe(true);
+ expect(patchBodies()).toEqual([{notes: null}]);
});
- it('sends only the changed notes, so an older tab does not overwrite the other fields', async () => {
+ it('sendsOnlyTheChangedNotes', async () => {
const onSaved = vi.fn();
render(
);
@@ -56,31 +100,39 @@ describe('BacklogCard', () => {
fireEvent.click(saveButton());
await waitFor(() => expect(onSaved).toHaveBeenCalled());
- expect(JSON.parse(fetch.mock.calls[0][1].body)).toEqual({notes: 'new notes'});
+ expect(patchBodies()).toEqual([{notes: 'new notes'}]);
});
- it('locks the fields while the save runs, so nothing typed then is lost', async () => {
+ it('locksTheNotesWhileTheSaveRuns', async () => {
let answer;
- fetchRoutes({'PATCH /api/library/games/220/backlog': (url, init) => new Promise((resolve) => {
+ fetchRoutes({[PATCH]: (url, init) => new Promise((resolve) => {
answer = () => resolve(json(200, {...game, ...JSON.parse(init.body)}));
})});
render(
);
- fireEvent.click(screen.getByRole('button', {name: 'BACKLOG.RATING_STARS:5'}));
+ fireEvent.change(screen.getByRole('textbox'), {target: {value: 'new notes'}});
fireEvent.click(saveButton());
- // :disabled, not .disabled: the fieldset locks them, not their own attribute.
expect(screen.getByRole('textbox').matches(':disabled')).toBe(true);
- expect(screen.getByRole('button', {name: 'BACKLOG.RATING_STARS:1'}).matches(':disabled')).toBe(true);
await act(async () => answer());
expect(screen.getByRole('textbox').matches(':disabled')).toBe(false);
});
- it('does not count blank notes instead of none as a change', () => {
+ it('doesNotCountBlankNotesInsteadOfNoneAsAChange', () => {
render(
);
fireEvent.change(screen.getByRole('textbox'), {target: {value: ' '}});
expect(saveButton().disabled).toBe(true);
});
+
+ it('resetBringsBackTheSavedNotes', () => {
+ render(
);
+
+ fireEvent.change(screen.getByRole('textbox'), {target: {value: 'draft'}});
+ fireEvent.click(screen.getByRole('button', {name: 'BACKLOG.RESET'}));
+
+ expect(screen.getByRole('textbox').value).toBe('old');
+ expect(saveButton().disabled).toBe(true);
+ });
});
diff --git a/src/components/EditionsCard.jsx b/src/components/EditionsCard.jsx
index 139e045..9e879cb 100644
--- a/src/components/EditionsCard.jsx
+++ b/src/components/EditionsCard.jsx
@@ -7,6 +7,7 @@ import {toast} from 'sonner';
import {Button} from '@/components/ui/button';
import {Card} from '@/components/ui/card';
import GameStatusBadge from './GameStatusBadge.jsx';
+import UnlinkDialog from './UnlinkDialog.jsx';
import FitText from './FitText.jsx';
import {splitGame, unlinkGame} from '../utils/links.js';
import {platformLabel} from '../utils/platforms.js';
@@ -65,16 +66,14 @@ export default function EditionsCard({game, auditor, onRequest, onChanged}) {
{t(auditor ? 'LINKS.LINK_WITH' : 'LINKS.SAME_AS')}
{game.editions?.length > 0 && (
-
run(splitGame, 'LINKS.SPLIT_DONE')}>
- {t('LINKS.SPLIT')}
-
+
run(splitGame, 'LINKS.SPLIT_DONE')}/>
)}
{auditor && game.linked && (
- run(unlinkGame, 'LINKS.UNLINKED')}>
- {t('LINKS.UNLINK')}
-
+ run(unlinkGame, 'LINKS.UNLINKED')}/>
)}
diff --git a/src/components/EditionsCard.test.jsx b/src/components/EditionsCard.test.jsx
index 23336b9..c5e5994 100644
--- a/src/components/EditionsCard.test.jsx
+++ b/src/components/EditionsCard.test.jsx
@@ -1,10 +1,10 @@
// @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
-import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
+import {cleanup, fireEvent, render, screen, waitFor, within} from '@testing-library/react';
import {MemoryRouter} from 'react-router-dom';
import {toast} from 'sonner';
import EditionsCard from './EditionsCard.jsx';
-import {splitGame} from '../utils/links.js';
+import {splitGame, unlinkGame} from '../utils/links.js';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
@@ -16,6 +16,11 @@ const renderCard = (game, onChanged = vi.fn()) => {
return onChanged;
};
+const confirmSplit = () => {
+ fireEvent.click(screen.getByRole('button', {name: 'LINKS.SPLIT'}));
+ fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', {name: 'LINKS.SPLIT'}));
+};
+
describe('EditionsCard', () => {
beforeEach(() => vi.clearAllMocks());
afterEach(cleanup);
@@ -33,11 +38,21 @@ describe('EditionsCard', () => {
expect(screen.getByText('LINKS.SHARED_STATUS')).toBeTruthy();
});
+ it('asksBeforeSplittingAndSendsNothingOnCancel', () => {
+ renderCard({appid: 220, editions: [edition]});
+
+ fireEvent.click(screen.getByRole('button', {name: 'LINKS.SPLIT'}));
+
+ expect(screen.getByText('LINKS.SPLIT_CONFIRM_TITLE')).toBeTruthy();
+ fireEvent.click(screen.getByRole('button', {name: 'DIALOG.CANCEL'}));
+ expect(splitGame).not.toHaveBeenCalled();
+ });
+
it('splitsTheGameAndReloads', async () => {
splitGame.mockResolvedValue({ok: true, status: 204});
const onChanged = renderCard({appid: 220, editions: [edition]});
- fireEvent.click(screen.getByText('LINKS.SPLIT'));
+ confirmSplit();
await waitFor(() => expect(onChanged).toHaveBeenCalled());
expect(splitGame).toHaveBeenCalledWith(220);
@@ -48,9 +63,24 @@ describe('EditionsCard', () => {
splitGame.mockResolvedValue({ok: false, status: 404});
const onChanged = renderCard({appid: 220, editions: [edition]});
- fireEvent.click(screen.getByText('LINKS.SPLIT'));
+ confirmSplit();
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('LINKS.ERROR'));
expect(onChanged).not.toHaveBeenCalled();
});
+
+ it('asksBeforeUnlinkingAndUnlinksOnConfirm', async () => {
+ unlinkGame.mockResolvedValue({ok: true, status: 204});
+ const onChanged = vi.fn();
+ render( );
+
+ fireEvent.click(screen.getByRole('button', {name: 'LINKS.UNLINK'}));
+ expect(unlinkGame).not.toHaveBeenCalled();
+ fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', {name: 'LINKS.UNLINK'}));
+
+ await waitFor(() => expect(onChanged).toHaveBeenCalled());
+ expect(unlinkGame).toHaveBeenCalledWith(220);
+ expect(toast.success).toHaveBeenCalledWith('LINKS.UNLINKED');
+ });
});
diff --git a/src/components/GameHeroCard.jsx b/src/components/GameHeroCard.jsx
index e5a4838..9d5fcde 100644
--- a/src/components/GameHeroCard.jsx
+++ b/src/components/GameHeroCard.jsx
@@ -1,20 +1,31 @@
import {useState} from 'react';
import {useTranslation} from 'react-i18next';
-import {Clock, ExternalLink, Eye, EyeOff, Gamepad2, Image as ImageIcon, ImageOff, Merge, PanelTop, PencilLine, RotateCcw, Timer} from 'lucide-react';
+import {Clock, Ellipsis, ExternalLink, Eye, EyeOff, Gamepad2, Image as ImageIcon, ImageOff, Merge, PanelTop, PencilLine, RotateCcw, Timer} from 'lucide-react';
import {toast} from 'sonner';
import {Badge} from '@/components/ui/badge';
import {Button} from '@/components/ui/button';
import {Card} from '@/components/ui/card';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu';
import {cn} from '@/lib/utils';
import GameCover from './GameCover.jsx';
import FitText from './FitText.jsx';
import ComingBadge from './ComingBadge.jsx';
-import GameStatusBadge from './GameStatusBadge.jsx';
-import ProfileArtMenu from './ProfileArtMenu.jsx';
+import ProfileArtItems from './ProfileArtItems.jsx';
+import RatingStars from './RatingStars.jsx';
+import StatusMenu from './StatusMenu.jsx';
import TimeToBeat from './TimeToBeat.jsx';
import useSaveAppearance from '../hooks/useSaveAppearance.js';
+import useSaveBacklogField from '../hooks/useSaveBacklogField.js';
+import useSetStatus from '../hooks/useSetStatus.js';
import {usePreferences} from '../context/PreferencesContext.jsx';
+import {UNDO_MS} from '../utils/backlog.js';
import {formatPlaytime} from '../utils/games.js';
import {setGameHidden} from '../utils/hiddenGames.js';
import {isSteamKey} from '../utils/manualGames.js';
@@ -25,11 +36,19 @@ const SPEEDRUN_PREFIX = 'https://www.speedrun.com/';
// The spans of each completed effect decoration; effects.css places them.
const DETAIL_ELEMENTS = {'fx-detail-gold-shine': 8, 'fx-detail-neon-pulse': 1, 'fx-detail-holo': 1, 'fx-detail-ember': 6, 'fx-detail-platinum': 6};
-export default function GameHeroCard({game, auditor, profileStyle, onCorrect, onMerge, onHiddenChanged, onProfileStyleSaved}) {
+/**
+ * Status and rating are edited here and save at once. A status change needs no points check: GameDetail's
+ * GamePointsLine sees the new status and does it.
+ *
+ * @param onSaved gets the saved backlog entry after a status or rating change
+ */
+export default function GameHeroCard({game, auditor, profileStyle, onCorrect, onMerge, onHiddenChanged, onProfileStyleSaved, onSaved}) {
const {t} = useTranslation();
const {appearance, equipped} = usePreferences();
const saveAppearance = useSaveAppearance();
const [hiding, setHiding] = useState(false);
+ const {setStatus, busy: statusBusy} = useSetStatus(game.appid, {onChanged: onSaved});
+ const {save, pending, busy: ratingBusy} = useSaveBacklogField(game.appid, {onSaved});
const onSteam = isSteamKey(game.appid);
// Core checks the link too; a link from the API must never run script.
@@ -39,8 +58,7 @@ export default function GameHeroCard({game, auditor, profileStyle, onCorrect, on
const isBanner = appearance.showBanner && appearance.bannerAppid === game.appid;
const detailFx = detailEffect(equipped, game.status);
- const toggleHidden = async () => {
- const hidden = !game.hidden;
+ const applyHidden = async (hidden, {undoable = false} = {}) => {
setHiding(true);
try {
const response = await setGameHidden(game.appid, hidden);
@@ -50,7 +68,12 @@ export default function GameHeroCard({game, auditor, profileStyle, onCorrect, on
return;
}
onHiddenChanged(hidden);
- toast.success(t(hidden ? 'GAME_DETAIL.HIDDEN_TOAST' : 'GAME_DETAIL.SHOWN_TOAST'));
+ const message = t(hidden ? 'GAME_DETAIL.HIDDEN_TOAST' : 'GAME_DETAIL.SHOWN_TOAST');
+ if (undoable) {
+ toast.success(message, {duration: UNDO_MS, action: {label: t('MANUAL_GAME.UNDO'), onClick: () => applyHidden(!hidden)}});
+ } else {
+ toast.success(message);
+ }
} catch (err) {
console.error('Hide game error:', err);
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
@@ -79,11 +102,17 @@ export default function GameHeroCard({game, auditor, profileStyle, onCorrect, on
{t('GAME_DETAIL.PLAYTIME', {time: formatPlaytime(game.totalPlaytimeMinutes ?? game.playtimeMinutes)})}
-
+ setStatus(status, {previous: game.status})}/>
{platformLabel(game) && (
{platformLabel(game)}
)}
+
+ {t('BACKLOG.RATING_LABEL')}
+ save('rating', value)} labelledBy="hero-rating" disabled={ratingBusy}/>
+
@@ -94,52 +123,56 @@ export default function GameHeroCard({game, auditor, profileStyle, onCorrect, on
)}
- {auditor && (
-
- {t('CORRECTION.OPEN')}
-
- )}
- {auditor && (
-
- {t('MERGE.OPEN')}
-
- )}
- {/* Store page, background and banner need Steam art, so a game off Steam has none of them. */}
+ {/* Background, banner and profile art need Steam art, so a game off Steam has none of them. */}
- {onSteam && (<>
+ {onSteam && (
{t('GAME_DETAIL.OPEN_IN_STEAM')}
- {isBackground ? (
-
saveAppearance({backgroundAppid: null})}>
- {t('GAME_DETAIL.REMOVE_BACKGROUND')}
-
- ) : (
-
saveAppearance({backgroundAppid: game.appid})}>
- {t('GAME_DETAIL.SET_BACKGROUND')}
-
- )}
- {isBanner ? (
-
saveAppearance({bannerAppid: null})}>
- {t('SETTINGS.BANNER_AUTO')}
-
- ) : (
-
saveAppearance({bannerAppid: game.appid, showBanner: true})}>
- {t('GAME_DETAIL.SET_BANNER')}
-
- )}
- {profileStyle && (
-
- )}
- >)}
-
- {game.hidden ? : }
- {t(game.hidden ? 'GAME_DETAIL.UNHIDE' : 'GAME_DETAIL.HIDE')}
-
+ )}
+
+
+
+
+
+ {onSteam && (<>
+ {isBackground ? (
+ saveAppearance({backgroundAppid: null})}>
+ {t('GAME_DETAIL.REMOVE_BACKGROUND')}
+
+ ) : (
+ saveAppearance({backgroundAppid: game.appid})}>
+ {t('GAME_DETAIL.SET_BACKGROUND')}
+
+ )}
+ {isBanner ? (
+ saveAppearance({bannerAppid: null})}>
+ {t('SETTINGS.BANNER_AUTO')}
+
+ ) : (
+ saveAppearance({bannerAppid: game.appid, showBanner: true})}>
+ {t('GAME_DETAIL.SET_BANNER')}
+
+ )}
+ {profileStyle && (
+
+ )}
+
+ >)}
+ applyHidden(!game.hidden, {undoable: true})}>
+ {game.hidden ? : }
+ {t(game.hidden ? 'GAME_DETAIL.UNHIDE' : 'GAME_DETAIL.HIDE')}
+
+ {auditor && (<>
+
+ {t('CORRECTION.OPEN')}
+ {t('MERGE.OPEN')}
+ >)}
+
+
diff --git a/src/components/GameHeroCard.test.jsx b/src/components/GameHeroCard.test.jsx
new file mode 100644
index 0000000..bc8e8df
--- /dev/null
+++ b/src/components/GameHeroCard.test.jsx
@@ -0,0 +1,109 @@
+// @vitest-environment jsdom
+import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
+import {act, cleanup, fireEvent, screen, waitFor} from '@testing-library/react';
+import {toast} from 'sonner';
+import GameHeroCard from './GameHeroCard.jsx';
+import {fetchRoutes, json} from '../test/fetchRoutes.js';
+import {renderWithProviders} from '../test/renderWithProviders.jsx';
+
+vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
+vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
+
+const game = {appid: 220, name: 'Half-Life 2', status: 'BACKLOG', rating: null, hidden: false, playtimeMinutes: 10};
+
+const routes = (extra = {}) => fetchRoutes({
+ 'GET /api/users/me': json(200, {settings: {}}),
+ 'PUT /api/library/games/220/status': (url, init) => json(200, {...game, ...JSON.parse(init.body)}),
+ 'PATCH /api/library/games/220/backlog': (url, init) => json(200, {...game, ...JSON.parse(init.body)}),
+ 'PUT /api/library/games/220/hidden': (url, init) => json(200, JSON.parse(init.body)),
+ ...extra,
+});
+
+const renderCard = (props = {}) => renderWithProviders(
+ );
+
+const openMore = async () => fireEvent.keyDown(await screen.findByRole('button', {name: 'GAME_DETAIL.MORE'}), {key: 'Enter'});
+const calls = (method) => fetch.mock.calls.filter(([, init]) => init?.method === method);
+
+beforeEach(() => {
+ localStorage.setItem('tbp_session', '1');
+});
+
+afterEach(() => {
+ cleanup();
+ vi.clearAllMocks();
+ localStorage.clear();
+});
+
+describe('GameHeroCard', () => {
+ it('keepsTheMoreMenuForArtHideAndAuditorTools', async () => {
+ routes();
+ renderCard({auditor: true, profileStyle: {}});
+ expect(screen.getByRole('link', {name: /GAME_DETAIL.OPEN_IN_STEAM/})).toBeTruthy();
+
+ await openMore();
+
+ const names = (await screen.findAllByRole('menuitem')).map((item) => item.textContent);
+ expect(names).toEqual(['GAME_DETAIL.SET_BACKGROUND', 'GAME_DETAIL.SET_BANNER', 'GAME_DETAIL.HIDE', 'CORRECTION.OPEN', 'MERGE.OPEN']);
+ expect(screen.getAllByRole('menuitemcheckbox')).toHaveLength(2);
+ expect(screen.queryByRole('button', {name: 'CORRECTION.OPEN'})).toBeNull();
+ });
+
+ it('offersNoAuditorToolsToAUser', async () => {
+ routes();
+ renderCard();
+
+ await openMore();
+
+ const names = (await screen.findAllByRole('menuitem')).map((item) => item.textContent);
+ expect(names).not.toContain('CORRECTION.OPEN');
+ expect(names).not.toContain('MERGE.OPEN');
+ });
+
+ it('hidesWithAnUndoThatShowsTheGameAgain', async () => {
+ routes();
+ const onHiddenChanged = vi.fn();
+ renderCard({onHiddenChanged});
+ await openMore();
+
+ fireEvent.click(await screen.findByRole('menuitem', {name: 'GAME_DETAIL.HIDE'}));
+
+ await waitFor(() => expect(onHiddenChanged).toHaveBeenCalledWith(true));
+ const [message, options] = toast.success.mock.calls.at(-1);
+ expect(message).toBe('GAME_DETAIL.HIDDEN_TOAST');
+ expect(options.action.label).toBe('MANUAL_GAME.UNDO');
+
+ await act(async () => options.action.onClick());
+
+ expect(onHiddenChanged).toHaveBeenLastCalledWith(false);
+ expect(calls('PUT').map(([, init]) => JSON.parse(init.body))).toEqual([{hidden: true}, {hidden: false}]);
+ // The undo is a plain toast.
+ expect(toast.success.mock.calls.at(-1)).toEqual(['GAME_DETAIL.SHOWN_TOAST']);
+ });
+
+ it('savesTheStatusFromTheMenuWithoutAskingThePointsItself', async () => {
+ routes();
+ const onSaved = vi.fn();
+ renderCard({onSaved});
+
+ fireEvent.keyDown(screen.getByRole('button', {name: /BACKLOG.STATUS_LABEL/}), {key: 'Enter'});
+ fireEvent.click(await screen.findByRole('menuitemradio', {name: 'BACKLOG.STATUS.PLAYING'}));
+
+ await waitFor(() => expect(onSaved).toHaveBeenCalledWith(expect.objectContaining({status: 'PLAYING'})));
+ expect(JSON.parse(calls('PUT')[0][1].body)).toEqual({status: 'PLAYING'});
+ // GameDetail's GamePointsLine does the points check when the status changes; none here (no route would fail).
+ expect(toast.success.mock.calls.at(-1)[1].action.label).toBe('MANUAL_GAME.UNDO');
+ });
+
+ it('savesTheRatingAtOnce', async () => {
+ routes();
+ const onSaved = vi.fn();
+ renderCard({onSaved});
+
+ fireEvent.click(screen.getByRole('button', {name: 'BACKLOG.RATING_STARS:4'}));
+
+ await waitFor(() => expect(onSaved).toHaveBeenCalled());
+ expect(calls('PATCH').map(([, init]) => JSON.parse(init.body))).toEqual([{rating: 4}]);
+ });
+});
diff --git a/src/components/ManualGameCard.jsx b/src/components/ManualGameCard.jsx
index 7601836..6a03993 100644
--- a/src/components/ManualGameCard.jsx
+++ b/src/components/ManualGameCard.jsx
@@ -9,11 +9,10 @@ import {Card} from '@/components/ui/card';
import {Input} from '@/components/ui/input';
import {Label} from '@/components/ui/label';
import {hoursToMinutes, removeGame, restoreGame, toHours, updateManualGame} from '../utils/manualGames.js';
+import {UNDO_MS} from '../utils/backlog.js';
import {MAX_PLATFORM_LENGTH} from '../utils/limits.js';
import useUnsavedChanges, {discardChanges} from '../hooks/useUnsavedChanges.js';
-const UNDO_MS = 8000;
-
/**
* Platform and hours of a game added by hand, and removing it. Steam games get both from the sync.
*
diff --git a/src/components/Pagination.jsx b/src/components/Pagination.jsx
new file mode 100644
index 0000000..4ddc98f
--- /dev/null
+++ b/src/components/Pagination.jsx
@@ -0,0 +1,39 @@
+import {useTranslation} from 'react-i18next';
+import {ChevronLeft, ChevronRight} from 'lucide-react';
+
+import {Button} from '@/components/ui/button';
+import {pageNumbers} from '../utils/pagination.js';
+
+/**
+ * Previous / page numbers / Next. Below the sm breakpoint only the current number shows.
+ *
+ * @param page current page, 0-based
+ * @param onChange gets the 0-based target page
+ * @param summary node shown after the buttons
+ */
+export default function Pagination({page, totalPages, onChange, summary}) {
+ const {t} = useTranslation();
+ return (
+
+
+ onChange(page - 1)} disabled={page <= 0}>
+ {t('LIBRARY.PREV')}
+
+ {pageNumbers(page, totalPages).map((n, i) => (n === null
+ ? …
+ : (
+ onChange(n)}>
+ {n + 1}
+
+ )))}
+ onChange(page + 1)} disabled={page >= totalPages - 1}>
+ {t('LIBRARY.NEXT')}
+
+
+ {summary}
+
+ );
+}
diff --git a/src/components/PlayingHint.jsx b/src/components/PlayingHint.jsx
index c151f97..a68dd01 100644
--- a/src/components/PlayingHint.jsx
+++ b/src/components/PlayingHint.jsx
@@ -25,7 +25,7 @@ export default function PlayingHint({game, onChanged}) {
{t('GAME_DETAIL.PLAYED_RECENTLY', {time: formatPlaytime(game.playtime2Weeks)})}
- setStatus('PLAYING')} disabled={busy}>
+ setStatus('PLAYING', {previous: 'BACKLOG'})} disabled={busy}>
{busy && }
{t('GAME_DETAIL.SET_PLAYING')}
diff --git a/src/components/ProfileArtItems.jsx b/src/components/ProfileArtItems.jsx
new file mode 100644
index 0000000..c2ad730
--- /dev/null
+++ b/src/components/ProfileArtItems.jsx
@@ -0,0 +1,38 @@
+import {useState} from 'react';
+import {useTranslation} from 'react-i18next';
+
+import {DropdownMenuCheckboxItem, DropdownMenuLabel} from '@/components/ui/dropdown-menu';
+import useSaveProfileStyle from '../hooks/useSaveProfileStyle.js';
+
+/**
+ * Menu items to use the art of this Steam game as banner or background of the public profile; a checked item removes
+ * it again. Render inside a DropdownMenuContent.
+ *
+ * @param onSaved gets the whole saved style
+ */
+export default function ProfileArtItems({appid, style, onSaved}) {
+ const {t} = useTranslation();
+ const save = useSaveProfileStyle();
+ const [saving, setSaving] = useState(false);
+
+ const toggle = async (field) => {
+ setSaving(true);
+ const saved = await save((stored) => ({[field]: stored[field] === appid ? null : appid}), style);
+ if (saved) onSaved(saved);
+ setSaving(false);
+ };
+
+ return (
+ <>
+ {t('GAME_DETAIL.PROFILE_ART')}
+ toggle('bannerAppid')}>
+ {t('GAME_DETAIL.PROFILE_BANNER')}
+
+ toggle('backgroundAppid')}>
+ {t('GAME_DETAIL.PROFILE_BACKGROUND')}
+
+ >
+ );
+}
diff --git a/src/components/ProfileArtMenu.jsx b/src/components/ProfileArtMenu.jsx
deleted file mode 100644
index 16d802a..0000000
--- a/src/components/ProfileArtMenu.jsx
+++ /dev/null
@@ -1,47 +0,0 @@
-import {useState} from 'react';
-import {useTranslation} from 'react-i18next';
-import {UserRound} from 'lucide-react';
-
-import {Button} from '@/components/ui/button';
-import {
- DropdownMenu,
- DropdownMenuCheckboxItem,
- DropdownMenuContent,
- DropdownMenuTrigger,
-} from '@/components/ui/dropdown-menu';
-import useSaveProfileStyle from '../hooks/useSaveProfileStyle.js';
-
-/**
- * Uses the art of this Steam game as banner or background of the public profile; a checked item removes it again.
- *
- * @param onSaved gets the whole saved style
- */
-export default function ProfileArtMenu({appid, style, onSaved}) {
- const {t} = useTranslation();
- const save = useSaveProfileStyle();
- const [saving, setSaving] = useState(false);
-
- const toggle = async (field) => {
- setSaving(true);
- const saved = await save((stored) => ({[field]: stored[field] === appid ? null : appid}), style);
- if (saved) onSaved(saved);
- setSaving(false);
- };
-
- return (
-
-
- {t('GAME_DETAIL.PROFILE_ART')}
-
-
- toggle('bannerAppid')}>
- {t('GAME_DETAIL.PROFILE_BANNER')}
-
- toggle('backgroundAppid')}>
- {t('GAME_DETAIL.PROFILE_BACKGROUND')}
-
-
-
- );
-}
diff --git a/src/components/RatingStars.jsx b/src/components/RatingStars.jsx
index bb6c700..eeb2da1 100644
--- a/src/components/RatingStars.jsx
+++ b/src/components/RatingStars.jsx
@@ -8,9 +8,19 @@ import {cn} from '@/lib/utils';
*
* @param value 0 for no rating
* @param labelledBy id of the element that names the group
+ * @param readOnly shows the stars as one image, without buttons
*/
-export default function RatingStars({value, onChange, labelledBy, disabled}) {
+export default function RatingStars({value, onChange, labelledBy, disabled, readOnly}) {
const {t} = useTranslation();
+ if (readOnly) {
+ return (
+
+ {[1, 2, 3, 4, 5].map((n) => (
+
+ ))}
+
+ );
+ }
return (
{[1, 2, 3, 4, 5].map((n) => (
@@ -21,7 +31,7 @@ export default function RatingStars({value, onChange, labelledBy, disabled}) {
aria-label={t('BACKLOG.RATING_STARS', {count: n})}
aria-pressed={value === n}
onClick={() => onChange(value === n ? 0 : n)}
- className="rounded-sm p-0.5 outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
+ className="rounded-sm p-0.5 pointer-coarse:size-11 pointer-coarse:p-2.5 outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
>
diff --git a/src/components/StatusMenu.jsx b/src/components/StatusMenu.jsx
new file mode 100644
index 0000000..30c5228
--- /dev/null
+++ b/src/components/StatusMenu.jsx
@@ -0,0 +1,48 @@
+import {useTranslation} from 'react-i18next';
+import {ChevronDown} from 'lucide-react';
+
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu';
+import {cn} from '@/lib/utils';
+import GameStatusBadge, {STATUS_META} from './GameStatusBadge.jsx';
+import {GAME_STATUSES} from '../utils/limits.js';
+
+/**
+ * The status badge as a menu: choosing another status calls `onSelect(status)`; the current one does nothing.
+ *
+ * @param busy disables the trigger while a save runs
+ * @param compact see GameStatusBadge
+ */
+export default function StatusMenu({status, onSelect, busy, compact, className}) {
+ const {t} = useTranslation();
+ return (
+
+
+
+
+
+
+
+
+ s !== status && onSelect(s)}>
+ {GAME_STATUSES.map((s) => {
+ const {icon: Icon, text} = STATUS_META[s];
+ return (
+
+
+ {t(`BACKLOG.STATUS.${s}`)}
+
+ );
+ })}
+
+
+
+ );
+}
diff --git a/src/components/StatusMenu.test.jsx b/src/components/StatusMenu.test.jsx
new file mode 100644
index 0000000..b000a85
--- /dev/null
+++ b/src/components/StatusMenu.test.jsx
@@ -0,0 +1,49 @@
+// @vitest-environment jsdom
+import {afterEach, describe, expect, it, vi} from 'vitest';
+import {cleanup, fireEvent, render, screen} from '@testing-library/react';
+import StatusMenu from './StatusMenu.jsx';
+
+vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
+
+afterEach(cleanup);
+
+const open = () => fireEvent.keyDown(screen.getByRole('button', {name: /BACKLOG.STATUS_LABEL/}), {key: 'Enter'});
+
+describe('StatusMenu', () => {
+ it('namesTheCurrentStatusAndListsAllFive', async () => {
+ render(
);
+
+ expect(screen.getByRole('button', {name: 'BACKLOG.STATUS_LABEL: BACKLOG.STATUS.PLAYING'})).toBeTruthy();
+ open();
+
+ const items = await screen.findAllByRole('menuitemradio');
+ expect(items).toHaveLength(5);
+ expect(screen.getByRole('menuitemradio', {name: 'BACKLOG.STATUS.PLAYING'}).getAttribute('aria-checked')).toBe('true');
+ });
+
+ it('callsOnSelectForAnotherStatus', async () => {
+ const onSelect = vi.fn();
+ render();
+ open();
+
+ fireEvent.click(await screen.findByRole('menuitemradio', {name: 'BACKLOG.STATUS.FINISHED'}));
+
+ expect(onSelect).toHaveBeenCalledWith('FINISHED');
+ });
+
+ it('doesNothingForTheCurrentStatus', async () => {
+ const onSelect = vi.fn();
+ render();
+ open();
+
+ fireEvent.click(await screen.findByRole('menuitemradio', {name: 'BACKLOG.STATUS.PLAYING'}));
+
+ expect(onSelect).not.toHaveBeenCalled();
+ });
+
+ it('isDisabledWhileBusy', () => {
+ render( );
+
+ expect(screen.getByRole('button', {name: /BACKLOG.STATUS_LABEL/}).disabled).toBe(true);
+ });
+});
diff --git a/src/components/UnlinkDialog.jsx b/src/components/UnlinkDialog.jsx
index 8acf094..65aa1b6 100644
--- a/src/components/UnlinkDialog.jsx
+++ b/src/components/UnlinkDialog.jsx
@@ -8,15 +8,18 @@ import {
/**
* "Unlink" button that asks first. The description says what the unlink does and does not do.
+ *
+ * @param size size of the trigger button
+ * @param icon icon component of the trigger button
*/
-export default function UnlinkDialog({label, title, description, confirmLabel, onConfirm, disabled, className}) {
+export default function UnlinkDialog({label, title, description, confirmLabel, onConfirm, disabled, className, size, icon: Icon = Unlink}) {
const {t} = useTranslation();
return (
-
- {label}
+
+ {label}
diff --git a/src/hooks/useSaveBacklogField.js b/src/hooks/useSaveBacklogField.js
new file mode 100644
index 0000000..3817e5f
--- /dev/null
+++ b/src/hooks/useSaveBacklogField.js
@@ -0,0 +1,45 @@
+import {useRef, useState} from 'react';
+import {useTranslation} from 'react-i18next';
+import {toast} from 'sonner';
+import {saveBacklogEntry, toBacklogRequest} from '../utils/backlog.js';
+
+/**
+ * Returns `{save, pending, busy}`. `save(field, value)` sends that one field (PATCH .../backlog; 0 or NONE clear it),
+ * shows the saved or error toast and calls `onSaved(entry)` with the whole saved entry. One request at a time: a call
+ * while one runs does nothing. Resolves to true when saved.
+ *
+ * `pending` is `{field, value}` while saving, else null. Show `pending.value` for that field meanwhile: a failed save
+ * then falls back to the saved value by itself.
+ */
+export default function useSaveBacklogField(appid, {onSaved} = {}) {
+ const {t} = useTranslation();
+ const [pending, setPending] = useState(null);
+ const running = useRef(false);
+
+ const save = async (field, value) => {
+ if (running.current) return false;
+ running.current = true;
+ setPending({field, value});
+ try {
+ const response = await saveBacklogEntry(appid, {[field]: toBacklogRequest({[field]: value})[field]});
+ if (response.status === 401) return false;
+ if (!response.ok) {
+ toast.error(t('BACKLOG.SAVE_FAILED'));
+ return false;
+ }
+ const entry = await response.json();
+ onSaved?.(entry);
+ toast.success(t('BACKLOG.SAVED'));
+ return true;
+ } catch (err) {
+ console.error('backlog save error:', err);
+ toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
+ return false;
+ } finally {
+ running.current = false;
+ setPending(null);
+ }
+ };
+
+ return {save, pending, busy: pending !== null};
+}
diff --git a/src/hooks/useSaveBacklogField.test.jsx b/src/hooks/useSaveBacklogField.test.jsx
new file mode 100644
index 0000000..69f6035
--- /dev/null
+++ b/src/hooks/useSaveBacklogField.test.jsx
@@ -0,0 +1,106 @@
+// @vitest-environment jsdom
+import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
+import {act, cleanup, renderHook} from '@testing-library/react';
+import {toast} from 'sonner';
+import useSaveBacklogField from './useSaveBacklogField.js';
+import {fetchRoutes, json} from '../test/fetchRoutes.js';
+
+vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
+vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
+
+const PATCH = 'PATCH /api/library/games/220/backlog';
+const bodies = (fetchMock) => fetchMock.mock.calls.map(([, init]) => JSON.parse(init.body));
+
+beforeEach(() => {
+ localStorage.setItem('tbp_session', '1');
+});
+
+afterEach(() => {
+ cleanup();
+ vi.clearAllMocks();
+ localStorage.clear();
+});
+
+const render = (options) => renderHook(() => useSaveBacklogField(220, options));
+
+describe('useSaveBacklogField', () => {
+ it('sendsOnlyTheOneFieldAndTellsOnSaved', async () => {
+ const fetchMock = fetchRoutes({[PATCH]: () => json(200, {appid: 220, rating: 5})});
+ const onSaved = vi.fn();
+ const {result} = render({onSaved});
+
+ let saved;
+ await act(async () => {
+ saved = await result.current.save('rating', 5);
+ });
+
+ expect(saved).toBe(true);
+ expect(bodies(fetchMock)).toEqual([{rating: 5}]);
+ expect(onSaved).toHaveBeenCalledWith({appid: 220, rating: 5});
+ expect(toast.success).toHaveBeenCalledWith('BACKLOG.SAVED');
+ });
+
+ it.each([['rating', 0, {rating: null}], ['priority', 'NONE', {priority: null}], ['notes', ' ', {notes: null}]])(
+ 'clearsAFieldWithNull_%s', async (field, value, body) => {
+ const fetchMock = fetchRoutes({[PATCH]: () => json(200, {})});
+ const {result} = render();
+
+ await act(() => result.current.save(field, value));
+
+ expect(bodies(fetchMock)).toEqual([body]);
+ });
+
+ it('holdsTheValueWhileSavingAndDropsItAfterAFailedSave', async () => {
+ let release;
+ fetchRoutes({[PATCH]: () => new Promise((resolve) => {
+ release = () => resolve(json(500, {}));
+ })});
+ const onSaved = vi.fn();
+ const {result} = render({onSaved});
+
+ let saving;
+ await act(async () => {
+ saving = result.current.save('priority', 'HIGH');
+ });
+ expect(result.current.pending).toEqual({field: 'priority', value: 'HIGH'});
+ expect(result.current.busy).toBe(true);
+ await act(async () => {
+ release();
+ await saving;
+ });
+
+ expect(result.current.pending).toBeNull();
+ expect(toast.error).toHaveBeenCalledWith('BACKLOG.SAVE_FAILED');
+ expect(onSaved).not.toHaveBeenCalled();
+ });
+
+ it('sendsOneRequestWhenCalledTwice', async () => {
+ let release;
+ const fetchMock = fetchRoutes({[PATCH]: () => new Promise((resolve) => {
+ release = () => resolve(json(200, {}));
+ })});
+ const {result} = render();
+
+ await act(async () => {
+ const first = result.current.save('rating', 3);
+ expect(await result.current.save('rating', 4)).toBe(false);
+ release();
+ await first;
+ });
+
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ });
+
+ it('showsTheNetworkErrorWhenFetchRejects', async () => {
+ vi.spyOn(console, 'error').mockImplementation(() => {});
+ fetchRoutes({[PATCH]: () => {
+ throw new Error('offline');
+ }});
+ const {result} = render();
+
+ await act(() => result.current.save('rating', 3));
+
+ expect(toast.error).toHaveBeenCalledWith('INFO.ERROR.NETWORK_FAILURE');
+ expect(result.current.pending).toBeNull();
+ });
+});
diff --git a/src/hooks/useSetStatus.js b/src/hooks/useSetStatus.js
index 16d0784..05b7259 100644
--- a/src/hooks/useSetStatus.js
+++ b/src/hooks/useSetStatus.js
@@ -1,14 +1,17 @@
import {useRef, useState} from 'react';
import {useTranslation} from 'react-i18next';
import {toast} from 'sonner';
-import {saveGameStatus} from '../utils/backlog.js';
+import {saveGameStatus, UNDO_MS} from '../utils/backlog.js';
import usePointsCheck from './usePointsCheck.js';
/**
- * Returns `{setStatus, busy}`. `setStatus(status)` saves the status (PUT .../status), shows the saved or error toast,
- * with `checkPoints` asks the points check with its toast, then calls `onChanged(entry)`. `entry` is the saved backlog
- * entry, or null when the body of a successful answer is empty or not JSON. A second call while one runs does nothing.
- * Resolves to true when saved.
+ * Returns `{setStatus, busy}`. `setStatus(status, {previous})` saves the status (PUT .../status), shows the saved or
+ * error toast, with `checkPoints` asks the points check with its toast, then calls `onChanged(entry)`. `entry` is the
+ * saved backlog entry, or null when the body of a successful answer is empty or not JSON. A second call while one runs
+ * does nothing. Resolves to true when saved.
+ *
+ * With `previous` (the status before, when it differs) the saved toast has an Undo action that saves `previous` again,
+ * also with `checkPoints` and `onChanged`. Undo does not use the busy lock: the caller may be gone by then.
*
* GameDetail's GamePointsLine already tells the points; do not set `checkPoints` there.
*/
@@ -18,10 +21,7 @@ export default function useSetStatus(appid, {onChanged, checkPoints = false} = {
const [busy, setBusy] = useState(false);
const running = useRef(false);
- const setStatus = async (status) => {
- if (running.current) return false;
- running.current = true;
- setBusy(true);
+ const save = async (status, previous) => {
try {
const response = await saveGameStatus(appid, status);
if (response.status === 401) return false;
@@ -30,7 +30,12 @@ export default function useSetStatus(appid, {onChanged, checkPoints = false} = {
return false;
}
const entry = await response.json().catch(() => null);
- toast.success(t('BACKLOG.SAVED'));
+ if (previous && previous !== status) {
+ toast.success(t('BACKLOG.SAVED'),
+ {duration: UNDO_MS, action: {label: t('MANUAL_GAME.UNDO'), onClick: () => save(previous)}});
+ } else {
+ toast.success(t('BACKLOG.SAVED'));
+ }
if (checkPoints) check(appid, {status, tell: true});
onChanged?.(entry);
return true;
@@ -38,6 +43,15 @@ export default function useSetStatus(appid, {onChanged, checkPoints = false} = {
console.error('set status error:', err);
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
return false;
+ }
+ };
+
+ const setStatus = async (status, {previous} = {}) => {
+ if (running.current) return false;
+ running.current = true;
+ setBusy(true);
+ try {
+ return await save(status, previous);
} finally {
running.current = false;
setBusy(false);
diff --git a/src/hooks/useSetStatus.test.jsx b/src/hooks/useSetStatus.test.jsx
index 599dca4..75b63f4 100644
--- a/src/hooks/useSetStatus.test.jsx
+++ b/src/hooks/useSetStatus.test.jsx
@@ -157,4 +157,50 @@ describe('useSetStatus', () => {
expect(result.current.busy).toBe(false);
});
+
+ it('showsUndoOnlyWithAPreviousStatusThatDiffers', async () => {
+ fetchRoutes({[PUT]: () => json(200, {})});
+ const {result} = render();
+
+ await act(() => result.current.setStatus('PLAYING'));
+ await act(() => result.current.setStatus('PLAYING', {previous: 'PLAYING'}));
+ expect(toast.success.mock.calls.every(([, options]) => options === undefined)).toBe(true);
+
+ await act(() => result.current.setStatus('PLAYING', {previous: 'BACKLOG'}));
+ const [message, options] = toast.success.mock.calls.at(-1);
+ expect(message).toBe('BACKLOG.SAVED');
+ expect(options.action.label).toBe('MANUAL_GAME.UNDO');
+ expect(options.duration).toBe(8000);
+ });
+
+ it('undoSavesThePreviousStatusAndTellsOnChanged', async () => {
+ const fetchMock = fetchRoutes({[PUT]: (url, init) => json(200, JSON.parse(init.body))});
+ const onChanged = vi.fn();
+ const {result} = render({onChanged, checkPoints: true});
+ await act(() => result.current.setStatus('PLAYING', {previous: 'BACKLOG'}));
+ onChanged.mockClear();
+ pointsCheck.mockClear();
+
+ await act(async () => toast.success.mock.calls[0][1].action.onClick());
+
+ expect(JSON.parse(fetchMock.mock.calls[1][1].body)).toEqual({status: 'BACKLOG'});
+ expect(onChanged).toHaveBeenCalledWith({status: 'BACKLOG'});
+ expect(pointsCheck).toHaveBeenCalledWith(220, {status: 'BACKLOG', tell: true});
+ // The undo itself has no Undo.
+ expect(toast.success.mock.calls.at(-1)).toEqual(['BACKLOG.SAVED']);
+ });
+
+ it('undoShowsSaveFailedOnAServerError', async () => {
+ let calls = 0;
+ fetchRoutes({[PUT]: () => json(++calls === 1 ? 200 : 500, {})});
+ const onChanged = vi.fn();
+ const {result} = render({onChanged});
+ await act(() => result.current.setStatus('PLAYING', {previous: 'BACKLOG'}));
+ onChanged.mockClear();
+
+ await act(async () => toast.success.mock.calls[0][1].action.onClick());
+
+ expect(toast.error).toHaveBeenCalledWith('BACKLOG.SAVE_FAILED');
+ expect(onChanged).not.toHaveBeenCalled();
+ });
});
diff --git a/src/pages/BacklogPage.jsx b/src/pages/BacklogPage.jsx
index 95ed41f..c8e049c 100644
--- a/src/pages/BacklogPage.jsx
+++ b/src/pages/BacklogPage.jsx
@@ -146,7 +146,7 @@ export default function BacklogPage() {
variant="ghost"
size="icon-sm"
aria-label={t('BACKLOG_ORDER.MOVE_DRAG')}
- className={cn('touch-none select-none cursor-grab', dragFrom === index && 'cursor-grabbing')}
+ className={cn('pointer-coarse:size-11 touch-none select-none cursor-grab', dragFrom === index && 'cursor-grabbing')}
{...handleProps(index)}
>
@@ -154,6 +154,7 @@ export default function BacklogPage() {
move(index, index - 1)}
@@ -163,6 +164,7 @@ export default function BacklogPage() {
move(index, index + 1)}
@@ -171,7 +173,7 @@ export default function BacklogPage() {
-
+
diff --git a/src/pages/GameDetail.jsx b/src/pages/GameDetail.jsx
index dbb01a0..b060d61 100644
--- a/src/pages/GameDetail.jsx
+++ b/src/pages/GameDetail.jsx
@@ -57,6 +57,12 @@ export default function GameDetail() {
if (data && String(data.appid) !== appid) navigate(`/library/${data.appid}`, {replace: true});
}, [data, appid, navigate]);
+ const onEntrySaved = (entry) => {
+ setData((d) => ({...d, ...entry}));
+ // The other editions share status, rating and notes.
+ if (data.editions?.length) reload();
+ };
+
const manual = !!data && data.source !== 'STEAM';
const errorBlock = error && (
@@ -92,7 +98,7 @@ export default function GameDetail() {
setCorrecting(true)} onMerge={() => setMerging(true)}
onHiddenChanged={(hidden) => setData((d) => ({...d, hidden}))}
- onProfileStyleSaved={setSavedProfileStyle}/>
+ onProfileStyleSaved={setSavedProfileStyle} onSaved={onEntrySaved}/>
{data.lastPlayedAt && (
@@ -111,12 +117,7 @@ export default function GameDetail() {
onDismissed={() => setData((d) => ({...d, finishedHint: null}))}/>
setData((d) => ({...d, ...entry}))}/>
- {
- setData((d) => ({...d, ...entry}));
- // The other editions share status, rating and notes.
- if (data.editions?.length) reload();
- }}/>
+
diff --git a/src/pages/GameDetail.test.jsx b/src/pages/GameDetail.test.jsx
index ff1fdc5..55c7827 100644
--- a/src/pages/GameDetail.test.jsx
+++ b/src/pages/GameDetail.test.jsx
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
-import {act, cleanup, fireEvent, screen, waitFor} from '@testing-library/react';
+import {act, cleanup, fireEvent, screen, waitFor, within} from '@testing-library/react';
import GameDetail from './GameDetail.jsx';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
import {renderWithProviders} from '../test/renderWithProviders.jsx';
@@ -19,6 +19,18 @@ const game = {
const renderDetail = (appid = 220) => renderWithProviders( ,
{path: '/library/:appid', entries: [`/library/${appid}`], routes: {'/library': library page
}});
+const openMore = async () => fireEvent.keyDown(await screen.findByRole('button', {name: 'GAME_DETAIL.MORE'}), {key: 'Enter'});
+const pickMore = async (name, role = 'menuitem') => {
+ await openMore();
+ fireEvent.click(await screen.findByRole(role, {name}));
+};
+const hasMoreItem = async (name) => {
+ await openMore();
+ const found = !!(await screen.findAllByRole('menuitem')).find((item) => item.textContent === name);
+ fireEvent.keyDown(document.activeElement, {key: 'Escape'});
+ return found;
+};
+
const echo = (url, init) => json(200, JSON.parse(init.body));
// The user and the game at /library/; `routes` adds or replaces answers.
@@ -90,24 +102,24 @@ describe('GameDetail', () => {
answer({...game, source: 'STEAM', hidden: false}, {'PUT /api/library/games/220/hidden': echo});
renderDetail();
- fireEvent.click(await screen.findByRole('button', {name: 'GAME_DETAIL.HIDE'}));
+ await pickMore('GAME_DETAIL.HIDE');
expect(await screen.findByText('GAME_DETAIL.HIDDEN_NOTE')).toBeTruthy();
const [url, init] = fetch.mock.calls.find(([, call]) => call?.method === 'PUT');
expect(url).toContain('/api/library/games/220/hidden');
expect(JSON.parse(init.body)).toEqual({hidden: true});
- fireEvent.click(screen.getByRole('button', {name: 'GAME_DETAIL.UNHIDE'}));
+ await pickMore('GAME_DETAIL.UNHIDE');
await waitFor(() => expect(screen.queryByText('GAME_DETAIL.HIDDEN_NOTE')).toBeNull());
- expect(screen.getByRole('button', {name: 'GAME_DETAIL.HIDE'})).toBeTruthy();
+ expect(await hasMoreItem('GAME_DETAIL.HIDE')).toBe(true);
});
it('keeps the game shown when Core refuses', async () => {
answer({...game, source: 'STEAM', hidden: false}, {'PUT /api/library/games/220/hidden': json(500, {})});
renderDetail();
- fireEvent.click(await screen.findByRole('button', {name: 'GAME_DETAIL.HIDE'}));
+ await pickMore('GAME_DETAIL.HIDE');
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('GAME_DETAIL.HIDE_ERROR'));
expect(screen.queryByText('GAME_DETAIL.HIDDEN_NOTE')).toBeNull();
@@ -118,12 +130,12 @@ describe('GameDetail', () => {
{'PUT /api/library/games/-1942/hidden': echo});
renderDetail(-1942);
- fireEvent.click(await screen.findByRole('button', {name: 'GAME_DETAIL.HIDE'}));
+ await pickMore('GAME_DETAIL.HIDE');
expect(await screen.findByText('GAME_DETAIL.HIDDEN_NOTE')).toBeTruthy();
expect(screen.queryByText('GAME_DETAIL.OPEN_IN_STEAM')).toBeNull();
- fireEvent.click(screen.getByRole('button', {name: 'GAME_DETAIL.UNHIDE'}));
+ await pickMore('GAME_DETAIL.UNHIDE');
await waitFor(() => expect(screen.queryByText('GAME_DETAIL.HIDDEN_NOTE')).toBeNull());
});
@@ -194,29 +206,31 @@ describe('GameDetail', () => {
answer(game, {'PUT /api/users/settings/appearance': echo});
renderDetail();
- fireEvent.click(await screen.findByRole('button', {name: 'GAME_DETAIL.SET_BACKGROUND'}));
+ await pickMore('GAME_DETAIL.SET_BACKGROUND');
- const remove = await screen.findByRole('button', {name: 'GAME_DETAIL.REMOVE_BACKGROUND'});
+ await waitFor(() => expect(fetch.mock.calls.some(([, call]) => call?.method === 'PUT')).toBe(true));
const [url, init] = fetch.mock.calls.find(([, call]) => call?.method === 'PUT');
expect(url).toContain('/api/users/settings/appearance');
expect(JSON.parse(init.body).backgroundAppid).toBe(220);
- fireEvent.click(remove);
- expect(await screen.findByRole('button', {name: 'GAME_DETAIL.SET_BACKGROUND'})).toBeTruthy();
+ await waitFor(async () => expect(await hasMoreItem('GAME_DETAIL.REMOVE_BACKGROUND')).toBe(true));
+ await pickMore('GAME_DETAIL.REMOVE_BACKGROUND');
+ await waitFor(async () => expect(await hasMoreItem('GAME_DETAIL.SET_BACKGROUND')).toBe(true));
});
it('uses the game as banner and goes back to the automatic banner', async () => {
answer(game, {'PUT /api/users/settings/appearance': echo});
renderDetail();
- fireEvent.click(await screen.findByRole('button', {name: 'GAME_DETAIL.SET_BANNER'}));
+ await pickMore('GAME_DETAIL.SET_BANNER');
- const auto = await screen.findByRole('button', {name: 'SETTINGS.BANNER_AUTO'});
+ await waitFor(() => expect(fetch.mock.calls.some(([, call]) => call?.method === 'PUT')).toBe(true));
const [, init] = fetch.mock.calls.find(([, call]) => call?.method === 'PUT');
expect(JSON.parse(init.body)).toMatchObject({bannerAppid: 220, showBanner: true});
- fireEvent.click(auto);
- expect(await screen.findByRole('button', {name: 'GAME_DETAIL.SET_BANNER'})).toBeTruthy();
+ await waitFor(async () => expect(await hasMoreItem('SETTINGS.BANNER_AUTO')).toBe(true));
+ await pickMore('SETTINGS.BANNER_AUTO');
+ await waitFor(async () => expect(await hasMoreItem('GAME_DETAIL.SET_BANNER')).toBe(true));
});
it('offers "use as banner" for the picked game while the banner is off, and turns it on', async () => {
@@ -224,7 +238,7 @@ describe('GameDetail', () => {
answer(game, {'PUT /api/users/settings/appearance': echo});
renderDetail();
- fireEvent.click(await screen.findByRole('button', {name: 'GAME_DETAIL.SET_BANNER'}));
+ await pickMore('GAME_DETAIL.SET_BANNER');
await waitFor(() => expect(fetch.mock.calls.some(([, call]) => call?.method === 'PUT')).toBe(true));
const [, init] = fetch.mock.calls.find(([, call]) => call?.method === 'PUT');
@@ -250,8 +264,9 @@ describe('GameDetail of a game added by hand', () => {
expect(await screen.findByLabelText('MANUAL_GAME.PLATFORM')).toBeTruthy();
expect(screen.getAllByText('Nintendo Switch').length).toBeGreaterThan(0);
expect(container.querySelector('img[src="https://images.igdb.com/z.jpg"]')).toBeTruthy();
- expect(screen.queryByRole('button', {name: 'GAME_DETAIL.SET_BACKGROUND'})).toBeNull();
- expect(screen.queryByRole('button', {name: 'GAME_DETAIL.SET_BANNER'})).toBeNull();
+ expect(await hasMoreItem('GAME_DETAIL.HIDE')).toBe(true);
+ expect(await hasMoreItem('GAME_DETAIL.SET_BACKGROUND')).toBe(false);
+ expect(await hasMoreItem('GAME_DETAIL.SET_BANNER')).toBe(false);
expect(screen.queryByText('GAME_DETAIL.OPEN_IN_STEAM')).toBeNull();
expect(screen.getByText('MANUAL_GAME.NO_ACHIEVEMENTS')).toBeTruthy();
});
@@ -327,7 +342,7 @@ describe('GameDetail of a game added by hand', () => {
answer({...game, source: 'STEAM'});
renderDetail();
- expect(await screen.findByRole('button', {name: 'GAME_DETAIL.SET_BACKGROUND'})).toBeTruthy();
+ expect(await hasMoreItem('GAME_DETAIL.SET_BACKGROUND')).toBe(true);
expect(screen.queryByText('MANUAL_GAME.TITLE')).toBeNull();
});
@@ -338,7 +353,7 @@ describe('GameDetail of a game added by hand', () => {
'PUT /api/users/settings/profile-style': json(204),
});
renderDetail();
- const menu = await screen.findByRole('button', {name: 'GAME_DETAIL.PROFILE_ART'});
+ const menu = await screen.findByRole('button', {name: 'GAME_DETAIL.MORE'});
backgroundAppid = 620; // set in a second tab
fireEvent.keyDown(menu, {key: 'Enter'});
@@ -357,7 +372,7 @@ describe('GameDetail of a game added by hand', () => {
'PUT /api/users/settings/profile-style': json(204),
});
renderDetail();
- const menu = await screen.findByRole('button', {name: 'GAME_DETAIL.PROFILE_ART'});
+ const menu = await screen.findByRole('button', {name: 'GAME_DETAIL.MORE'});
bannerAppid = 620; // another tab picked another game
fireEvent.keyDown(menu, {key: 'Enter'});
@@ -379,7 +394,7 @@ describe('GameDetail of a game added by hand', () => {
});
const styleBodies = () => fetch.mock.calls.filter(([url, init]) => init?.method === 'PUT' && url.endsWith('/profile-style'))
.map(([, init]) => JSON.parse(init.body));
- const openMenu = async () => fireEvent.keyDown(await screen.findByRole('button', {name: 'GAME_DETAIL.PROFILE_ART'}), {key: 'Enter'});
+ const openMenu = openMore;
renderDetail();
await openMenu();
@@ -402,7 +417,7 @@ describe('GameDetail of a game added by hand', () => {
loggedInAs({role: 'AUDITOR', activeRole: 'AUDITOR'});
renderDetail();
- fireEvent.click(await screen.findByRole('button', {name: 'CORRECTION.OPEN'}));
+ await pickMore('CORRECTION.OPEN');
expect(await screen.findByText('CORRECTION.TITLE')).toBeTruthy();
});
@@ -411,7 +426,7 @@ describe('GameDetail of a game added by hand', () => {
loggedInAs({role: 'AUDITOR', activeRole: 'AUDITOR'});
renderDetail();
- fireEvent.click(await screen.findByRole('button', {name: 'MERGE.OPEN'}));
+ await pickMore('MERGE.OPEN');
expect(await screen.findByText('MERGE.TITLE')).toBeTruthy();
});
@@ -423,6 +438,7 @@ describe('GameDetail of a game added by hand', () => {
expect(await screen.findByRole('link', {name: 'Half-Life 2 PS5'})).toBeTruthy();
fireEvent.click(await screen.findByRole('button', {name: 'LINKS.UNLINK'}));
+ fireEvent.click(within(await screen.findByRole('dialog')).getByRole('button', {name: 'LINKS.UNLINK'}));
await waitFor(() => expect(fetch.mock.calls.some(([, init]) => init?.method === 'DELETE')).toBe(true));
expect(toast.success).toHaveBeenCalledWith('LINKS.UNLINKED');
@@ -445,8 +461,8 @@ describe('GameDetail of a game added by hand', () => {
await waitFor(() => expect(fetch).toHaveBeenCalledWith(expect.stringContaining('/api/users/me'), expect.anything()));
// Let the answer of /me reach the page before looking for the button.
await act(async () => {});
- expect(screen.queryByRole('button', {name: 'CORRECTION.OPEN'})).toBeNull();
- expect(screen.queryByRole('button', {name: 'MERGE.OPEN'})).toBeNull();
+ expect(await hasMoreItem('CORRECTION.OPEN')).toBe(false);
+ expect(await hasMoreItem('MERGE.OPEN')).toBe(false);
expect(screen.queryByRole('button', {name: 'LINKS.UNLINK'})).toBeNull();
cleanup();
}
diff --git a/src/pages/GameLibrary.jsx b/src/pages/GameLibrary.jsx
index f8d3565..1cce7ed 100644
--- a/src/pages/GameLibrary.jsx
+++ b/src/pages/GameLibrary.jsx
@@ -7,7 +7,7 @@ import useForceSync from '../hooks/useForceSync.js';
import {formatPlaytime} from '../utils/games.js';
import {libraryQuery, parseSources, parseStatus} from '../utils/backlog.js';
import {GAME_SOURCES, GAME_STATUSES, MAX_LIBRARY_SEARCH_LENGTH} from '../utils/limits.js';
-import {ArrowUpDown, ChevronLeft, ChevronRight, Clock, EyeOff, Gamepad2, Layers, Library, Plus, RefreshCw, Search, SearchX, Timer, Trophy, Upload} from 'lucide-react';
+import {ArrowUpDown, ChevronRight, Clock, EyeOff, Gamepad2, Layers, Library, Plus, RefreshCw, Search, SearchX, Timer, Trophy, Upload} from 'lucide-react';
import {Badge} from '@/components/ui/badge';
import {Button} from '@/components/ui/button';
@@ -21,7 +21,10 @@ import GameCover from '../components/GameCover.jsx';
import FitText from '../components/FitText.jsx';
import LibraryTabs from '../components/LibraryTabs.jsx';
import ComingBadge from '../components/ComingBadge.jsx';
-import GameStatusBadge from '../components/GameStatusBadge.jsx';
+import Pagination from '../components/Pagination.jsx';
+import RatingStars from '../components/RatingStars.jsx';
+import StatusMenu from '../components/StatusMenu.jsx';
+import useSetStatus from '../hooks/useSetStatus.js';
import PageContainer from '../components/PageContainer.jsx';
import HintText from '../components/HintText.jsx';
import PlatformIcons from '../components/PlatformIcons.jsx';
@@ -42,10 +45,12 @@ const SHORT_GAME_MINUTES = 600;
const SORT_OPTIONS = [
{value: 'playtimeMinutes,desc', labelKey: 'LIBRARY.SORT_PLAYTIME_DESC'},
{value: 'playtimeMinutes,asc', labelKey: 'LIBRARY.SORT_PLAYTIME_ASC'},
- {value: 'lastSeenAt,desc', labelKey: 'LIBRARY.SORT_LAST_SEEN'},
{value: 'firstSeenAt,desc', labelKey: 'LIBRARY.SORT_FIRST_SEEN'},
{value: 'rating,desc', labelKey: 'LIBRARY.SORT_RATING'},
{value: 'priority,desc', labelKey: 'LIBRARY.SORT_PRIORITY'},
+ {value: 'name,asc', labelKey: 'LIBRARY.SORT_NAME'},
+ {value: 'lastPlayedAt,desc', labelKey: 'LIBRARY.SORT_LAST_PLAYED'},
+ {value: 'timeToBeat,asc', labelKey: 'LIBRARY.SORT_TIME_TO_BEAT'},
];
// Last loaded page, so a back navigation from a game renders at once and the scroll position can be restored.
@@ -58,6 +63,66 @@ export function clearLibraryPageCache() {
lastPage = {query: null, data: null, stamp: null};
}
+// The status, rating and priority of an answered backlog entry, in the row of that game. No entry: no change.
+const patchRow = (page, appid, entry) => (!entry ? page : {
+ ...page,
+ content: page.content.map((row) => (row.appid === appid
+ ? {...row, status: entry.status, rating: entry.rating, priority: entry.priority} : row)),
+});
+
+/**
+ * One game row. The whole row opens the game (the link stretches over it); the status menu sits above that link. A
+ * status set here asks the points check, which the game page does itself.
+ */
+function LibraryRow({game: g, sort, equipped, onStatus}) {
+ const {t} = useTranslation();
+ const {setStatus, busy} = useSetStatus(g.appid, {checkPoints: true, onChanged: (entry) => onStatus(g.appid, entry)});
+ return (
+
+
+
+
+
+
+
+
+
+
+ {!g.platforms?.length && platformLabel(g) && (
+
+ {platformLabel(g)}
+
+ )}
+ {g.editions >= 2 && (
+
+ x{g.editions}
+
+ )}
+
+ setStatus(status, {previous: g.status})}/>
+ {sort.startsWith('rating') && g.rating > 0 && }
+ {sort.startsWith('priority') && g.priority && (
+ {t(`BACKLOG.PRIORITY.${g.priority}`)}
+ )}
+ {g.achievementsTotal > 0 && g.achievementsUnlocked != null && (
+
+
+ {g.achievementsUnlocked}/{g.achievementsTotal}
+
+ )}
+
+
+ {formatPlaytime(g.totalPlaytimeMinutes ?? g.playtimeMinutes)}
+
+
+
+ );
+}
+
export default function GameLibrary() {
const {t} = useTranslation();
const {equipped} = usePreferences();
@@ -193,6 +258,12 @@ export default function GameLibrary() {
updateParams({page: target});
};
+ // A row that no longer matches the status filter stays until the next load, so Undo can still reach it.
+ const applyEntry = (appid, entry) => {
+ setData((d) => patchRow(d, appid, entry));
+ if (lastPage.data) lastPage = {...lastPage, data: patchRow(lastPage.data, appid, entry)};
+ };
+
const {trigger: triggerSync, busy: syncBusy} = useForceSync(sync.watch);
const syncing = syncBusy || sync.isRunning;
@@ -266,7 +337,7 @@ export default function GameLibrary() {
size="sm"
variant={listStatus === s && !hiddenOnly ? 'default' : 'outline'}
aria-pressed={listStatus === s && !hiddenOnly}
- className="h-7 rounded-full px-3 text-xs"
+ className="h-7 rounded-full px-3 text-xs pointer-coarse:h-11 pointer-coarse:px-4"
onClick={() => onStatusChange(s)}
>
{s ? t(`BACKLOG.STATUS.${s}`) : t('LIBRARY.FILTER_ALL')}
@@ -276,7 +347,7 @@ export default function GameLibrary() {
size="sm"
variant={listShortOnly ? 'default' : 'outline'}
aria-pressed={listShortOnly}
- className="h-7 rounded-full px-3 text-xs"
+ className="h-7 rounded-full px-3 text-xs pointer-coarse:h-11 pointer-coarse:px-4"
onClick={() => updateParams({maxMinutes: listShortOnly ? null : SHORT_GAME_MINUTES, hidden: null, page: 0})}
>
{t('LIBRARY.FILTER_SHORT')}
@@ -285,7 +356,7 @@ export default function GameLibrary() {
size="sm"
variant={hiddenOnly ? 'default' : 'outline'}
aria-pressed={hiddenOnly}
- className="h-7 rounded-full px-3 text-xs"
+ className="h-7 rounded-full px-3 text-xs pointer-coarse:h-11 pointer-coarse:px-4"
onClick={onHiddenChange}
>
{t('LIBRARY.FILTER_HIDDEN')}
@@ -299,7 +370,7 @@ export default function GameLibrary() {
size="sm"
variant={listSources.includes(s) ? 'default' : 'outline'}
aria-pressed={listSources.includes(s)}
- className="h-7 rounded-full px-3 text-xs"
+ className="h-7 rounded-full px-3 text-xs pointer-coarse:h-11 pointer-coarse:px-4"
onClick={() => toggleSource(s)}
>
{t(`LIBRARY.SOURCE.${s}`)}
@@ -351,64 +422,15 @@ export default function GameLibrary() {
{data.content.map((g) => (
-
-
-
-
-
-
-
-
-
- {!g.platforms?.length && platformLabel(g) && (
-
- {platformLabel(g)}
-
- )}
- {g.editions >= 2 && (
-
- x{g.editions}
-
- )}
-
-
- {g.achievementsTotal > 0 && g.achievementsUnlocked != null && (
-
-
- {g.achievementsUnlocked}/{g.achievementsTotal}
-
- )}
-
-
- {formatPlaytime(g.totalPlaytimeMinutes ?? g.playtimeMinutes)}
-
-
-
-
+
))}
)}
{ready && data.totalPages > 1 && (
-
- goToPage(page - 1)} disabled={page <= 0}>
- {t('LIBRARY.PREV')}
-
-
- {t('LIBRARY.PAGE_OF', {current: data.page + 1, total: data.totalPages})}
- {' · '}
- {data.totalElements} {t('LIBRARY.GAMES')}
-
- goToPage(page + 1)} disabled={page >= data.totalPages - 1}>
- {t('LIBRARY.NEXT')}
-
-
+
)}
);
diff --git a/src/pages/GameLibrary.test.jsx b/src/pages/GameLibrary.test.jsx
index 5f1075a..22ae52f 100644
--- a/src/pages/GameLibrary.test.jsx
+++ b/src/pages/GameLibrary.test.jsx
@@ -1,7 +1,7 @@
// @vitest-environment jsdom
import {StrictMode} from 'react';
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
-import {act, cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
+import {act, cleanup, fireEvent, render, screen, waitFor, within} from '@testing-library/react';
import {createMemoryRouter, RouterProvider, useLocation, useNavigate} from 'react-router-dom';
import GameLibrary, {clearLibraryPageCache} from './GameLibrary.jsx';
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
@@ -12,7 +12,7 @@ import {clearSession, markSession} from '../utils/auth.js';
import {toast} from 'sonner';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
-vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
+vi.mock('sonner', () => ({toast: Object.assign(vi.fn(), {success: vi.fn(), error: vi.fn()})}));
const syncOptions = vi.hoisted(() => ({current: null}));
const syncState = vi.hoisted(() => ({lastSyncedAt: null, queued: false, isRunning: false}));
vi.mock('../hooks/useLibrarySync.js', () => ({default: (opts) => {
@@ -268,6 +268,114 @@ describe('GameLibrary sort', () => {
await waitFor(() => expect(fetch).toHaveBeenCalled());
expect(new URL(fetch.mock.calls[0][0], 'http://x').searchParams.get('sort')).toBe(sort);
});
+
+ it.each(['name,asc', 'lastPlayedAt,desc', 'timeToBeat,asc'])('sends the new sort %s from the URL', async (sort) => {
+ renderLibrary(`/library?sort=${encodeURIComponent(sort)}`);
+
+ await waitFor(() => expect(fetch).toHaveBeenCalled());
+ expect(new URL(fetch.mock.calls[0][0], 'http://x').searchParams.get('sort')).toBe(sort);
+ });
+
+ it('fallsBackToTheDefaultSortForTheOldLastSeenLink', async () => {
+ renderLibrary('/library?sort=lastSeenAt%2Cdesc');
+
+ await waitFor(() => expect(fetch).toHaveBeenCalled());
+ expect(new URL(fetch.mock.calls[0][0], 'http://x').searchParams.get('sort')).toBe('playtimeMinutes,desc');
+ });
+
+ it('offersTheNewSortsAndNotLastSeen', async () => {
+ // jsdom lacks both; the Radix select needs them when it opens.
+ Element.prototype.hasPointerCapture ??= () => false;
+ Element.prototype.scrollIntoView ??= () => {};
+ renderLibrary();
+
+ fireEvent.keyDown(await screen.findByRole('combobox', {name: 'LIBRARY.SORT_BY'}), {key: 'Enter'});
+
+ const labels = (await screen.findAllByRole('option')).map((option) => option.textContent);
+ expect(labels).toEqual(expect.arrayContaining(['LIBRARY.SORT_NAME', 'LIBRARY.SORT_LAST_PLAYED', 'LIBRARY.SORT_TIME_TO_BEAT']));
+ expect(labels).not.toContain('LIBRARY.SORT_LAST_SEEN');
+ });
+});
+
+describe('GameLibrary row', () => {
+ const rows = [
+ {appid: 220, name: 'Half-Life 2', playtimeMinutes: 0, status: 'BACKLOG', rating: 4, priority: 'HIGH'},
+ {appid: 620, name: 'Portal 2', playtimeMinutes: 0, status: 'PLAYING', rating: null, priority: null},
+ ];
+
+ it('keepsTheWholeRowALinkToTheGame', async () => {
+ answer(rows);
+ renderLibrary();
+
+ const link = await screen.findByRole('link', {name: /Half-Life 2/});
+
+ expect(link.getAttribute('href')).toBe('/library/220');
+ });
+
+ it('setsTheStatusFromTheRowMenuAsksThePointsAndShowsTheNewBadge', async () => {
+ fetchRoutes({
+ 'GET /api/users/me': json(200, {settings: {}}),
+ 'GET /api/library/games': json(200, {...page, content: rows, totalElements: 2, totalPages: 1}),
+ 'GET /api/library/games-count': json(200, {count: 2, hasSteamId: true, canSync: true, sources: []}),
+ 'PUT /api/library/games/220/status': (url, init) => json(200, {...rows[0], ...JSON.parse(init.body)}),
+ 'GET /api/rewards/games/220': json(200, {balance: 5, missing: {}, paid: []}),
+ 'GET /api/rewards/wallet': json(200, {balance: 5, catalog: [], owned: [], equipped: {}, history: []}),
+ });
+ renderLibrary();
+
+ fireEvent.keyDown(await screen.findByRole('button', {name: 'BACKLOG.STATUS_LABEL: BACKLOG.STATUS.BACKLOG'}), {key: 'Enter'});
+ fireEvent.click(await screen.findByRole('menuitemradio', {name: 'BACKLOG.STATUS.FINISHED'}));
+
+ expect(await screen.findByRole('button', {name: 'BACKLOG.STATUS_LABEL: BACKLOG.STATUS.FINISHED'})).toBeTruthy();
+ const put = fetch.mock.calls.find(([, init]) => init?.method === 'PUT');
+ expect(JSON.parse(put[1].body)).toEqual({status: 'FINISHED'});
+ await waitFor(() => expect(fetch.mock.calls.some(([url]) => url.includes('/api/rewards/games/220'))).toBe(true));
+ expect(toast.success.mock.calls.some(([, options]) => options?.action?.label === 'MANUAL_GAME.UNDO')).toBe(true);
+ });
+
+ it('showsTheStarsOnlyWhenSortedByRating', async () => {
+ answer(rows);
+ renderLibrary('/library?sort=rating%2Cdesc');
+
+ expect(await screen.findByRole('img', {name: 'BACKLOG.RATING_STARS:4'})).toBeTruthy();
+ expect(screen.queryByText('BACKLOG.PRIORITY.HIGH')).toBeNull();
+
+ cleanup();
+ answer(rows);
+ renderLibrary();
+ await screen.findByRole('link', {name: /Half-Life 2/});
+ expect(screen.queryByRole('img', {name: /BACKLOG.RATING_STARS/})).toBeNull();
+ });
+
+ it('showsThePriorityOnlyWhenSortedByPriority', async () => {
+ answer(rows);
+ renderLibrary('/library?sort=priority%2Cdesc');
+
+ expect(await screen.findByText('BACKLOG.PRIORITY.HIGH')).toBeTruthy();
+ expect(screen.queryByRole('img', {name: /BACKLOG.RATING_STARS/})).toBeNull();
+ });
+});
+
+describe('GameLibrary pagination', () => {
+ const manyPages = () => fetchRoutes({
+ 'GET /api/users/me': json(200, {settings: {}}),
+ 'GET /api/library/games': json(200, {...page, page: 4, totalElements: 240, totalPages: 12}),
+ 'GET /api/library/games-count': json(200, {count: 240, hasSteamId: true, canSync: true, sources: []}),
+ });
+
+ it('showsPageNumbersMarksTheCurrentOneAndGoesToAClickedPage', async () => {
+ manyPages();
+ const lib = renderLibrary('/library?page=4');
+
+ const nav = await screen.findByRole('navigation', {name: 'LIBRARY.PAGINATION'});
+ const current = within(nav).getByRole('button', {current: 'page'});
+ expect(current.textContent).toBe('5');
+ expect(within(nav).getAllByRole('button').map((b) => b.textContent)).toEqual(['LIBRARY.PREV', '1', '4', '5', '6', '12', 'LIBRARY.NEXT']);
+
+ fireEvent.click(within(nav).getByRole('button', {name: '12'}));
+
+ await waitFor(() => expect(lib.params().get('page')).toBe('11'));
+ });
});
describe('GameLibrary short games', () => {
diff --git a/src/pages/PlayNextPage.jsx b/src/pages/PlayNextPage.jsx
index cdc10c0..0a2515f 100644
--- a/src/pages/PlayNextPage.jsx
+++ b/src/pages/PlayNextPage.jsx
@@ -280,7 +280,8 @@ const emptyKey = (budget, mood) => {
function PickCard({game}) {
const {t, i18n} = useTranslation();
const [started, setStarted] = useState(false);
- const {setStatus, busy} = useSetStatus(game.appid, {checkPoints: true, onChanged: () => setStarted(true)});
+ // The picker lists backlog games only, so Undo goes back to BACKLOG.
+ const {setStatus, busy} = useSetStatus(game.appid, {checkPoints: true, onChanged: (entry) => setStarted(entry?.status !== 'BACKLOG')});
const reason = reasonText(game.reason, t, i18n.language);
return (
@@ -311,7 +312,7 @@ function PickCard({game}) {
{t('PLAY_NEXT.STARTED')}
) : (
- setStatus('PLAYING')} disabled={busy}>
+ setStatus('PLAYING', {previous: 'BACKLOG'})} disabled={busy}>
{busy ? : }
{t('PLAY_NEXT.START_PLAYING')}
diff --git a/src/pages/PlayNextPage.test.jsx b/src/pages/PlayNextPage.test.jsx
index cc62668..877661c 100644
--- a/src/pages/PlayNextPage.test.jsx
+++ b/src/pages/PlayNextPage.test.jsx
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
-import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
+import {act, cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
import {toast} from 'sonner';
import {MemoryRouter} from 'react-router-dom';
import PlayNextPage from './PlayNextPage.jsx';
@@ -326,6 +326,21 @@ describe('PlayNextPage', () => {
expect(JSON.parse(puts[0][1].body)).toEqual({status: 'PLAYING'});
});
+ it('puts the card back to the Start button when the user undoes it', async () => {
+ mockRows([row('NEXT_UP', game(7))]);
+ renderPage();
+ await screen.findByText('Game 7');
+ fireEvent.click(screen.getByRole('button', {name: 'PLAY_NEXT.START_PLAYING'}));
+ await screen.findByText('PLAY_NEXT.STARTED');
+
+ const [, options] = toast.success.mock.calls.find(([, call]) => call?.action);
+ await act(async () => options.action.onClick());
+
+ expect(await screen.findByRole('button', {name: 'PLAY_NEXT.START_PLAYING'})).toBeTruthy();
+ const puts = globalThis.fetch.mock.calls.filter(([, call]) => call?.method === 'PUT');
+ expect(JSON.parse(puts.at(-1)[1].body)).toEqual({status: 'BACKLOG'});
+ });
+
it('shows the points toast after Start', async () => {
mockRows([row('NEXT_UP', game(7))]);
renderPage();
diff --git a/src/utils/backlog.js b/src/utils/backlog.js
index cb2c2c7..a188fc8 100644
--- a/src/utils/backlog.js
+++ b/src/utils/backlog.js
@@ -4,6 +4,9 @@ import {GAME_SOURCES, GAME_STATUSES} from './limits.js';
// Radix Select has no empty value, so "no priority" needs its own value in the form.
export const NO_PRIORITY = 'NONE';
+// How long a toast with an Undo action stays.
+export const UNDO_MS = 8000;
+
export const parseStatus = (value) => (GAME_STATUSES.includes(value) ? value : null);
export const parseSources = (value) => GAME_SOURCES.filter((s) => (value ?? '').split(',').includes(s));
diff --git a/src/utils/pagination.js b/src/utils/pagination.js
new file mode 100644
index 0000000..d5b1c0e
--- /dev/null
+++ b/src/utils/pagination.js
@@ -0,0 +1,13 @@
+/**
+ * The page buttons to show: first, last and the current page with its neighbours, as 0-based numbers. `null` marks a
+ * gap of two or more pages; a gap of one page shows that page instead.
+ */
+export const pageNumbers = (page, totalPages) => {
+ const shown = new Set([0, totalPages - 1, page - 1, page, page + 1].filter((n) => n >= 0 && n < totalPages));
+ const sorted = [...shown].sort((a, b) => a - b);
+ return sorted.flatMap((n, i) => {
+ const gap = i > 0 ? n - sorted[i - 1] : 1;
+ if (gap === 2) return [n - 1, n];
+ return gap > 2 ? [null, n] : [n];
+ });
+};
diff --git a/src/utils/pagination.test.js b/src/utils/pagination.test.js
new file mode 100644
index 0000000..716e319
--- /dev/null
+++ b/src/utils/pagination.test.js
@@ -0,0 +1,25 @@
+import {describe, expect, it} from 'vitest';
+import {pageNumbers} from './pagination.js';
+
+describe('pageNumbers', () => {
+ it('showsEveryPageOfAShortList', () => {
+ expect(pageNumbers(0, 1)).toEqual([0]);
+ expect(pageNumbers(2, 5)).toEqual([0, 1, 2, 3, 4]);
+ });
+
+ it('keepsFirstLastAndTheNeighboursOfTheCurrentPage', () => {
+ expect(pageNumbers(5, 12)).toEqual([0, null, 4, 5, 6, null, 11]);
+ });
+
+ it('marksOneGapAtTheEdges', () => {
+ expect(pageNumbers(0, 12)).toEqual([0, 1, null, 11]);
+ expect(pageNumbers(1, 12)).toEqual([0, 1, 2, null, 11]);
+ expect(pageNumbers(11, 12)).toEqual([0, null, 10, 11]);
+ });
+
+ it('showsAPageInsteadOfAGapOfOne', () => {
+ expect(pageNumbers(3, 7)).toEqual([0, 1, 2, 3, 4, 5, 6]);
+ expect(pageNumbers(4, 9)).toEqual([0, null, 3, 4, 5, null, 8]);
+ expect(pageNumbers(2, 6)).toEqual([0, 1, 2, 3, 4, 5]);
+ });
+});