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:
Julian Tabel
2026-02-05 15:09:14 +01:00
parent 13e90eb308
commit 7c65775c8b
15 changed files with 371 additions and 19 deletions

View 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' }),
}

View 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
View 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`)
}

View 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
View 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}`)
}

View 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] })
},
})
}

View 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,
})
}

View 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,
})
}

View 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'] })
},
})
}

View File

@@ -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>
<BrowserRouter>
<App />
</BrowserRouter>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
</BrowserRouter>
</QueryClientProvider>
</StrictMode>,
)

View File

@@ -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 }