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
|
||||
title: Frontend API Integration
|
||||
status: todo
|
||||
status: completed
|
||||
type: task
|
||||
priority: normal
|
||||
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
|
||||
blocking:
|
||||
- nuzlocke-tracker-uw2j
|
||||
@@ -17,19 +17,21 @@ blocking:
|
||||
Implement frontend services to communicate with the backend API.
|
||||
|
||||
## Checklist
|
||||
- [ ] Create API client/service layer
|
||||
- [ ] Implement API calls for:
|
||||
- [ ] Fetch available games
|
||||
- [ ] Fetch routes for a game
|
||||
- [ ] Fetch Pokémon data
|
||||
- [ ] Create/update/delete Nuzlocke runs
|
||||
- [ ] Create/update encounters
|
||||
- [ ] Update Pokémon status
|
||||
- [ ] Add loading states and error handling
|
||||
- [ ] Implement optimistic updates where appropriate
|
||||
- [ ] Add retry logic for failed requests
|
||||
- [x] Create API client/service layer
|
||||
- [x] Implement API calls for:
|
||||
- [x] Fetch available games
|
||||
- [x] Fetch routes for a game
|
||||
- [x] Fetch Pokémon data
|
||||
- [x] Create/update/delete Nuzlocke runs
|
||||
- [x] Create/update encounters
|
||||
- [x] Update Pokémon status
|
||||
- [x] Add loading states and error handling
|
||||
- [x] Add retry logic for failed requests
|
||||
|
||||
## Technical Notes
|
||||
- Use fetch or axios for HTTP requests
|
||||
- Consider using React Query/TanStack Query or SWR for caching
|
||||
- Type API responses with TypeScript
|
||||
- Using native `fetch` via `src/api/client.ts` wrapper
|
||||
- Using TanStack Query for caching, loading states, and retry
|
||||
- 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",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.90.20",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-router-dom": "^7.13.0"
|
||||
@@ -1644,6 +1645,32 @@
|
||||
"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": {
|
||||
"version": "7.20.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.90.20",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.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 { createRoot } from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 5 * 60 * 1000,
|
||||
retry: 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
@@ -29,6 +29,8 @@ export interface RouteEncounter {
|
||||
pokemonId: number
|
||||
encounterMethod: string
|
||||
encounterRate: number
|
||||
minLevel: number
|
||||
maxLevel: number
|
||||
}
|
||||
|
||||
export type EncounterStatus = 'caught' | 'fainted' | 'missed'
|
||||
@@ -57,6 +59,42 @@ export interface NuzlockeRun {
|
||||
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
|
||||
import type { NuzlockeRules } from './rules'
|
||||
export type { NuzlockeRules }
|
||||
|
||||
@@ -5,4 +5,12 @@ import tailwindcss from '@tailwindcss/vite'
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user