(null)
+ const [isSubmitting, setIsSubmitting] = useState(false)
+
+ const handleSubmit = async (e: FormEvent) => {
+ e.preventDefault()
+ setError(null)
+ setResult(null)
+
+ let items: unknown[]
+ try {
+ items = JSON.parse(json)
+ if (!Array.isArray(items)) throw new Error('Must be an array')
+ } catch {
+ setError('Invalid JSON. Must be an array of pokemon objects.')
+ return
+ }
+
+ setIsSubmitting(true)
+ try {
+ const res = await onSubmit(items as Array<{ nationalDex: number; name: string; types: string[] }>)
+ setResult(res)
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Import failed')
+ } finally {
+ setIsSubmitting(false)
+ }
+ }
+
+ return (
+
+
+
+
+
Bulk Import Pokemon
+
+
+
+
+ )
+}
diff --git a/frontend/src/components/admin/DeleteConfirmModal.tsx b/frontend/src/components/admin/DeleteConfirmModal.tsx
new file mode 100644
index 0000000..5b69a54
--- /dev/null
+++ b/frontend/src/components/admin/DeleteConfirmModal.tsx
@@ -0,0 +1,48 @@
+interface DeleteConfirmModalProps {
+ title: string
+ message: string
+ onConfirm: () => void
+ onCancel: () => void
+ isDeleting?: boolean
+}
+
+export function DeleteConfirmModal({
+ title,
+ message,
+ onConfirm,
+ onCancel,
+ isDeleting,
+}: DeleteConfirmModalProps) {
+ return (
+
+
+
+
+
+ {title}
+
+
+ {message}
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/frontend/src/components/admin/FormModal.tsx b/frontend/src/components/admin/FormModal.tsx
new file mode 100644
index 0000000..e61fa5f
--- /dev/null
+++ b/frontend/src/components/admin/FormModal.tsx
@@ -0,0 +1,49 @@
+import { type FormEvent, type ReactNode } from 'react'
+
+interface FormModalProps {
+ title: string
+ onClose: () => void
+ onSubmit: (e: FormEvent) => void
+ children: ReactNode
+ submitLabel?: string
+ isSubmitting?: boolean
+}
+
+export function FormModal({
+ title,
+ onClose,
+ onSubmit,
+ children,
+ submitLabel = 'Save',
+ isSubmitting,
+}: FormModalProps) {
+ return (
+
+
+
+
+
{title}
+
+
+
+
+ )
+}
diff --git a/frontend/src/components/admin/GameFormModal.tsx b/frontend/src/components/admin/GameFormModal.tsx
new file mode 100644
index 0000000..302d2a5
--- /dev/null
+++ b/frontend/src/components/admin/GameFormModal.tsx
@@ -0,0 +1,119 @@
+import { type FormEvent, useState, useEffect } from 'react'
+import { FormModal } from './FormModal'
+import type { Game, CreateGameInput, UpdateGameInput } from '../../types'
+
+interface GameFormModalProps {
+ game?: Game
+ onSubmit: (data: CreateGameInput | UpdateGameInput) => void
+ onClose: () => void
+ isSubmitting?: boolean
+}
+
+function slugify(name: string) {
+ return name
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, '-')
+ .replace(/^-|-$/g, '')
+}
+
+export function GameFormModal({ game, onSubmit, onClose, isSubmitting }: GameFormModalProps) {
+ const [name, setName] = useState(game?.name ?? '')
+ const [slug, setSlug] = useState(game?.slug ?? '')
+ const [generation, setGeneration] = useState(String(game?.generation ?? ''))
+ const [region, setRegion] = useState(game?.region ?? '')
+ const [boxArtUrl, setBoxArtUrl] = useState(game?.boxArtUrl ?? '')
+ const [releaseYear, setReleaseYear] = useState(game?.releaseYear ? String(game.releaseYear) : '')
+ const [autoSlug, setAutoSlug] = useState(!game)
+
+ useEffect(() => {
+ if (autoSlug) setSlug(slugify(name))
+ }, [name, autoSlug])
+
+ const handleSubmit = (e: FormEvent) => {
+ e.preventDefault()
+ onSubmit({
+ name,
+ slug,
+ generation: Number(generation),
+ region,
+ boxArtUrl: boxArtUrl || null,
+ releaseYear: releaseYear ? Number(releaseYear) : null,
+ })
+ }
+
+ return (
+
+
+
+ setName(e.target.value)}
+ className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
+ />
+
+
+
+ {
+ setSlug(e.target.value)
+ setAutoSlug(false)
+ }}
+ className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
+ />
+
+
+
+
+ setBoxArtUrl(e.target.value)}
+ placeholder="Optional"
+ className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
+ />
+
+
+
+ setReleaseYear(e.target.value)}
+ placeholder="Optional"
+ className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
+ />
+
+
+ )
+}
diff --git a/frontend/src/components/admin/PokemonFormModal.tsx b/frontend/src/components/admin/PokemonFormModal.tsx
new file mode 100644
index 0000000..c581e07
--- /dev/null
+++ b/frontend/src/components/admin/PokemonFormModal.tsx
@@ -0,0 +1,83 @@
+import { type FormEvent, useState } from 'react'
+import { FormModal } from './FormModal'
+import type { Pokemon, CreatePokemonInput, UpdatePokemonInput } from '../../types'
+
+interface PokemonFormModalProps {
+ pokemon?: Pokemon
+ onSubmit: (data: CreatePokemonInput | UpdatePokemonInput) => void
+ onClose: () => void
+ isSubmitting?: boolean
+}
+
+export function PokemonFormModal({ pokemon, onSubmit, onClose, isSubmitting }: PokemonFormModalProps) {
+ const [nationalDex, setNationalDex] = useState(String(pokemon?.nationalDex ?? ''))
+ const [name, setName] = useState(pokemon?.name ?? '')
+ const [types, setTypes] = useState(pokemon?.types.join(', ') ?? '')
+ const [spriteUrl, setSpriteUrl] = useState(pokemon?.spriteUrl ?? '')
+
+ const handleSubmit = (e: FormEvent) => {
+ e.preventDefault()
+ const typesList = types
+ .split(',')
+ .map((t) => t.trim())
+ .filter(Boolean)
+ onSubmit({
+ nationalDex: Number(nationalDex),
+ name,
+ types: typesList,
+ spriteUrl: spriteUrl || null,
+ })
+ }
+
+ return (
+
+
+
+ setNationalDex(e.target.value)}
+ className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
+ />
+
+
+
+ setName(e.target.value)}
+ className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
+ />
+
+
+
+ setTypes(e.target.value)}
+ placeholder="Fire, Flying"
+ className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
+ />
+
+
+
+ setSpriteUrl(e.target.value)}
+ placeholder="Optional"
+ className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
+ />
+
+
+ )
+}
diff --git a/frontend/src/components/admin/RouteEncounterFormModal.tsx b/frontend/src/components/admin/RouteEncounterFormModal.tsx
new file mode 100644
index 0000000..c9a4e0c
--- /dev/null
+++ b/frontend/src/components/admin/RouteEncounterFormModal.tsx
@@ -0,0 +1,134 @@
+import { type FormEvent, useState } from 'react'
+import { FormModal } from './FormModal'
+import { usePokemonList } from '../../hooks/useAdmin'
+import type { RouteEncounterDetail, CreateRouteEncounterInput, UpdateRouteEncounterInput } from '../../types'
+
+interface RouteEncounterFormModalProps {
+ encounter?: RouteEncounterDetail
+ onSubmit: (data: CreateRouteEncounterInput | UpdateRouteEncounterInput) => void
+ onClose: () => void
+ isSubmitting?: boolean
+}
+
+export function RouteEncounterFormModal({
+ encounter,
+ onSubmit,
+ onClose,
+ isSubmitting,
+}: RouteEncounterFormModalProps) {
+ const [search, setSearch] = useState('')
+ const [pokemonId, setPokemonId] = useState(encounter?.pokemonId ?? 0)
+ const [encounterMethod, setEncounterMethod] = useState(encounter?.encounterMethod ?? '')
+ const [encounterRate, setEncounterRate] = useState(String(encounter?.encounterRate ?? ''))
+ const [minLevel, setMinLevel] = useState(String(encounter?.minLevel ?? ''))
+ const [maxLevel, setMaxLevel] = useState(String(encounter?.maxLevel ?? ''))
+
+ const { data: pokemonOptions = [] } = usePokemonList(search || undefined)
+
+ const handleSubmit = (e: FormEvent) => {
+ e.preventDefault()
+ if (encounter) {
+ onSubmit({
+ encounterMethod,
+ encounterRate: Number(encounterRate),
+ minLevel: Number(minLevel),
+ maxLevel: Number(maxLevel),
+ })
+ } else {
+ onSubmit({
+ pokemonId,
+ encounterMethod,
+ encounterRate: Number(encounterRate),
+ minLevel: Number(minLevel),
+ maxLevel: Number(maxLevel),
+ })
+ }
+ }
+
+ return (
+
+ {!encounter && (
+
+
+ setSearch(e.target.value)}
+ placeholder="Search pokemon..."
+ className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600 mb-2"
+ />
+ {pokemonOptions.length > 0 && (
+
+ )}
+
+ )}
+
+
+ setEncounterMethod(e.target.value)}
+ placeholder="e.g. Walking, Surfing, Fishing"
+ className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
+ />
+
+
+
+ setEncounterRate(e.target.value)}
+ className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
+ />
+
+
+
+ )
+}
diff --git a/frontend/src/components/admin/RouteFormModal.tsx b/frontend/src/components/admin/RouteFormModal.tsx
new file mode 100644
index 0000000..f63ac42
--- /dev/null
+++ b/frontend/src/components/admin/RouteFormModal.tsx
@@ -0,0 +1,52 @@
+import { type FormEvent, useState } from 'react'
+import { FormModal } from './FormModal'
+import type { Route, CreateRouteInput, UpdateRouteInput } from '../../types'
+
+interface RouteFormModalProps {
+ route?: Route
+ nextOrder?: number
+ onSubmit: (data: CreateRouteInput | UpdateRouteInput) => void
+ onClose: () => void
+ isSubmitting?: boolean
+}
+
+export function RouteFormModal({ route, nextOrder, onSubmit, onClose, isSubmitting }: RouteFormModalProps) {
+ const [name, setName] = useState(route?.name ?? '')
+ const [order, setOrder] = useState(String(route?.order ?? nextOrder ?? 0))
+
+ const handleSubmit = (e: FormEvent) => {
+ e.preventDefault()
+ onSubmit({ name, order: Number(order) })
+ }
+
+ return (
+
+
+
+ setName(e.target.value)}
+ className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
+ />
+
+
+
+ setOrder(e.target.value)}
+ className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
+ />
+
+
+ )
+}
diff --git a/frontend/src/components/admin/index.ts b/frontend/src/components/admin/index.ts
new file mode 100644
index 0000000..4e0e6ef
--- /dev/null
+++ b/frontend/src/components/admin/index.ts
@@ -0,0 +1,9 @@
+export { AdminLayout } from './AdminLayout'
+export { AdminTable, type Column } from './AdminTable'
+export { FormModal } from './FormModal'
+export { DeleteConfirmModal } from './DeleteConfirmModal'
+export { GameFormModal } from './GameFormModal'
+export { RouteFormModal } from './RouteFormModal'
+export { PokemonFormModal } from './PokemonFormModal'
+export { BulkImportModal } from './BulkImportModal'
+export { RouteEncounterFormModal } from './RouteEncounterFormModal'
diff --git a/frontend/src/hooks/useAdmin.ts b/frontend/src/hooks/useAdmin.ts
new file mode 100644
index 0000000..96f4c52
--- /dev/null
+++ b/frontend/src/hooks/useAdmin.ts
@@ -0,0 +1,164 @@
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
+import * as adminApi from '../api/admin'
+import type {
+ CreateGameInput,
+ UpdateGameInput,
+ CreateRouteInput,
+ UpdateRouteInput,
+ RouteReorderItem,
+ CreatePokemonInput,
+ UpdatePokemonInput,
+ CreateRouteEncounterInput,
+ UpdateRouteEncounterInput,
+} from '../types'
+
+// --- Queries ---
+
+export function usePokemonList(search?: string) {
+ return useQuery({
+ queryKey: ['pokemon', { search }],
+ queryFn: () => adminApi.listPokemon(search),
+ })
+}
+
+// --- Game Mutations ---
+
+export function useCreateGame() {
+ const qc = useQueryClient()
+ return useMutation({
+ mutationFn: (data: CreateGameInput) => adminApi.createGame(data),
+ onSuccess: () => qc.invalidateQueries({ queryKey: ['games'] }),
+ })
+}
+
+export function useUpdateGame() {
+ const qc = useQueryClient()
+ return useMutation({
+ mutationFn: ({ id, data }: { id: number; data: UpdateGameInput }) =>
+ adminApi.updateGame(id, data),
+ onSuccess: () => qc.invalidateQueries({ queryKey: ['games'] }),
+ })
+}
+
+export function useDeleteGame() {
+ const qc = useQueryClient()
+ return useMutation({
+ mutationFn: (id: number) => adminApi.deleteGame(id),
+ onSuccess: () => qc.invalidateQueries({ queryKey: ['games'] }),
+ })
+}
+
+// --- Route Mutations ---
+
+export function useCreateRoute(gameId: number) {
+ const qc = useQueryClient()
+ return useMutation({
+ mutationFn: (data: CreateRouteInput) => adminApi.createRoute(gameId, data),
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: ['games', gameId] })
+ qc.invalidateQueries({ queryKey: ['games', gameId, 'routes'] })
+ },
+ })
+}
+
+export function useUpdateRoute(gameId: number) {
+ const qc = useQueryClient()
+ return useMutation({
+ mutationFn: ({ routeId, data }: { routeId: number; data: UpdateRouteInput }) =>
+ adminApi.updateRoute(gameId, routeId, data),
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: ['games', gameId] })
+ qc.invalidateQueries({ queryKey: ['games', gameId, 'routes'] })
+ },
+ })
+}
+
+export function useDeleteRoute(gameId: number) {
+ const qc = useQueryClient()
+ return useMutation({
+ mutationFn: (routeId: number) => adminApi.deleteRoute(gameId, routeId),
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: ['games', gameId] })
+ qc.invalidateQueries({ queryKey: ['games', gameId, 'routes'] })
+ },
+ })
+}
+
+export function useReorderRoutes(gameId: number) {
+ const qc = useQueryClient()
+ return useMutation({
+ mutationFn: (routes: RouteReorderItem[]) => adminApi.reorderRoutes(gameId, routes),
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: ['games', gameId] })
+ qc.invalidateQueries({ queryKey: ['games', gameId, 'routes'] })
+ },
+ })
+}
+
+// --- Pokemon Mutations ---
+
+export function useCreatePokemon() {
+ const qc = useQueryClient()
+ return useMutation({
+ mutationFn: (data: CreatePokemonInput) => adminApi.createPokemon(data),
+ onSuccess: () => qc.invalidateQueries({ queryKey: ['pokemon'] }),
+ })
+}
+
+export function useUpdatePokemon() {
+ const qc = useQueryClient()
+ return useMutation({
+ mutationFn: ({ id, data }: { id: number; data: UpdatePokemonInput }) =>
+ adminApi.updatePokemon(id, data),
+ onSuccess: () => qc.invalidateQueries({ queryKey: ['pokemon'] }),
+ })
+}
+
+export function useDeletePokemon() {
+ const qc = useQueryClient()
+ return useMutation({
+ mutationFn: (id: number) => adminApi.deletePokemon(id),
+ onSuccess: () => qc.invalidateQueries({ queryKey: ['pokemon'] }),
+ })
+}
+
+export function useBulkImportPokemon() {
+ const qc = useQueryClient()
+ return useMutation({
+ mutationFn: (items: Array<{ nationalDex: number; name: string; types: string[]; spriteUrl?: string | null }>) =>
+ adminApi.bulkImportPokemon(items),
+ onSuccess: () => qc.invalidateQueries({ queryKey: ['pokemon'] }),
+ })
+}
+
+// --- Route Encounter Mutations ---
+
+export function useAddRouteEncounter(routeId: number) {
+ const qc = useQueryClient()
+ return useMutation({
+ mutationFn: (data: CreateRouteEncounterInput) =>
+ adminApi.addRouteEncounter(routeId, data),
+ onSuccess: () =>
+ qc.invalidateQueries({ queryKey: ['routes', routeId, 'pokemon'] }),
+ })
+}
+
+export function useUpdateRouteEncounter(routeId: number) {
+ const qc = useQueryClient()
+ return useMutation({
+ mutationFn: ({ encounterId, data }: { encounterId: number; data: UpdateRouteEncounterInput }) =>
+ adminApi.updateRouteEncounter(routeId, encounterId, data),
+ onSuccess: () =>
+ qc.invalidateQueries({ queryKey: ['routes', routeId, 'pokemon'] }),
+ })
+}
+
+export function useRemoveRouteEncounter(routeId: number) {
+ const qc = useQueryClient()
+ return useMutation({
+ mutationFn: (encounterId: number) =>
+ adminApi.removeRouteEncounter(routeId, encounterId),
+ onSuccess: () =>
+ qc.invalidateQueries({ queryKey: ['routes', routeId, 'pokemon'] }),
+ })
+}
diff --git a/frontend/src/pages/admin/AdminGameDetail.tsx b/frontend/src/pages/admin/AdminGameDetail.tsx
new file mode 100644
index 0000000..aa22baf
--- /dev/null
+++ b/frontend/src/pages/admin/AdminGameDetail.tsx
@@ -0,0 +1,171 @@
+import { useState } from 'react'
+import { useParams, useNavigate, Link } from 'react-router-dom'
+import { AdminTable, type Column } from '../../components/admin/AdminTable'
+import { RouteFormModal } from '../../components/admin/RouteFormModal'
+import { DeleteConfirmModal } from '../../components/admin/DeleteConfirmModal'
+import { useGame } from '../../hooks/useGames'
+import {
+ useCreateRoute,
+ useUpdateRoute,
+ useDeleteRoute,
+ useReorderRoutes,
+} from '../../hooks/useAdmin'
+import type { Route as GameRoute, CreateRouteInput, UpdateRouteInput } from '../../types'
+
+export function AdminGameDetail() {
+ const { gameId } = useParams<{ gameId: string }>()
+ const navigate = useNavigate()
+ const id = Number(gameId)
+ const { data: game, isLoading } = useGame(id)
+
+ const createRoute = useCreateRoute(id)
+ const updateRoute = useUpdateRoute(id)
+ const deleteRoute = useDeleteRoute(id)
+ const reorderRoutes = useReorderRoutes(id)
+
+ const [showCreate, setShowCreate] = useState(false)
+ const [editing, setEditing] = useState(null)
+ const [deleting, setDeleting] = useState(null)
+
+ if (isLoading) return Loading...
+ if (!game) return Game not found
+
+ const routes = game.routes ?? []
+
+ const moveRoute = (route: GameRoute, direction: 'up' | 'down') => {
+ const idx = routes.findIndex((r) => r.id === route.id)
+ if (direction === 'up' && idx <= 0) return
+ if (direction === 'down' && idx >= routes.length - 1) return
+
+ const swapIdx = direction === 'up' ? idx - 1 : idx + 1
+ const newRoutes = routes.map((r, i) => {
+ if (i === idx) return { id: r.id, order: routes[swapIdx].order }
+ if (i === swapIdx) return { id: r.id, order: routes[idx].order }
+ return { id: r.id, order: r.order }
+ })
+ reorderRoutes.mutate(newRoutes)
+ }
+
+ const columns: Column[] = [
+ { header: 'Order', accessor: (r) => r.order, className: 'w-16' },
+ { header: 'Name', accessor: (r) => r.name },
+ {
+ header: 'Actions',
+ className: 'w-48',
+ accessor: (r) => {
+ const idx = routes.findIndex((rt) => rt.id === r.id)
+ return (
+ e.stopPropagation()}>
+
+
+
+
+
+ )
+ },
+ },
+ ]
+
+ return (
+
+
+
+
+
{game.name}
+
+ {game.region} · Gen {game.generation}
+ {game.releaseYear ? ` \u00b7 ${game.releaseYear}` : ''}
+
+
+
+
+
Routes ({routes.length})
+
+
+
+
navigate(`/admin/games/${id}/routes/${r.id}`)}
+ keyFn={(r) => r.id}
+ />
+
+ {showCreate && (
+ 0 ? Math.max(...routes.map((r) => r.order)) + 1 : 1}
+ onSubmit={(data) =>
+ createRoute.mutate(data as CreateRouteInput, {
+ onSuccess: () => setShowCreate(false),
+ })
+ }
+ onClose={() => setShowCreate(false)}
+ isSubmitting={createRoute.isPending}
+ />
+ )}
+
+ {editing && (
+
+ updateRoute.mutate(
+ { routeId: editing.id, data: data as UpdateRouteInput },
+ { onSuccess: () => setEditing(null) },
+ )
+ }
+ onClose={() => setEditing(null)}
+ isSubmitting={updateRoute.isPending}
+ />
+ )}
+
+ {deleting && (
+
+ deleteRoute.mutate(deleting.id, {
+ onSuccess: () => setDeleting(null),
+ })
+ }
+ onCancel={() => setDeleting(null)}
+ isDeleting={deleteRoute.isPending}
+ />
+ )}
+
+ )
+}
diff --git a/frontend/src/pages/admin/AdminGames.tsx b/frontend/src/pages/admin/AdminGames.tsx
new file mode 100644
index 0000000..6e5c0e6
--- /dev/null
+++ b/frontend/src/pages/admin/AdminGames.tsx
@@ -0,0 +1,110 @@
+import { useState } from 'react'
+import { useNavigate } from 'react-router-dom'
+import { AdminTable, type Column } from '../../components/admin/AdminTable'
+import { GameFormModal } from '../../components/admin/GameFormModal'
+import { DeleteConfirmModal } from '../../components/admin/DeleteConfirmModal'
+import { useGames } from '../../hooks/useGames'
+import { useCreateGame, useUpdateGame, useDeleteGame } from '../../hooks/useAdmin'
+import type { Game, CreateGameInput, UpdateGameInput } from '../../types'
+
+export function AdminGames() {
+ const navigate = useNavigate()
+ const { data: games = [], isLoading } = useGames()
+ const createGame = useCreateGame()
+ const updateGame = useUpdateGame()
+ const deleteGame = useDeleteGame()
+
+ const [showCreate, setShowCreate] = useState(false)
+ const [editing, setEditing] = useState(null)
+ const [deleting, setDeleting] = useState(null)
+
+ const columns: Column[] = [
+ { header: 'Name', accessor: (g) => g.name },
+ { header: 'Slug', accessor: (g) => g.slug },
+ { header: 'Region', accessor: (g) => g.region },
+ { header: 'Gen', accessor: (g) => g.generation },
+ { header: 'Year', accessor: (g) => g.releaseYear ?? '-' },
+ {
+ header: 'Actions',
+ accessor: (g) => (
+ e.stopPropagation()}>
+
+
+
+ ),
+ },
+ ]
+
+ return (
+
+
+
Games
+
+
+
+
navigate(`/admin/games/${g.id}`)}
+ keyFn={(g) => g.id}
+ />
+
+ {showCreate && (
+
+ createGame.mutate(data as CreateGameInput, {
+ onSuccess: () => setShowCreate(false),
+ })
+ }
+ onClose={() => setShowCreate(false)}
+ isSubmitting={createGame.isPending}
+ />
+ )}
+
+ {editing && (
+
+ updateGame.mutate(
+ { id: editing.id, data: data as UpdateGameInput },
+ { onSuccess: () => setEditing(null) },
+ )
+ }
+ onClose={() => setEditing(null)}
+ isSubmitting={updateGame.isPending}
+ />
+ )}
+
+ {deleting && (
+
+ deleteGame.mutate(deleting.id, {
+ onSuccess: () => setDeleting(null),
+ })
+ }
+ onCancel={() => setDeleting(null)}
+ isDeleting={deleteGame.isPending}
+ />
+ )}
+
+ )
+}
diff --git a/frontend/src/pages/admin/AdminPokemon.tsx b/frontend/src/pages/admin/AdminPokemon.tsx
new file mode 100644
index 0000000..f4df1c7
--- /dev/null
+++ b/frontend/src/pages/admin/AdminPokemon.tsx
@@ -0,0 +1,149 @@
+import { useState } from 'react'
+import { AdminTable, type Column } from '../../components/admin/AdminTable'
+import { PokemonFormModal } from '../../components/admin/PokemonFormModal'
+import { BulkImportModal } from '../../components/admin/BulkImportModal'
+import { DeleteConfirmModal } from '../../components/admin/DeleteConfirmModal'
+import {
+ usePokemonList,
+ useCreatePokemon,
+ useUpdatePokemon,
+ useDeletePokemon,
+ useBulkImportPokemon,
+} from '../../hooks/useAdmin'
+import type { Pokemon, CreatePokemonInput, UpdatePokemonInput } from '../../types'
+
+export function AdminPokemon() {
+ const [search, setSearch] = useState('')
+ const { data: pokemon = [], isLoading } = usePokemonList(search || undefined)
+ const createPokemon = useCreatePokemon()
+ const updatePokemon = useUpdatePokemon()
+ const deletePokemon = useDeletePokemon()
+ const bulkImport = useBulkImportPokemon()
+
+ const [showCreate, setShowCreate] = useState(false)
+ const [showBulkImport, setShowBulkImport] = useState(false)
+ const [editing, setEditing] = useState(null)
+ const [deleting, setDeleting] = useState(null)
+
+ const columns: Column[] = [
+ { header: 'Dex #', accessor: (p) => p.nationalDex, className: 'w-16' },
+ {
+ header: 'Sprite',
+ className: 'w-16',
+ accessor: (p) =>
+ p.spriteUrl ? (
+
+ ) : (
+
+ ),
+ },
+ { header: 'Name', accessor: (p) => p.name },
+ { header: 'Types', accessor: (p) => p.types.join(', ') },
+ {
+ header: 'Actions',
+ accessor: (p) => (
+
+
+
+
+ ),
+ },
+ ]
+
+ return (
+
+
+
Pokemon
+
+
+
+
+
+
+
+ setSearch(e.target.value)}
+ placeholder="Search by name..."
+ className="w-full max-w-sm px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
+ />
+
+
+
p.id}
+ />
+
+ {showCreate && (
+
+ createPokemon.mutate(data as CreatePokemonInput, {
+ onSuccess: () => setShowCreate(false),
+ })
+ }
+ onClose={() => setShowCreate(false)}
+ isSubmitting={createPokemon.isPending}
+ />
+ )}
+
+ {showBulkImport && (
+ bulkImport.mutateAsync(items)}
+ onClose={() => setShowBulkImport(false)}
+ />
+ )}
+
+ {editing && (
+
+ updatePokemon.mutate(
+ { id: editing.id, data: data as UpdatePokemonInput },
+ { onSuccess: () => setEditing(null) },
+ )
+ }
+ onClose={() => setEditing(null)}
+ isSubmitting={updatePokemon.isPending}
+ />
+ )}
+
+ {deleting && (
+
+ deletePokemon.mutate(deleting.id, {
+ onSuccess: () => setDeleting(null),
+ })
+ }
+ onCancel={() => setDeleting(null)}
+ isDeleting={deletePokemon.isPending}
+ />
+ )}
+
+ )
+}
diff --git a/frontend/src/pages/admin/AdminRouteDetail.tsx b/frontend/src/pages/admin/AdminRouteDetail.tsx
new file mode 100644
index 0000000..c7d4942
--- /dev/null
+++ b/frontend/src/pages/admin/AdminRouteDetail.tsx
@@ -0,0 +1,155 @@
+import { useState } from 'react'
+import { useParams, Link } from 'react-router-dom'
+import { AdminTable, type Column } from '../../components/admin/AdminTable'
+import { RouteEncounterFormModal } from '../../components/admin/RouteEncounterFormModal'
+import { DeleteConfirmModal } from '../../components/admin/DeleteConfirmModal'
+import { useGame, useRoutePokemon } from '../../hooks/useGames'
+import {
+ useAddRouteEncounter,
+ useUpdateRouteEncounter,
+ useRemoveRouteEncounter,
+} from '../../hooks/useAdmin'
+import type {
+ RouteEncounterDetail,
+ CreateRouteEncounterInput,
+ UpdateRouteEncounterInput,
+} from '../../types'
+
+export function AdminRouteDetail() {
+ const { gameId, routeId } = useParams<{ gameId: string; routeId: string }>()
+ const gId = Number(gameId)
+ const rId = Number(routeId)
+
+ const { data: game } = useGame(gId)
+ const { data: encounters = [], isLoading } = useRoutePokemon(rId)
+
+ const addEncounter = useAddRouteEncounter(rId)
+ const updateEncounter = useUpdateRouteEncounter(rId)
+ const removeEncounter = useRemoveRouteEncounter(rId)
+
+ const [showCreate, setShowCreate] = useState(false)
+ const [editing, setEditing] = useState(null)
+ const [deleting, setDeleting] = useState(null)
+
+ const route = game?.routes?.find((r) => r.id === rId)
+
+ const columns: Column[] = [
+ {
+ header: 'Pokemon',
+ accessor: (e) => (
+
+ {e.pokemon.spriteUrl ? (
+

+ ) : null}
+
+ #{e.pokemon.nationalDex} {e.pokemon.name}
+
+
+ ),
+ },
+ { header: 'Method', accessor: (e) => e.encounterMethod },
+ { header: 'Rate', accessor: (e) => `${e.encounterRate}%` },
+ {
+ header: 'Levels',
+ accessor: (e) =>
+ e.minLevel === e.maxLevel ? `Lv ${e.minLevel}` : `Lv ${e.minLevel}-${e.maxLevel}`,
+ },
+ {
+ header: 'Actions',
+ accessor: (e) => (
+
+
+
+
+ ),
+ },
+ ]
+
+ return (
+
+
+
+
+
+ {route?.name ?? 'Route'} - Pokemon ({encounters.length})
+
+
+
+
+
e.id}
+ />
+
+ {showCreate && (
+
+ addEncounter.mutate(data as CreateRouteEncounterInput, {
+ onSuccess: () => setShowCreate(false),
+ })
+ }
+ onClose={() => setShowCreate(false)}
+ isSubmitting={addEncounter.isPending}
+ />
+ )}
+
+ {editing && (
+
+ updateEncounter.mutate(
+ { encounterId: editing.id, data: data as UpdateRouteEncounterInput },
+ { onSuccess: () => setEditing(null) },
+ )
+ }
+ onClose={() => setEditing(null)}
+ isSubmitting={updateEncounter.isPending}
+ />
+ )}
+
+ {deleting && (
+
+ removeEncounter.mutate(deleting.id, {
+ onSuccess: () => setDeleting(null),
+ })
+ }
+ onCancel={() => setDeleting(null)}
+ isDeleting={removeEncounter.isPending}
+ />
+ )}
+
+ )
+}
diff --git a/frontend/src/pages/admin/index.ts b/frontend/src/pages/admin/index.ts
new file mode 100644
index 0000000..4b62f5c
--- /dev/null
+++ b/frontend/src/pages/admin/index.ts
@@ -0,0 +1,4 @@
+export { AdminGames } from './AdminGames'
+export { AdminGameDetail } from './AdminGameDetail'
+export { AdminPokemon } from './AdminPokemon'
+export { AdminRouteDetail } from './AdminRouteDetail'
diff --git a/frontend/src/types/admin.ts b/frontend/src/types/admin.ts
new file mode 100644
index 0000000..511e210
--- /dev/null
+++ b/frontend/src/types/admin.ts
@@ -0,0 +1,67 @@
+export interface CreateGameInput {
+ name: string
+ slug: string
+ generation: number
+ region: string
+ boxArtUrl?: string | null
+ releaseYear?: number | null
+}
+
+export interface UpdateGameInput {
+ name?: string
+ slug?: string
+ generation?: number
+ region?: string
+ boxArtUrl?: string | null
+ releaseYear?: number | null
+}
+
+export interface CreateRouteInput {
+ name: string
+ order: number
+}
+
+export interface UpdateRouteInput {
+ name?: string
+ order?: number
+}
+
+export interface RouteReorderItem {
+ id: number
+ order: number
+}
+
+export interface CreatePokemonInput {
+ nationalDex: number
+ name: string
+ types: string[]
+ spriteUrl?: string | null
+}
+
+export interface UpdatePokemonInput {
+ nationalDex?: number
+ name?: string
+ types?: string[]
+ spriteUrl?: string | null
+}
+
+export interface BulkImportResult {
+ created: number
+ updated: number
+ errors: string[]
+}
+
+export interface CreateRouteEncounterInput {
+ pokemonId: number
+ encounterMethod: string
+ encounterRate: number
+ minLevel: number
+ maxLevel: number
+}
+
+export interface UpdateRouteEncounterInput {
+ encounterMethod?: string
+ encounterRate?: number
+ minLevel?: number
+ maxLevel?: number
+}
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts
index d926007..14fc022 100644
--- a/frontend/src/types/index.ts
+++ b/frontend/src/types/index.ts
@@ -1,2 +1,3 @@
+export * from './admin'
export * from './game'
export * from './rules'