Public Access
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.
This commit is contained in:
1 parent
5be0d42bca
commit
60dcfe8992
13 files changed
+276
-16
No files matched your search
@@ -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() {
|
||||
<Route path="/dashboard" element={<WelcomeGate><Dashboard/></WelcomeGate>}/>
|
||||
<Route path="/library" element={<GameLibrary/>}/>
|
||||
<Route path="/library/:appid" element={<GameDetail/>}/>
|
||||
<Route path="/search" element={<GameSearchPage/>}/>
|
||||
<Route path="/wishlist" element={<WishlistPage/>}/>
|
||||
<Route path="/backlog" element={<BacklogPage/>}/>
|
||||
<Route path="/play-next" element={<PlayNextPage/>}/>
|
||||
|
||||
@@ -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}) {
|
||||
<div className="flex items-center gap-3">
|
||||
<GameCover appid={hit.appid} imageUrl={hit.imageUrl} className="w-24 shrink-0"/>
|
||||
<div className="@container min-w-0 flex-1 font-medium">
|
||||
<FitText text={`${hit.name}${hit.year ? ` (${hit.year})` : ''}`}/>
|
||||
<FitText text={hitTitle(hit)}/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
|
||||
@@ -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 (
|
||||
<header className="sticky top-0 z-50 border-b bg-background/80 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<PageContainer className="flex h-16 items-center gap-1 px-4 sm:gap-2 md:px-8">
|
||||
<PageContainer className="flex h-16 items-center gap-1 px-3 sm:gap-2 sm:px-4 md:px-8">
|
||||
<Link to={isAuthenticated ? '/dashboard' : '/'} aria-label="ToBePlayed" className="flex items-center gap-2 font-bold tracking-tight sm:mr-2 whitespace-nowrap">
|
||||
<Logo className="h-8 w-auto shrink-0"/>
|
||||
{/* 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}) {
|
||||
<Badge variant="secondary" className="absolute top-full left-1/2 -translate-x-1/2 -translate-y-1/2 pointer-events-none border-border">{t('NAV.VIEW_AS.BADGE', {role: t(`ROLES.${viewingAs}`)})}</Badge>
|
||||
)}
|
||||
{isAuthenticated && wallet && (
|
||||
<Button asChild variant="ghost" size="sm" title={`${t('POINTS.TITLE')}: ${wallet.balance}`} className="min-w-0 shrink lg:shrink-0">
|
||||
<Button asChild variant="ghost" size="sm" title={`${t('POINTS.TITLE')}: ${wallet.balance}`} className={cn('min-w-0 shrink lg:shrink-0', !questionsIconHidden && 'max-sm:hidden')}>
|
||||
<Link to="/points" aria-label={`${t('POINTS.TITLE')}: ${wallet.balance}`}>
|
||||
<Coins className="text-primary"/>
|
||||
<span className={cn('min-w-0 truncate tabular-nums', !questionsIconHidden && 'max-sm:hidden')}>{wallet.balance}</span>
|
||||
<span className="min-w-0 truncate tabular-nums">{wallet.balance}</span>
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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'});
|
||||
|
||||
|
||||
@@ -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
|
||||
<GameCover appid={hit.appid} imageUrl={hit.imageUrl} className="w-20 shrink-0"/>
|
||||
<span className="@container min-w-0 flex-1">
|
||||
<span className="block font-medium">
|
||||
<FitText text={`${hit.name}${hit.year ? ` (${hit.year})` : ''}`}/>
|
||||
<FitText text={hitTitle(hit)}/>
|
||||
</span>
|
||||
<span className="block truncate text-xs text-muted-foreground">
|
||||
{hit.platforms.join(' · ')}
|
||||
</span>
|
||||
</span>
|
||||
{blocked && <Badge variant="secondary">{t('MANUAL_GAME.IN_LIBRARY')}</Badge>}
|
||||
{markInLibrary && hit.inLibrary && <Badge variant="secondary">{t('MANUAL_GAME.IN_LIBRARY')}</Badge>}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<Card className="gap-4 p-5">
|
||||
{adding ? (
|
||||
<AddForm hit={hit} favorite={favorite} onBack={onBack} navigate={navigate}/>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-start">
|
||||
<GameCover appid={hit.appid} imageUrl={hit.imageUrl} className="w-full md:w-72 md:shrink-0"/>
|
||||
<div className="min-w-0 flex-1 space-y-3">
|
||||
<h1 className="@container text-2xl font-bold">
|
||||
<FitText text={hitTitle(hit)}/>
|
||||
</h1>
|
||||
{hit.platforms.length > 0 && (
|
||||
<p className="text-sm text-muted-foreground">{hit.platforms.join(' · ')}</p>
|
||||
)}
|
||||
{preview?.genres?.length > 0 && (
|
||||
<p className="text-sm text-muted-foreground">{preview.genres.join(', ')}</p>
|
||||
)}
|
||||
{preview && <TimeToBeat time={preview.timeToBeat}/>}
|
||||
<p className="text-sm text-muted-foreground">{t('CATALOG.NOT_IN_LIBRARY')}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="ghost" onClick={onBack}><ArrowLeft/>{t('MANUAL_GAME.BACK_TO_SEARCH')}</Button>
|
||||
<Button onClick={() => setAdding(true)}><Plus/>{t('CATALOG.ADD')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<PageContainer className="space-y-4 px-4 py-6 md:px-8">
|
||||
{picked ? (
|
||||
<SearchHitCard hit={picked} onBack={() => setPicked(null)}/>
|
||||
) : (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2"><Search className="size-4 text-primary"/>{t('GAME_SEARCH.TITLE')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<p className="text-sm text-muted-foreground">{t('GAME_SEARCH.HINT')}</p>
|
||||
<GameSearch query={query} onQueryChange={onQueryChange} onPick={onPick} markInLibrary/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -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(<GameSearchPage/>, {path: '/search', entries,
|
||||
routes: {'/library/:appid': <p>detail page</p>}});
|
||||
}
|
||||
|
||||
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());
|
||||
});
|
||||
});
|
||||
@@ -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});
|
||||
|
||||
Reference in new issue
Block a user