From 60dcfe899230b6eb4def12bc1b2c6586a12f4867 Mon Sep 17 00:00:00 2001 From: lobo Date: Tue, 20 Oct 2026 10:42:06 +0200 Subject: [PATCH] feat(search): add game search page (#50) Add /search to find games by name on IGDB. A game in the library opens its game page. Other hits open a detail view with an Add button. The query stays in the URL. To fit the new nav icon on 360 px phones, the header padding is smaller and the points chip hides while the Questions icon shows. --- README.md | 2 + public/i18n/de.json | 5 + public/i18n/en.json | 5 + src/App.jsx | 2 + src/components/AddGamePanel.jsx | 6 +- src/components/AppHeader.jsx | 9 +- src/components/AppHeader.points.test.jsx | 10 +- src/components/AppHeader.test.jsx | 6 ++ src/components/GameSearch.jsx | 8 +- src/components/SearchHitCard.jsx | 69 +++++++++++++ src/pages/GameSearchPage.jsx | 50 ++++++++++ src/pages/GameSearchPage.test.jsx | 118 +++++++++++++++++++++++ src/utils/manualGames.js | 2 + 13 files changed, 276 insertions(+), 16 deletions(-) create mode 100644 src/components/SearchHitCard.jsx create mode 100644 src/pages/GameSearchPage.jsx create mode 100644 src/pages/GameSearchPage.test.jsx diff --git a/README.md b/README.md index a363f95..2a591b8 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,8 @@ flowchart LR - **Add a game by name** for games outside Steam (PlayStation, Switch, Xbox, physical): search IGDB, pick a hit, a platform and optional hours. Works without a Steam ID. Games already in ToBePlayed come first. Check several hits to add them at once through the import review. + The **Search** page (`/search`, nav item "Search") finds games by name: results with cover, year, platforms and an + "In library" badge; an own game opens its game page, any other hit opens a detail view with "Add". - **Import** (`/import`): a list (one title per line) or a CSV (title, platform, hours or minutes, status). Core matches the lines with IGDB and saves them in the background; the page shows the steps (queued with the imports before yours, searching on IGDB, your review, adding to the library, time to beat and moods, done) with the diff --git a/public/i18n/de.json b/public/i18n/de.json index 8a2d51b..a5509b5 100644 --- a/public/i18n/de.json +++ b/public/i18n/de.json @@ -373,6 +373,7 @@ "NAV": { "DASHBOARD": "Dashboard", "LIBRARY": "Bibliothek", + "SEARCH": "Suche", "SETTINGS": "Einstellungen", "TO_DASHBOARD": "Zum Dashboard", "MENU": "Menü", @@ -841,6 +842,10 @@ "GAME_NOT_FOUND": "Dieses Spiel ist nicht in deiner Bibliothek." } }, + "GAME_SEARCH": { + "TITLE": "Spiele finden", + "HINT": "Suche bei IGDB nach dem Namen. Eigene Spiele öffnen deine Spielseite, andere kannst du hinzufügen." + }, "CATALOG": { "TITLE": "Spielekatalog", "SEARCH": "Nach Name suchen", diff --git a/public/i18n/en.json b/public/i18n/en.json index 4469d3e..e8fbaba 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -373,6 +373,7 @@ "NAV": { "DASHBOARD": "Dashboard", "LIBRARY": "Library", + "SEARCH": "Search", "SETTINGS": "Settings", "TO_DASHBOARD": "Go to dashboard", "MENU": "Menu", @@ -841,6 +842,10 @@ "GAME_NOT_FOUND": "This game is not in your library." } }, + "GAME_SEARCH": { + "TITLE": "Find games", + "HINT": "Search IGDB by name. Games you own open your game page; others can be added." + }, "CATALOG": { "TITLE": "Game catalog", "SEARCH": "Search by name", diff --git a/src/App.jsx b/src/App.jsx index 5e8b30f..0439dff 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -30,6 +30,7 @@ import BacklogPage from './pages/BacklogPage.jsx'; import PlayNextPage from './pages/PlayNextPage.jsx'; import StatsPage from './pages/StatsPage.jsx'; import ImportPage from './pages/ImportPage.jsx'; +import GameSearchPage from './pages/GameSearchPage.jsx'; import PointsPage from './pages/PointsPage.jsx'; import PublicProfilePage from './pages/PublicProfilePage.jsx'; import AppHeader from './components/AppHeader.jsx'; @@ -86,6 +87,7 @@ function App() { }/> }/> }/> + }/> }/> }/> }/> diff --git a/src/components/AddGamePanel.jsx b/src/components/AddGamePanel.jsx index 48d07f9..41dbf9b 100644 --- a/src/components/AddGamePanel.jsx +++ b/src/components/AddGamePanel.jsx @@ -15,7 +15,7 @@ import GameSearch from './GameSearch.jsx'; import usePointsCheck from '../hooks/usePointsCheck.js'; import useStartImport from '../hooks/useStartImport.js'; import {GAME_STATUSES, MAX_PLATFORM_LENGTH} from '../utils/limits.js'; -import {addGame, hoursToMinutes} from '../utils/manualGames.js'; +import {addGame, hitTitle, hoursToMinutes} from '../utils/manualGames.js'; import {preselectPlatform} from '../utils/platforms.js'; import {useCurrentUserContext} from '../context/CurrentUserContext.jsx'; @@ -86,7 +86,7 @@ export default function AddGamePanel({onClose, onAdded, initialQuery = ''}) { ); } -function AddForm({hit, favorite, onBack, navigate, onAdded}) { +export function AddForm({hit, favorite, onBack, navigate, onAdded}) { const {t} = useTranslation(); const [platform, setPlatform] = useState(() => preselectPlatform(null, hit.platforms, favorite)); // The favorite can arrive after the pick; it fills only a platform the user has not picked yet. @@ -132,7 +132,7 @@ function AddForm({hit, favorite, onBack, navigate, onAdded}) {
- +
diff --git a/src/components/AppHeader.jsx b/src/components/AppHeader.jsx index 0ec47dd..b734295 100644 --- a/src/components/AppHeader.jsx +++ b/src/components/AppHeader.jsx @@ -34,6 +34,7 @@ const NAV_ITEMS = [ {to: '/dashboard', labelKey: 'NAV.DASHBOARD', Icon: LayoutDashboard}, // The wishlist and the backlog order are tabs of the library. {to: '/library', labelKey: 'NAV.LIBRARY', Icon: Library, alsoActiveOn: ['/wishlist', '/backlog']}, + {to: '/search', labelKey: 'NAV.SEARCH', Icon: Search}, {to: '/play-next', labelKey: 'NAV.PLAY_NEXT', Icon: Dices}, {to: '/questions', labelKey: 'NAV.QUESTIONS', Icon: CircleQuestionMark}, {to: '/stats', labelKey: 'NAV.STATS', Icon: ChartColumn}, @@ -120,7 +121,7 @@ export default function AppHeader({isAuthenticated, onLogout}) { }, [isAuthenticated, pathname]); const shownQuestions = pathname === '/questions' ? 0 : openQuestions; - // 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. + // Below sm the Questions icon shows only with open questions, removed games or on its own page; the points chip is hidden (/points stays in the menu). const questionsIconHidden = openQuestions === 0 && !hasRemovedGames && pathname !== '/questions'; const switchRole = async (role) => { @@ -157,7 +158,7 @@ export default function AppHeader({isAuthenticated, onLogout}) { return (
- + {/* On a narrow phone the menu and buttons need the room; the logo alone stays. */} @@ -188,10 +189,10 @@ export default function AppHeader({isAuthenticated, onLogout}) { {t('NAV.VIEW_AS.BADGE', {role: t(`ROLES.${viewingAs}`)})} )} {isAuthenticated && wallet && ( - )} diff --git a/src/components/AppHeader.points.test.jsx b/src/components/AppHeader.points.test.jsx index 52b10fd..683df11 100644 --- a/src/components/AppHeader.points.test.jsx +++ b/src/components/AppHeader.points.test.jsx @@ -34,20 +34,18 @@ async function loadChip(count) { } describe('AppHeader points chip', () => { - it('hides the number below sm while the questions icon shows, and keeps the balance as label', async () => { + it('hides the chip below sm while the questions icon shows', async () => { const chip = await loadChip(2); await screen.findByRole('link', {name: 'NAV.QUESTIONS_OPEN:2'}); - expect(chip.getAttribute('aria-label')).toBe('POINTS.TITLE: 135'); - expect(screen.getByText('135').className).toContain('max-sm:hidden'); + expect(chip.className).toContain('max-sm:hidden'); }); - it('shows the number at every width while the questions icon is hidden', async () => { + it('shows the chip at every width while the questions icon is hidden', async () => { const chip = await loadChip(0); await screen.findByTitle('NAV.QUESTIONS'); - expect(chip.getAttribute('aria-label')).toBe('POINTS.TITLE: 135'); - expect(screen.getByText('135').className).not.toContain('max-sm:hidden'); + expect(chip.className).not.toContain('max-sm:hidden'); }); it('shows the balance as a link to the points page once the wallet is loaded', async () => { diff --git a/src/components/AppHeader.test.jsx b/src/components/AppHeader.test.jsx index 51c2da9..0da8d6d 100644 --- a/src/components/AppHeader.test.jsx +++ b/src/components/AppHeader.test.jsx @@ -55,6 +55,12 @@ describe('AppHeader', () => { expect(screen.getByTitle('NAV.FOLLOWING').getAttribute('href')).toBe('/following'); }); + it('links to the game search when logged in', () => { + loggedInAs({role: 'USER', activeRole: 'USER'}); + + expect(screen.getByTitle('NAV.SEARCH').getAttribute('href')).toBe('/search'); + }); + it('links to the community stats when logged in', () => { loggedInAs({role: 'USER', activeRole: 'USER'}); diff --git a/src/components/GameSearch.jsx b/src/components/GameSearch.jsx index abd83d3..4733d4b 100644 --- a/src/components/GameSearch.jsx +++ b/src/components/GameSearch.jsx @@ -6,7 +6,7 @@ import {Badge} from '@/components/ui/badge'; import {Input} from '@/components/ui/input'; import GameCover from './GameCover.jsx'; import FitText from './FitText.jsx'; -import {searchGames} from '../utils/manualGames.js'; +import {hitTitle, searchGames} from '../utils/manualGames.js'; import {MAX_GAME_SEARCH_LENGTH, MIN_GAME_SEARCH_LENGTH} from '../utils/limits.js'; const SEARCH_DEBOUNCE_MS = 400; @@ -17,9 +17,11 @@ const SEARCH_DEBOUNCE_MS = 400; * * @param hide hits not offered at all * @param blockInLibrary hits already in the own library are shown, but cannot be picked + * @param markInLibrary hits in the own library get the "In library" badge; defaults to blockInLibrary * @param selected with onToggle: igdb ids checked for a pick of several games; each hit gets a checkbox */ export default function GameSearch({query, onQueryChange, onPick, hide = () => false, blockInLibrary = false, + markInLibrary = blockInLibrary, selected, onToggle}) { const {t} = useTranslation(); const {searchState, results} = useGameSearch(query); @@ -62,13 +64,13 @@ export default function GameSearch({query, onQueryChange, onPick, hide = () => f - + {hit.platforms.join(' · ')} - {blocked && {t('MANUAL_GAME.IN_LIBRARY')}} + {markInLibrary && hit.inLibrary && {t('MANUAL_GAME.IN_LIBRARY')}} ); diff --git a/src/components/SearchHitCard.jsx b/src/components/SearchHitCard.jsx new file mode 100644 index 0000000..a0d1331 --- /dev/null +++ b/src/components/SearchHitCard.jsx @@ -0,0 +1,69 @@ +import {useEffect, useState} from 'react'; +import {useNavigate} from 'react-router-dom'; +import {useTranslation} from 'react-i18next'; +import {ArrowLeft, Plus} from 'lucide-react'; + +import {Button} from '@/components/ui/button'; +import {Card} from '@/components/ui/card'; +import {AddForm} from './AddGamePanel.jsx'; +import FitText from './FitText.jsx'; +import GameCover from './GameCover.jsx'; +import TimeToBeat from './TimeToBeat.jsx'; +import {useCurrentUserContext} from '../context/CurrentUserContext.jsx'; +import {fetchPreview} from '../utils/catalog.js'; +import {hitTitle} from '../utils/manualGames.js'; + +/** + * A search hit the user does not have: what the hit says, genres and time to beat when the catalog knows the game, + * and the add form behind an "Add" button. + */ +export default function SearchHitCard({hit, onBack}) { + const {t} = useTranslation(); + const navigate = useNavigate(); + const {user} = useCurrentUserContext(); + const favorite = user?.settings?.favoritePlatform ?? null; + const [preview, setPreview] = useState(null); + const [adding, setAdding] = useState(false); + + useEffect(() => { + let cancelled = false; + setPreview(null); + if (!hit.inCatalog) return undefined; + (async () => { + const {ok, data} = await fetchPreview(hit.appid); + if (!cancelled && ok && data) setPreview(data); + })(); + return () => { + cancelled = true; + }; + }, [hit.appid, hit.inCatalog]); + + return ( + + {adding ? ( + + ) : ( +
+ +
+

+ +

+ {hit.platforms.length > 0 && ( +

{hit.platforms.join(' · ')}

+ )} + {preview?.genres?.length > 0 && ( +

{preview.genres.join(', ')}

+ )} + {preview && } +

{t('CATALOG.NOT_IN_LIBRARY')}

+
+ + +
+
+
+ )} +
+ ); +} diff --git a/src/pages/GameSearchPage.jsx b/src/pages/GameSearchPage.jsx new file mode 100644 index 0000000..e38f62a --- /dev/null +++ b/src/pages/GameSearchPage.jsx @@ -0,0 +1,50 @@ +import {useEffect, useState} from 'react'; +import {useNavigate, useSearchParams} from 'react-router-dom'; +import {useTranslation} from 'react-i18next'; +import {Search} from 'lucide-react'; + +import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card'; +import GameSearch from '../components/GameSearch.jsx'; +import PageContainer from '../components/PageContainer.jsx'; +import SearchHitCard from '../components/SearchHitCard.jsx'; + +/** + * Find games by name on IGDB. A game the user owns opens its page; any other hit opens a detail view with "Add". + * The query lives in the URL, so it stays when the user comes back from a game page. + */ +export default function GameSearchPage() { + const {t} = useTranslation(); + const navigate = useNavigate(); + const [params, setParams] = useSearchParams(); + const [query, setQuery] = useState(params.get('q') ?? ''); + const [picked, setPicked] = useState(null); + + // The input keeps its own state: a router update per keystroke runs in a transition and drops keys of a burst. + useEffect(() => { + const timer = setTimeout(() => setParams(query ? {q: query} : {}, {replace: true}), 400); + return () => clearTimeout(timer); + }, [query, setParams]); + const onQueryChange = (q) => { + setPicked(null); + setQuery(q); + }; + const onPick = (hit) => (hit.inLibrary ? navigate(`/library/${hit.appid}`) : setPicked(hit)); + + return ( + + {picked ? ( + setPicked(null)}/> + ) : ( + + + {t('GAME_SEARCH.TITLE')} + + +

{t('GAME_SEARCH.HINT')}

+ +
+
+ )} +
+ ); +} diff --git a/src/pages/GameSearchPage.test.jsx b/src/pages/GameSearchPage.test.jsx new file mode 100644 index 0000000..312a7bf --- /dev/null +++ b/src/pages/GameSearchPage.test.jsx @@ -0,0 +1,118 @@ +// @vitest-environment jsdom +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {act, cleanup, fireEvent, screen, waitFor} from '@testing-library/react'; +import GameSearchPage from './GameSearchPage.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: Object.assign(vi.fn(), {success: vi.fn(), error: vi.fn()})})); + +const zelda = { + appid: -1025, igdbId: 1025, name: 'Zelda', year: 2017, imageUrl: 'https://images.igdb.com/z.jpg', + platforms: ['Nintendo Switch', 'Wii U'], inLibrary: false, inCatalog: true, +}; +const portal = {...zelda, appid: 400, igdbId: 71, name: 'Portal', inLibrary: true}; + +function renderPage(found = json(200, {available: true, results: [zelda, portal]}), entries = ['/search']) { + fetchRoutes({ + 'GET /api/users/me': json(200, {role: 'USER', settings: {favoritePlatform: 'Nintendo Switch'}}), + 'GET /api/library/game-search': found, + 'GET /api/library/games/-1025/preview': json(200, {appid: -1025, name: 'Zelda', genres: ['Adventure'], timeToBeat: null}), + 'POST /api/library/games': json(201, {appid: -1025}), + 'GET /api/rewards/games/-1025': json(200, {balance: 15, missing: {}, paid: []}), + 'GET /api/rewards/wallet': json(200, {balance: 15, catalog: [], owned: [], equipped: {}, history: []}), + }); + return renderWithProviders(, {path: '/search', entries, + routes: {'/library/:appid':

detail page

}}); +} + +const searchCalls = () => fetch.mock.calls.filter(([url]) => url.includes('game-search')); +const search = (value) => fireEvent.change(screen.getByRole('searchbox'), {target: {value}}); + +beforeEach(() => { + localStorage.setItem('tbp_session', '1'); +}); + +afterEach(() => { + cleanup(); + localStorage.clear(); + vi.clearAllMocks(); +}); + +describe('GameSearchPage', () => { + it('doesNotSearchBelowTwoCharacters', async () => { + renderPage(); + search('z'); + + await new Promise((resolve) => setTimeout(resolve, 600)); + expect(searchCalls()).toHaveLength(0); + }); + + it('showsHitsWithYearPlatformsAndInLibraryBadge', async () => { + renderPage(); + search('zel'); + + expect(await screen.findByRole('button', {name: /Zelda \(2017\)/})).toBeTruthy(); + expect(screen.getAllByText('Nintendo Switch · Wii U').length).toBe(2); + const portalButton = screen.getByRole('button', {name: /Portal/}); + expect(portalButton.textContent).toContain('MANUAL_GAME.IN_LIBRARY'); + expect(portalButton.disabled).toBe(false); + expect(screen.getByRole('button', {name: /Zelda/}).textContent).not.toContain('MANUAL_GAME.IN_LIBRARY'); + }); + + it('opensTheOwnGamePageForAGameInTheLibrary', async () => { + const {router} = renderPage(); + search('port'); + fireEvent.click(await screen.findByRole('button', {name: /Portal/})); + + expect(await screen.findByText('detail page')).toBeTruthy(); + expect(router.state.location.pathname).toBe('/library/400'); + }); + + it('showsTheDetailAndAddsAGameNotInTheLibrary', async () => { + renderPage(); + search('zelda'); + fireEvent.click(await screen.findByRole('button', {name: /Zelda/})); + + expect(await screen.findByText('Adventure')).toBeTruthy(); + expect(screen.getByText('CATALOG.NOT_IN_LIBRARY')).toBeTruthy(); + fireEvent.click(screen.getByRole('button', {name: 'CATALOG.ADD'})); + fireEvent.click(await screen.findByRole('button', {name: 'MANUAL_GAME.ADD'})); + + expect(await screen.findByText('detail page')).toBeTruthy(); + const [, init] = fetch.mock.calls.find(([, call]) => call?.method === 'POST'); + expect(JSON.parse(init.body)).toEqual({ + igdbId: 1025, platform: 'Nintendo Switch', status: 'BACKLOG', playtimeMinutes: 0}); + }); + + it('keepsTheQueryInTheUrlAndSearchesOnEntry', async () => { + renderPage(undefined, ['/search?q=zelda']); + + expect(screen.getByRole('searchbox').value).toBe('zelda'); + expect(await screen.findByRole('button', {name: /Zelda/})).toBeTruthy(); + expect(searchCalls()).toHaveLength(1); + }); + + it('keepsEveryKeystrokeOfABurst', () => { + renderPage(); + const input = screen.getByRole('searchbox'); + act(() => { + fireEvent.change(input, {target: {value: 'z'}}); + fireEvent.change(input, {target: {value: `${input.value}e`}}); + }); + + expect(input.value).toBe('ze'); + }); + + it.each([ + [json(200, {available: true, results: []}), 'MANUAL_GAME.NO_HITS'], + [json(200, {available: false, results: []}), 'MANUAL_GAME.UNAVAILABLE'], + [json(502, {}), 'MANUAL_GAME.SEARCH_FAILED'], + ])('showsEmptyUnavailableAndFailedStates_%#', async (found, text) => { + renderPage(found); + search('zelda'); + + await waitFor(() => expect(screen.getByText(text)).toBeTruthy()); + }); +}); diff --git a/src/utils/manualGames.js b/src/utils/manualGames.js index 2ac9790..8d0e495 100644 --- a/src/utils/manualGames.js +++ b/src/utils/manualGames.js @@ -15,6 +15,8 @@ export const hoursToMinutes = (hours) => { return Number.isFinite(value) && value > 0 ? Math.round(value * 60) : 0; }; +export const hitTitle = (hit) => `${hit.name}${hit.year ? ` (${hit.year})` : ''}`; + // The calls return the raw response; the caller handles the status. export const searchGames = (query, signal) => apiFetch(`${LIBRARY_URL()}/game-search?${new URLSearchParams({q: query.trim()})}`, {signal});