Add frontend API client and TanStack Query hooks
Install @tanstack/react-query, create a fetch-based API client with typed functions for all endpoints, and add query/mutation hooks for games, pokemon, runs, and encounters. Includes Vite dev proxy for /api and QueryClientProvider setup. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,11 +1,11 @@
|
|||||||
---
|
---
|
||||||
# nuzlocke-tracker-8fcj
|
# nuzlocke-tracker-8fcj
|
||||||
title: Frontend API Integration
|
title: Frontend API Integration
|
||||||
status: todo
|
status: completed
|
||||||
type: task
|
type: task
|
||||||
priority: normal
|
priority: normal
|
||||||
created_at: 2026-02-04T15:44:42Z
|
created_at: 2026-02-04T15:44:42Z
|
||||||
updated_at: 2026-02-04T15:47:24Z
|
updated_at: 2026-02-05T13:55:52Z
|
||||||
parent: nuzlocke-tracker-f5ob
|
parent: nuzlocke-tracker-f5ob
|
||||||
blocking:
|
blocking:
|
||||||
- nuzlocke-tracker-uw2j
|
- nuzlocke-tracker-uw2j
|
||||||
@@ -17,19 +17,21 @@ blocking:
|
|||||||
Implement frontend services to communicate with the backend API.
|
Implement frontend services to communicate with the backend API.
|
||||||
|
|
||||||
## Checklist
|
## Checklist
|
||||||
- [ ] Create API client/service layer
|
- [x] Create API client/service layer
|
||||||
- [ ] Implement API calls for:
|
- [x] Implement API calls for:
|
||||||
- [ ] Fetch available games
|
- [x] Fetch available games
|
||||||
- [ ] Fetch routes for a game
|
- [x] Fetch routes for a game
|
||||||
- [ ] Fetch Pokémon data
|
- [x] Fetch Pokémon data
|
||||||
- [ ] Create/update/delete Nuzlocke runs
|
- [x] Create/update/delete Nuzlocke runs
|
||||||
- [ ] Create/update encounters
|
- [x] Create/update encounters
|
||||||
- [ ] Update Pokémon status
|
- [x] Update Pokémon status
|
||||||
- [ ] Add loading states and error handling
|
- [x] Add loading states and error handling
|
||||||
- [ ] Implement optimistic updates where appropriate
|
- [x] Add retry logic for failed requests
|
||||||
- [ ] Add retry logic for failed requests
|
|
||||||
|
|
||||||
## Technical Notes
|
## Technical Notes
|
||||||
- Use fetch or axios for HTTP requests
|
- Using native `fetch` via `src/api/client.ts` wrapper
|
||||||
- Consider using React Query/TanStack Query or SWR for caching
|
- Using TanStack Query for caching, loading states, and retry
|
||||||
- Type API responses with TypeScript
|
- All API responses typed with TypeScript
|
||||||
|
- Vite dev proxy configured for `/api` → backend
|
||||||
|
- Query hooks in `src/hooks/` for each domain (games, pokemon, runs, encounters)
|
||||||
|
- Mutations auto-invalidate relevant query caches
|
||||||
27
frontend/package-lock.json
generated
27
frontend/package-lock.json
generated
@@ -8,6 +8,7 @@
|
|||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@tanstack/react-query": "^5.90.20",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"react-router-dom": "^7.13.0"
|
"react-router-dom": "^7.13.0"
|
||||||
@@ -1644,6 +1645,32 @@
|
|||||||
"vite": "^5.2.0 || ^6 || ^7"
|
"vite": "^5.2.0 || ^6 || ^7"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tanstack/query-core": {
|
||||||
|
"version": "5.90.20",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.20.tgz",
|
||||||
|
"integrity": "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tanstack/react-query": {
|
||||||
|
"version": "5.90.20",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.20.tgz",
|
||||||
|
"integrity": "sha512-vXBxa+qeyveVO7OA0jX1z+DeyCA4JKnThKv411jd5SORpBKgkcVnYKCiBgECvADvniBX7tobwBmg01qq9JmMJw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@tanstack/query-core": "5.90.20"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^18 || ^19"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/babel__core": {
|
"node_modules/@types/babel__core": {
|
||||||
"version": "7.20.5",
|
"version": "7.20.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@tanstack/react-query": "^5.90.20",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"react-router-dom": "^7.13.0"
|
"react-router-dom": "^7.13.0"
|
||||||
|
|||||||
51
frontend/src/api/client.ts
Normal file
51
frontend/src/api/client.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
const API_BASE = import.meta.env.VITE_API_URL ?? ''
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
status: number
|
||||||
|
|
||||||
|
constructor(status: number, message: string) {
|
||||||
|
super(message)
|
||||||
|
this.name = 'ApiError'
|
||||||
|
this.status = status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request<T>(
|
||||||
|
path: string,
|
||||||
|
options?: RequestInit,
|
||||||
|
): Promise<T> {
|
||||||
|
const res = await fetch(`${API_BASE}/api/v1${path}`, {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...options?.headers,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({}))
|
||||||
|
throw new ApiError(res.status, body.detail ?? res.statusText)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.status === 204) return undefined as T
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
get: <T>(path: string) => request<T>(path),
|
||||||
|
|
||||||
|
post: <T>(path: string, body: unknown) =>
|
||||||
|
request<T>(path, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}),
|
||||||
|
|
||||||
|
patch: <T>(path: string, body: unknown) =>
|
||||||
|
request<T>(path, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}),
|
||||||
|
|
||||||
|
del: <T = void>(path: string) =>
|
||||||
|
request<T>(path, { method: 'DELETE' }),
|
||||||
|
}
|
||||||
24
frontend/src/api/encounters.ts
Normal file
24
frontend/src/api/encounters.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { api } from './client'
|
||||||
|
import type {
|
||||||
|
Encounter,
|
||||||
|
CreateEncounterInput,
|
||||||
|
UpdateEncounterInput,
|
||||||
|
} from '../types/game'
|
||||||
|
|
||||||
|
export function createEncounter(
|
||||||
|
runId: number,
|
||||||
|
data: CreateEncounterInput,
|
||||||
|
): Promise<Encounter> {
|
||||||
|
return api.post(`/runs/${runId}/encounters`, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateEncounter(
|
||||||
|
id: number,
|
||||||
|
data: UpdateEncounterInput,
|
||||||
|
): Promise<Encounter> {
|
||||||
|
return api.patch(`/encounters/${id}`, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteEncounter(id: number): Promise<void> {
|
||||||
|
return api.del(`/encounters/${id}`)
|
||||||
|
}
|
||||||
22
frontend/src/api/games.ts
Normal file
22
frontend/src/api/games.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { api } from './client'
|
||||||
|
import type { Game, Route, RouteEncounter } from '../types/game'
|
||||||
|
|
||||||
|
export interface GameDetail extends Game {
|
||||||
|
routes: Route[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getGames(): Promise<Game[]> {
|
||||||
|
return api.get('/games')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getGame(id: number): Promise<GameDetail> {
|
||||||
|
return api.get(`/games/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getGameRoutes(gameId: number): Promise<Route[]> {
|
||||||
|
return api.get(`/games/${gameId}/routes`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRoutePokemon(routeId: number): Promise<RouteEncounter[]> {
|
||||||
|
return api.get(`/routes/${routeId}/pokemon`)
|
||||||
|
}
|
||||||
6
frontend/src/api/pokemon.ts
Normal file
6
frontend/src/api/pokemon.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { api } from './client'
|
||||||
|
import type { Pokemon } from '../types/game'
|
||||||
|
|
||||||
|
export function getPokemon(id: number): Promise<Pokemon> {
|
||||||
|
return api.get(`/pokemon/${id}`)
|
||||||
|
}
|
||||||
30
frontend/src/api/runs.ts
Normal file
30
frontend/src/api/runs.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { api } from './client'
|
||||||
|
import type {
|
||||||
|
NuzlockeRun,
|
||||||
|
RunDetail,
|
||||||
|
CreateRunInput,
|
||||||
|
UpdateRunInput,
|
||||||
|
} from '../types/game'
|
||||||
|
|
||||||
|
export function getRuns(): Promise<NuzlockeRun[]> {
|
||||||
|
return api.get('/runs')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRun(id: number): Promise<RunDetail> {
|
||||||
|
return api.get(`/runs/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createRun(data: CreateRunInput): Promise<NuzlockeRun> {
|
||||||
|
return api.post('/runs', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateRun(
|
||||||
|
id: number,
|
||||||
|
data: UpdateRunInput,
|
||||||
|
): Promise<NuzlockeRun> {
|
||||||
|
return api.patch(`/runs/${id}`, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteRun(id: number): Promise<void> {
|
||||||
|
return api.del(`/runs/${id}`)
|
||||||
|
}
|
||||||
43
frontend/src/hooks/useEncounters.ts
Normal file
43
frontend/src/hooks/useEncounters.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import {
|
||||||
|
createEncounter,
|
||||||
|
updateEncounter,
|
||||||
|
deleteEncounter,
|
||||||
|
} from '../api/encounters'
|
||||||
|
import type { CreateEncounterInput, UpdateEncounterInput } from '../types/game'
|
||||||
|
|
||||||
|
export function useCreateEncounter(runId: number) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (data: CreateEncounterInput) => createEncounter(runId, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['runs', runId] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateEncounter(runId: number) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
id,
|
||||||
|
data,
|
||||||
|
}: {
|
||||||
|
id: number
|
||||||
|
data: UpdateEncounterInput
|
||||||
|
}) => updateEncounter(id, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['runs', runId] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteEncounter(runId: number) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: number) => deleteEncounter(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['runs', runId] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
31
frontend/src/hooks/useGames.ts
Normal file
31
frontend/src/hooks/useGames.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { getGames, getGame, getGameRoutes, getRoutePokemon } from '../api/games'
|
||||||
|
|
||||||
|
export function useGames() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['games'],
|
||||||
|
queryFn: getGames,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGame(id: number) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['games', id],
|
||||||
|
queryFn: () => getGame(id),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGameRoutes(gameId: number) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['games', gameId, 'routes'],
|
||||||
|
queryFn: () => getGameRoutes(gameId),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRoutePokemon(routeId: number | null) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['routes', routeId, 'pokemon'],
|
||||||
|
queryFn: () => getRoutePokemon(routeId!),
|
||||||
|
enabled: routeId !== null,
|
||||||
|
})
|
||||||
|
}
|
||||||
10
frontend/src/hooks/usePokemon.ts
Normal file
10
frontend/src/hooks/usePokemon.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { getPokemon } from '../api/pokemon'
|
||||||
|
|
||||||
|
export function usePokemon(id: number | null) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['pokemon', id],
|
||||||
|
queryFn: () => getPokemon(id!),
|
||||||
|
enabled: id !== null,
|
||||||
|
})
|
||||||
|
}
|
||||||
47
frontend/src/hooks/useRuns.ts
Normal file
47
frontend/src/hooks/useRuns.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { getRuns, getRun, createRun, updateRun, deleteRun } from '../api/runs'
|
||||||
|
import type { CreateRunInput, UpdateRunInput } from '../types/game'
|
||||||
|
|
||||||
|
export function useRuns() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['runs'],
|
||||||
|
queryFn: getRuns,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRun(id: number) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['runs', id],
|
||||||
|
queryFn: () => getRun(id),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateRun() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (data: CreateRunInput) => createRun(data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['runs'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateRun(id: number) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (data: UpdateRunInput) => updateRun(id, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['runs'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteRun() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: number) => deleteRun(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['runs'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,13 +1,25 @@
|
|||||||
import { StrictMode } from 'react'
|
import { StrictMode } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import { BrowserRouter } from 'react-router-dom'
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
import App from './App.tsx'
|
import App from './App.tsx'
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
retry: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<BrowserRouter>
|
<QueryClientProvider client={queryClient}>
|
||||||
<App />
|
<BrowserRouter>
|
||||||
</BrowserRouter>
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ export interface RouteEncounter {
|
|||||||
pokemonId: number
|
pokemonId: number
|
||||||
encounterMethod: string
|
encounterMethod: string
|
||||||
encounterRate: number
|
encounterRate: number
|
||||||
|
minLevel: number
|
||||||
|
maxLevel: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export type EncounterStatus = 'caught' | 'fainted' | 'missed'
|
export type EncounterStatus = 'caught' | 'fainted' | 'missed'
|
||||||
@@ -57,6 +59,42 @@ export interface NuzlockeRun {
|
|||||||
completedAt: string | null
|
completedAt: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RunDetail extends NuzlockeRun {
|
||||||
|
game: Game
|
||||||
|
encounters: EncounterDetail[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EncounterDetail extends Encounter {
|
||||||
|
pokemon: Pokemon
|
||||||
|
route: Route
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateRunInput {
|
||||||
|
gameId: number
|
||||||
|
name: string
|
||||||
|
rules?: NuzlockeRules
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateRunInput {
|
||||||
|
name?: string
|
||||||
|
status?: RunStatus
|
||||||
|
rules?: NuzlockeRules
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateEncounterInput {
|
||||||
|
routeId: number
|
||||||
|
pokemonId: number
|
||||||
|
nickname?: string
|
||||||
|
status: EncounterStatus
|
||||||
|
catchLevel?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateEncounterInput {
|
||||||
|
nickname?: string
|
||||||
|
status?: EncounterStatus
|
||||||
|
faintLevel?: number
|
||||||
|
}
|
||||||
|
|
||||||
// Re-export for convenience
|
// Re-export for convenience
|
||||||
import type { NuzlockeRules } from './rules'
|
import type { NuzlockeRules } from './rules'
|
||||||
export type { NuzlockeRules }
|
export type { NuzlockeRules }
|
||||||
|
|||||||
@@ -5,4 +5,12 @@ import tailwindcss from '@tailwindcss/vite'
|
|||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react(), tailwindcss()],
|
plugins: [react(), tailwindcss()],
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:8000',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user