diff --git a/README.md b/README.md index 3248531..09ef4cb 100644 --- a/README.md +++ b/README.md @@ -281,6 +281,9 @@ flowchart LR 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`. + 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. ```mermaid flowchart LR @@ -312,6 +315,7 @@ flowchart LR Header["AppHeader badge"] --> Count["Core /api/library/questions/count"] Page["QuestionsPage"] --> List["Core /api/library/questions"] Page -->|"Yes / No / stars"| Answer["Core POST /api/library/questions/{appid}"] + Page -->|"Removed games, Restore"| Removed["Core /api/library/removed-games, POST /games/{appid}/restore"] ``` - Owner and user lists (`src/components/people/`, `src/utils/people.js`) need a login and show listed users by name (a link to `/u/`), everyone else as "+N private", the viewer as "You". The setting "show me in owner lists" is diff --git a/public/i18n/de.json b/public/i18n/de.json index 2966b5c..7f7ef32 100644 --- a/public/i18n/de.json +++ b/public/i18n/de.json @@ -540,10 +540,11 @@ "PLATFORM": "Plattform", "HOURS": "Gespielte Stunden", "REMOVE": "Aus der Bibliothek entfernen", - "REMOVE_CONFIRM": "Dieses Spiel mit Status, Bewertung und Notizen entfernen?", + "REMOVE_CONFIRM": "Dieses Spiel entfernen? Du kannst es unter Fragen wiederherstellen.", "REMOVE_YES": "Ja, entfernen", "REMOVE_NO": "Abbrechen", "REMOVED": "Spiel entfernt.", + "UNDO": "Rückgängig", "NO_ACHIEVEMENTS": "Erfolge und Trophäen gibt es nur für Spiele von Steam oder PlayStation.", "SELECT": "{{name}} auswählen", "SELECTED": "{{count}} ausgewählt", @@ -1341,6 +1342,12 @@ "STATUS": "Statuswechsel" } }, + "REMOVED_GAMES": { + "TITLE_COUNT": "Entfernte Spiele ({{count}})", + "RESTORE": "Wiederherstellen", + "RESTORED": "Spiel wiederhergestellt.", + "HINT": "Wiederhergestellte Spiele behalten Status, Bewertung und Notizen." + }, "QUESTIONS": { "TITLE": "Fragen", "TITLE_COUNT": "Fragen ({{count}})", diff --git a/public/i18n/en.json b/public/i18n/en.json index 592b153..501d496 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -540,10 +540,11 @@ "PLATFORM": "Platform", "HOURS": "Hours played", "REMOVE": "Remove from library", - "REMOVE_CONFIRM": "Remove this game with its status, rating and notes?", + "REMOVE_CONFIRM": "Remove this game? You can restore it under Questions.", "REMOVE_YES": "Yes, remove", "REMOVE_NO": "Cancel", "REMOVED": "Game removed.", + "UNDO": "Undo", "NO_ACHIEVEMENTS": "Achievements and trophies exist only for games from Steam or PlayStation.", "SELECT": "Select {{name}}", "SELECTED": "{{count}} selected", @@ -1341,6 +1342,12 @@ "STATUS": "Status changes" } }, + "REMOVED_GAMES": { + "TITLE_COUNT": "Removed games ({{count}})", + "RESTORE": "Restore", + "RESTORED": "Game restored.", + "HINT": "Restored games keep their status, rating and notes." + }, "QUESTIONS": { "TITLE": "Questions", "TITLE_COUNT": "Questions ({{count}})", diff --git a/src/App.test.jsx b/src/App.test.jsx index ca45bfc..b268cf3 100644 --- a/src/App.test.jsx +++ b/src/App.test.jsx @@ -36,6 +36,7 @@ describe('App', () => { 'POST /api/auth/refresh': json(401, {}), 'GET /api/inbox': json(200, {results: [], unread: 0}), 'GET /api/library/questions/count': json(200, {count: 0}), + 'GET /api/library/removed-games': json(200, {results: []}), }); const {router} = renderWithProviders(, {entries: ['/library']}); await waitFor(() => expect(screen.getByText('Half-Life 2')).toBeTruthy()); diff --git a/src/components/AppHeader.jsx b/src/components/AppHeader.jsx index 63814d9..a8f917a 100644 --- a/src/components/AppHeader.jsx +++ b/src/components/AppHeader.jsx @@ -26,6 +26,7 @@ import useSteamProfile from '../hooks/useSteamProfile.js'; import {fetchPendingCount, ROLES, viewAs} from '../utils/admin.js'; import {canCorrect} from '../utils/corrections.js'; import {fetchInbox} from '../utils/inbox.js'; +import {fetchRemovedGames} from '../utils/manualGames.js'; import {fetchQuestionCount} from '../utils/questions.js'; import {expireSession, markSession} from '../utils/auth.js'; @@ -97,19 +98,29 @@ export default function AppHeader({isAuthenticated, onLogout}) { // Loaded again on every page change. The questions page shows the live count in its title, so it has no badge. const [openQuestions, setOpenQuestions] = useState(0); + const [hasRemovedGames, setHasRemovedGames] = useState(false); useEffect(() => { if (!isAuthenticated) return undefined; let current = true; + // The removed games are restored on the questions page, so they keep its link on a phone. Asked only + // while no question is open: an open question keeps the link anyway. fetchQuestionCount().then(({ok, data}) => { - if (current && ok) setOpenQuestions(data.count); + if (!current || !ok) return; + setOpenQuestions(data.count); + if (data.count === 0 && pathname !== '/questions') { + fetchRemovedGames().then((removed) => { + if (current && removed.ok) setHasRemovedGames(removed.data.results.length > 0); + }); + } }); return () => { current = false; }; }, [isAuthenticated, pathname]); const shownQuestions = pathname === '/questions' ? 0 : openQuestions; - // Below sm the Questions icon shows only with open questions or on its own page; the points chip then gives up its number. - const questionsIconHidden = openQuestions === 0 && pathname !== '/questions'; + + // Below sm the Questions icon shows only with open questions, removed games or on its own page; the points chip then gives up its number. + const questionsIconHidden = openQuestions === 0 && !hasRemovedGames && pathname !== '/questions'; const switchRole = async (role) => { try { diff --git a/src/components/AppHeader.points.test.jsx b/src/components/AppHeader.points.test.jsx index c225108..52b10fd 100644 --- a/src/components/AppHeader.points.test.jsx +++ b/src/components/AppHeader.points.test.jsx @@ -25,6 +25,7 @@ async function loadChip(count) { 'GET /api/library/steam-profile': json(200, {}), 'GET /api/inbox': json(200, {results: [], unread: 0}), 'GET /api/library/questions/count': json(200, {count}), + 'GET /api/library/removed-games': json(200, {results: []}), 'GET /api/rewards/wallet': json(200, {balance: 135, catalog: [], owned: [], equipped: {}, history: []}), }); renderWithProviders(<> {}}/>); @@ -56,6 +57,7 @@ describe('AppHeader points chip', () => { 'GET /api/library/steam-profile': json(200, {}), 'GET /api/inbox': json(200, {results: [], unread: 0}), 'GET /api/library/questions/count': json(200, {count: 0}), + 'GET /api/library/removed-games': json(200, {results: []}), 'GET /api/rewards/wallet': json(200, {balance: 135, catalog: [], owned: [], equipped: {}, history: []}), }); renderWithProviders(<> {}}/>); diff --git a/src/components/AppHeader.test.jsx b/src/components/AppHeader.test.jsx index bfc65fd..9040652 100644 --- a/src/components/AppHeader.test.jsx +++ b/src/components/AppHeader.test.jsx @@ -20,7 +20,8 @@ const loggedInAs = (me, routes = {}, options = {}) => { localStorage.setItem('tbp_session', '1'); fetchRoutes({'GET /api/users/me': json(200, me), 'GET /api/library/steam-profile': json(200, {}), 'GET /api/inbox': json(200, {results: [], unread: 0}), - 'GET /api/library/questions/count': json(200, {count: 0}), ...routes}); + 'GET /api/library/questions/count': json(200, {count: 0}), + 'GET /api/library/removed-games': json(200, {results: []}), ...routes}); renderWithProviders( {}}/>, options); }; @@ -108,6 +109,24 @@ describe('AppHeader', () => { expect((await screen.findByRole('link', {name: 'NAV.QUESTIONS_OPEN:2'})).className).not.toContain('max-sm:hidden'); }); + it('keeps the questions icon at every width while the user has removed games', async () => { + loggedInAs({role: 'USER', activeRole: 'USER'}, + {'GET /api/library/removed-games': json(200, {results: [{appid: 1, name: 'Game'}]})}); + + await vi.waitFor(() => expect(screen.getByTitle('NAV.QUESTIONS').className).not.toContain('max-sm:hidden')); + }); + + it('skipsTheRemovedGamesCallWhileQuestionsAreOpen', async () => { + const removed = vi.fn(() => json(200, {results: []})); + loggedInAs({role: 'USER', activeRole: 'USER'}, {'GET /api/library/questions/count': json(200, {count: 2}), + 'GET /api/library/removed-games': removed}); + + await screen.findByRole('link', {name: 'NAV.QUESTIONS_OPEN:2'}); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(removed).not.toHaveBeenCalled(); + }); + it('keeps the questions icon below sm on the questions page, without a badge', async () => { loggedInAs({role: 'USER', activeRole: 'USER'}, {'GET /api/library/questions/count': json(200, {count: 0})}, {entries: ['/questions']}); diff --git a/src/components/ManualGameCard.jsx b/src/components/ManualGameCard.jsx index 18669a2..7601836 100644 --- a/src/components/ManualGameCard.jsx +++ b/src/components/ManualGameCard.jsx @@ -8,10 +8,12 @@ import {Button} from '@/components/ui/button'; import {Card} from '@/components/ui/card'; import {Input} from '@/components/ui/input'; import {Label} from '@/components/ui/label'; -import {hoursToMinutes, removeGame, toHours, updateManualGame} from '../utils/manualGames.js'; +import {hoursToMinutes, removeGame, restoreGame, toHours, updateManualGame} from '../utils/manualGames.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. * @@ -54,8 +56,16 @@ export default function ManualGameCard({game, onSaved}) { toast.success(t('BACKLOG.SAVED')); }); + const undoRemove = () => run(() => restoreGame(game.appid), () => { + toast.success(t('REMOVED_GAMES.RESTORED')); + navigate(`/library/${game.appid}`); + }); + const remove = () => run(() => removeGame(game.appid), () => { - toast.success(t('MANUAL_GAME.REMOVED')); + toast.success(t('MANUAL_GAME.REMOVED'), { + duration: UNDO_MS, + action: {label: t('MANUAL_GAME.UNDO'), onClick: undoRemove}, + }); // The game is gone, so are its unsaved edits on this page. discardChanges(); navigate('/library', {replace: true}); diff --git a/src/components/ManualGameCard.test.jsx b/src/components/ManualGameCard.test.jsx index 1d0fcca..2da15a6 100644 --- a/src/components/ManualGameCard.test.jsx +++ b/src/components/ManualGameCard.test.jsx @@ -64,6 +64,43 @@ describe('ManualGameCard', () => { await waitFor(() => expect(router.state.location.pathname).toBe('/library')); }); + it('offers an undo after the remove that restores the game and opens it', async () => { + answer({ + 'DELETE /api/library/games/-1025': json(204, null), + 'POST /api/library/games/-1025/restore': json(204, null), + }); + const {router} = renderCard(); + fireEvent.click(screen.getByRole('button', {name: 'MANUAL_GAME.REMOVE'})); + fireEvent.click(screen.getByRole('button', {name: 'MANUAL_GAME.REMOVE_YES'})); + await waitFor(() => expect(router.state.location.pathname).toBe('/library')); + + const [message, options] = toast.success.mock.calls.find(([text]) => text === 'MANUAL_GAME.REMOVED'); + expect(message).toBe('MANUAL_GAME.REMOVED'); + expect(options.action.label).toBe('MANUAL_GAME.UNDO'); + await options.action.onClick(); + + const [url] = fetch.mock.calls.find(([, init]) => init?.method === 'POST'); + expect(url).toContain('/api/library/games/-1025/restore'); + await waitFor(() => expect(router.state.location.pathname).toBe('/library/-1025')); + expect(toast.success).toHaveBeenCalledWith('REMOVED_GAMES.RESTORED'); + }); + + it('shows an error when the undo fails', async () => { + answer({ + 'DELETE /api/library/games/-1025': json(204, null), + 'POST /api/library/games/-1025/restore': json(500, {}), + }); + const {router} = renderCard(); + fireEvent.click(screen.getByRole('button', {name: 'MANUAL_GAME.REMOVE'})); + fireEvent.click(screen.getByRole('button', {name: 'MANUAL_GAME.REMOVE_YES'})); + await waitFor(() => expect(router.state.location.pathname).toBe('/library')); + + await toast.success.mock.calls.find(([text]) => text === 'MANUAL_GAME.REMOVED')[1].action.onClick(); + + expect(toast.error).toHaveBeenCalledWith('BACKLOG.SAVE_FAILED'); + expect(router.state.location.pathname).toBe('/library'); + }); + it('shows an error when the save fails', async () => { answer({'PUT /api/library/games/-1025/manual': json(500, {})}); const onSaved = vi.fn(); diff --git a/src/components/RemovedGamesSection.jsx b/src/components/RemovedGamesSection.jsx new file mode 100644 index 0000000..b7e98e9 --- /dev/null +++ b/src/components/RemovedGamesSection.jsx @@ -0,0 +1,70 @@ +import {useEffect, useState} from 'react'; +import {useTranslation} from 'react-i18next'; +import {toast} from 'sonner'; + +import {Button} from '@/components/ui/button'; +import GameCover from './GameCover.jsx'; +import {fetchRemovedGames, restoreGame} from '../utils/manualGames.js'; + +/** + * The games the user removed, in a closed `
`, one Restore button each. Shows nothing when there are none or + * the list does not load: it is a safety net, not a page. + */ +export default function RemovedGamesSection() { + const {t} = useTranslation(); + const [games, setGames] = useState([]); + const [busy, setBusy] = useState(false); + + useEffect(() => { + let active = true; + fetchRemovedGames().then(({ok, data}) => { + if (active && ok) setGames(data.results); + }); + return () => { + active = false; + }; + }, []); + + const restore = async (appid) => { + setBusy(true); + try { + const response = await restoreGame(appid); + if (response.status === 401) return; + if (response.ok || response.status === 404) { + setGames((current) => current.filter((game) => game.appid !== appid)); + if (response.ok) toast.success(t('REMOVED_GAMES.RESTORED')); + } else { + toast.error(t('BACKLOG.SAVE_FAILED')); + } + } catch (err) { + console.error('restore error:', err); + toast.error(t('INFO.ERROR.NETWORK_FAILURE')); + } finally { + setBusy(false); + } + }; + + if (games.length === 0) return null; + return ( +
+ {t('REMOVED_GAMES.TITLE_COUNT', {count: games.length})} +

{t('REMOVED_GAMES.HINT')}

+
    + {games.map((game) => ( +
  • +
    + +
    +
    +

    {game.name ?? `App ${game.appid}`}

    + {game.platform &&

    {game.platform}

    } +
    + +
  • + ))} +
+
+ ); +} diff --git a/src/components/RemovedGamesSection.test.jsx b/src/components/RemovedGamesSection.test.jsx new file mode 100644 index 0000000..c8a4773 --- /dev/null +++ b/src/components/RemovedGamesSection.test.jsx @@ -0,0 +1,82 @@ +// @vitest-environment jsdom +import {afterEach, 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 RemovedGamesSection from './RemovedGamesSection.jsx'; +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 removed = (appid, name) => ({appid, name, imageUrl: null, source: 'MANUAL', platform: 'Switch', removedAt: '2026-10-10T10:00:00Z'}); +const LIST = 'GET /api/library/removed-games'; + +const renderSection = (routes) => { + fetchRoutes(routes); + return render(); +}; + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +describe('RemovedGamesSection', () => { + it('isClosedByDefaultAndListsTheGames', async () => { + const {container} = renderSection({[LIST]: json(200, {results: [removed(-1, 'Zelda'), removed(-2, 'Mario')]})}); + + expect(await screen.findByText('REMOVED_GAMES.TITLE_COUNT:2')).toBeTruthy(); + expect(container.querySelector('details').open).toBe(false); + expect(screen.getByText('Zelda')).toBeTruthy(); + expect(screen.getByText('Mario')).toBeTruthy(); + }); + + it('showsNoLinkToTheGamePage', async () => { + renderSection({[LIST]: json(200, {results: [removed(-1, 'Zelda')]})}); + await screen.findByText('Zelda'); + + expect(screen.queryByRole('link')).toBeNull(); + }); + + it('restoresAGameAndDropsItsRow', async () => { + renderSection({ + [LIST]: json(200, {results: [removed(-1, 'Zelda'), removed(-2, 'Mario')]}), + 'POST /api/library/games/-1/restore': json(204, null), + }); + await screen.findByText('Zelda'); + + fireEvent.click(screen.getAllByRole('button', {name: 'REMOVED_GAMES.RESTORE'})[0]); + + await waitFor(() => expect(screen.queryByText('Zelda')).toBeNull()); + expect(screen.getByText('Mario')).toBeTruthy(); + expect(toast.success).toHaveBeenCalledWith('REMOVED_GAMES.RESTORED'); + }); + + it('showsAnErrorAndKeepsTheRowWhenTheRestoreFails', async () => { + renderSection({ + [LIST]: json(200, {results: [removed(-1, 'Zelda')]}), + 'POST /api/library/games/-1/restore': json(500, {}), + }); + await screen.findByText('Zelda'); + + fireEvent.click(screen.getByRole('button', {name: 'REMOVED_GAMES.RESTORE'})); + + await waitFor(() => expect(toast.error).toHaveBeenCalledWith('BACKLOG.SAVE_FAILED')); + expect(screen.getByText('Zelda')).toBeTruthy(); + }); + + it('rendersNothingForAnEmptyList', async () => { + const {container} = renderSection({[LIST]: json(200, {results: []})}); + + await waitFor(() => expect(fetch).toHaveBeenCalled()); + expect(container.innerHTML).toBe(''); + }); + + it('rendersNothingWhenTheListFails', async () => { + const {container} = renderSection({[LIST]: json(500, {})}); + + await waitFor(() => expect(fetch).toHaveBeenCalled()); + expect(container.innerHTML).toBe(''); + }); +}); diff --git a/src/pages/QuestionsPage.jsx b/src/pages/QuestionsPage.jsx index 9d078ee..4d579d7 100644 --- a/src/pages/QuestionsPage.jsx +++ b/src/pages/QuestionsPage.jsx @@ -5,6 +5,7 @@ import {toast} from 'sonner'; import PageContainer from '../components/PageContainer.jsx'; import QuestionCard from '../components/QuestionCard.jsx'; +import RemovedGamesSection from '../components/RemovedGamesSection.jsx'; import usePointsCheck from '../hooks/usePointsCheck.js'; import {answerQuestion, fetchQuestions} from '../utils/questions.js'; import {loadExpected} from '../utils/rewards.js'; @@ -84,6 +85,7 @@ export default function QuestionsPage() { answer(item, choice)}/> ))} + ); } diff --git a/src/pages/QuestionsPage.test.jsx b/src/pages/QuestionsPage.test.jsx index 464d5f9..849f1ea 100644 --- a/src/pages/QuestionsPage.test.jsx +++ b/src/pages/QuestionsPage.test.jsx @@ -17,6 +17,7 @@ const booked = (appid) => ({balance: 30, missing: {}, paid: [{kind: 'FINISHED', const WALLET = { 'GET /api/rewards/wallet': json(200, {balance: 30, catalog: [], owned: [], equipped: {}, history: []}), 'GET /api/rewards/expected': json(200, {}), + 'GET /api/library/removed-games': json(200, {results: []}), }; const expectedCalls = () => globalThis.fetch.mock.calls.filter(([url]) => url.includes('/api/rewards/expected')); const pointsCalls = () => globalThis.fetch.mock.calls.filter(([url]) => url.includes('/api/rewards/games/')); @@ -40,6 +41,16 @@ afterEach(() => { }); describe('QuestionsPage', () => { + it('showsTheRemovedGamesSectionAlsoWithoutQuestions', async () => { + renderPage({ + 'GET /api/library/questions': json(200, {results: []}), + 'GET /api/library/removed-games': json(200, {results: [{appid: -1, name: 'Zelda', imageUrl: null, source: 'MANUAL', platform: 'Switch'}]}), + }); + + expect(await screen.findByText('QUESTIONS.EMPTY')).toBeTruthy(); + expect(await screen.findByText('REMOVED_GAMES.TITLE_COUNT:1')).toBeTruthy(); + }); + it('showsOneCardPerGameWithTheCountInTheTitle', async () => { renderPage({'GET /api/library/questions': json(200, {results: [card(1, 'START'), card(2, 'FINISHED')]})}); diff --git a/src/utils/manualGames.js b/src/utils/manualGames.js index cc53ebb..2ac9790 100644 --- a/src/utils/manualGames.js +++ b/src/utils/manualGames.js @@ -1,4 +1,4 @@ -import {apiFetch} from './api.js'; +import {apiFetch, request} from './api.js'; const LIBRARY_URL = () => `${import.meta.env.VITE_CORE_API_URL}/api/library`; const JSON_HEADERS = {'Content-Type': 'application/json'}; @@ -34,3 +34,11 @@ export const updateManualGame = (appid, {platform, playtimeMinutes}) => }); export const removeGame = (appid) => apiFetch(`${LIBRARY_URL()}/games/${appid}`, {method: 'DELETE'}); + +/** + * GET /api/library/removed-games: `{results: [{appid, name, imageUrl, source, platform, removedAt}]}`, the games the + * user removed, newest first. + */ +export const fetchRemovedGames = () => request(`${LIBRARY_URL()}/removed-games`); + +export const restoreGame = (appid) => apiFetch(`${LIBRARY_URL()}/games/${appid}/restore`, {method: 'POST'});