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});