Add admin panel with CRUD endpoints and management UI

Add admin API endpoints for games, routes, pokemon, and route encounters
with full CRUD operations including bulk import. Build admin frontend
with game/route/pokemon management pages, navigation, and data tables.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-05 18:36:19 +01:00
parent a911259ef5
commit 55e6650e0e
28 changed files with 2140 additions and 10 deletions

View File

@@ -1,10 +1,11 @@
---
# nuzlocke-tracker-hy41
title: Admin Panel
status: todo
status: in-progress
type: task
priority: normal
created_at: 2026-02-04T15:47:05Z
updated_at: 2026-02-04T15:47:05Z
updated_at: 2026-02-05T17:25:58Z
parent: nuzlocke-tracker-f5ob
---

View File

@@ -6,7 +6,16 @@ from sqlalchemy.orm import selectinload
from app.core.database import get_session
from app.models.game import Game
from app.models.route import Route
from app.schemas.game import GameDetailResponse, GameResponse, RouteResponse
from app.schemas.game import (
GameCreate,
GameDetailResponse,
GameResponse,
GameUpdate,
RouteCreate,
RouteReorderRequest,
RouteResponse,
RouteUpdate,
)
router = APIRouter()
@@ -48,3 +57,161 @@ async def list_game_routes(
.order_by(Route.order)
)
return result.scalars().all()
# --- Admin endpoints ---
@router.post("", response_model=GameResponse, status_code=201)
async def create_game(
data: GameCreate, session: AsyncSession = Depends(get_session)
):
existing = await session.execute(
select(Game).where(Game.slug == data.slug)
)
if existing.scalar_one_or_none() is not None:
raise HTTPException(status_code=409, detail="Game with this slug already exists")
game = Game(**data.model_dump())
session.add(game)
await session.commit()
await session.refresh(game)
return game
@router.put("/{game_id}", response_model=GameResponse)
async def update_game(
game_id: int, data: GameUpdate, session: AsyncSession = Depends(get_session)
):
game = await session.get(Game, game_id)
if game is None:
raise HTTPException(status_code=404, detail="Game not found")
update_data = data.model_dump(exclude_unset=True)
if "slug" in update_data:
existing = await session.execute(
select(Game).where(Game.slug == update_data["slug"], Game.id != game_id)
)
if existing.scalar_one_or_none() is not None:
raise HTTPException(status_code=409, detail="Game with this slug already exists")
for field, value in update_data.items():
setattr(game, field, value)
await session.commit()
await session.refresh(game)
return game
@router.delete("/{game_id}", status_code=204)
async def delete_game(
game_id: int, session: AsyncSession = Depends(get_session)
):
result = await session.execute(
select(Game).where(Game.id == game_id).options(selectinload(Game.runs))
)
game = result.scalar_one_or_none()
if game is None:
raise HTTPException(status_code=404, detail="Game not found")
if game.runs:
raise HTTPException(
status_code=409,
detail="Cannot delete game with existing runs. Delete the runs first.",
)
# Delete routes (and their route_encounters via cascade)
routes = await session.execute(
select(Route).where(Route.game_id == game_id)
)
for route in routes.scalars().all():
await session.delete(route)
await session.delete(game)
await session.commit()
@router.post("/{game_id}/routes", response_model=RouteResponse, status_code=201)
async def create_route(
game_id: int, data: RouteCreate, session: AsyncSession = Depends(get_session)
):
game = await session.get(Game, game_id)
if game is None:
raise HTTPException(status_code=404, detail="Game not found")
route = Route(game_id=game_id, **data.model_dump())
session.add(route)
await session.commit()
await session.refresh(route)
return route
@router.put("/{game_id}/routes/reorder", response_model=list[RouteResponse])
async def reorder_routes(
game_id: int,
data: RouteReorderRequest,
session: AsyncSession = Depends(get_session),
):
game = await session.get(Game, game_id)
if game is None:
raise HTTPException(status_code=404, detail="Game not found")
for item in data.routes:
route = await session.get(Route, item.id)
if route is None or route.game_id != game_id:
raise HTTPException(
status_code=400,
detail=f"Route {item.id} not found in this game",
)
route.order = item.order
await session.commit()
result = await session.execute(
select(Route).where(Route.game_id == game_id).order_by(Route.order)
)
return result.scalars().all()
@router.put("/{game_id}/routes/{route_id}", response_model=RouteResponse)
async def update_route(
game_id: int,
route_id: int,
data: RouteUpdate,
session: AsyncSession = Depends(get_session),
):
route = await session.get(Route, route_id)
if route is None or route.game_id != game_id:
raise HTTPException(status_code=404, detail="Route not found in this game")
for field, value in data.model_dump(exclude_unset=True).items():
setattr(route, field, value)
await session.commit()
await session.refresh(route)
return route
@router.delete("/{game_id}/routes/{route_id}", status_code=204)
async def delete_route(
game_id: int,
route_id: int,
session: AsyncSession = Depends(get_session),
):
result = await session.execute(
select(Route)
.where(Route.id == route_id, Route.game_id == game_id)
.options(selectinload(Route.encounters))
)
route = result.scalar_one_or_none()
if route is None:
raise HTTPException(status_code=404, detail="Route not found in this game")
if route.encounters:
raise HTTPException(
status_code=409,
detail="Cannot delete route with existing encounters. Delete the encounters first.",
)
await session.delete(route)
await session.commit()

View File

@@ -1,17 +1,96 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import joinedload
from sqlalchemy.orm import joinedload, selectinload
from app.core.database import get_session
from app.models.pokemon import Pokemon
from app.models.route import Route
from app.models.route_encounter import RouteEncounter
from app.schemas.pokemon import PokemonResponse, RouteEncounterDetailResponse
from app.schemas.pokemon import (
BulkImportItem,
BulkImportResult,
PokemonCreate,
PokemonResponse,
PokemonUpdate,
RouteEncounterCreate,
RouteEncounterDetailResponse,
RouteEncounterUpdate,
)
router = APIRouter()
@router.get("/pokemon", response_model=list[PokemonResponse])
async def list_pokemon(
search: str | None = Query(None),
limit: int = Query(50, ge=1, le=500),
offset: int = Query(0, ge=0),
session: AsyncSession = Depends(get_session),
):
query = select(Pokemon)
if search:
query = query.where(
func.lower(Pokemon.name).contains(search.lower())
)
query = query.order_by(Pokemon.national_dex).offset(offset).limit(limit)
result = await session.execute(query)
return result.scalars().all()
@router.post("/pokemon/bulk-import", response_model=BulkImportResult)
async def bulk_import_pokemon(
items: list[BulkImportItem],
session: AsyncSession = Depends(get_session),
):
created = 0
updated = 0
errors: list[str] = []
for item in items:
try:
existing = await session.execute(
select(Pokemon).where(Pokemon.national_dex == item.national_dex)
)
pokemon = existing.scalar_one_or_none()
if pokemon is not None:
pokemon.name = item.name
pokemon.types = item.types
if item.sprite_url is not None:
pokemon.sprite_url = item.sprite_url
updated += 1
else:
pokemon = Pokemon(**item.model_dump())
session.add(pokemon)
created += 1
except Exception as e:
errors.append(f"Dex #{item.national_dex} ({item.name}): {e}")
await session.commit()
return BulkImportResult(created=created, updated=updated, errors=errors)
@router.post("/pokemon", response_model=PokemonResponse, status_code=201)
async def create_pokemon(
data: PokemonCreate, session: AsyncSession = Depends(get_session)
):
existing = await session.execute(
select(Pokemon).where(Pokemon.national_dex == data.national_dex)
)
if existing.scalar_one_or_none() is not None:
raise HTTPException(
status_code=409,
detail=f"Pokemon with national dex #{data.national_dex} already exists",
)
pokemon = Pokemon(**data.model_dump())
session.add(pokemon)
await session.commit()
await session.refresh(pokemon)
return pokemon
@router.get("/pokemon/{pokemon_id}", response_model=PokemonResponse)
async def get_pokemon(
pokemon_id: int, session: AsyncSession = Depends(get_session)
@@ -22,6 +101,61 @@ async def get_pokemon(
return pokemon
@router.put("/pokemon/{pokemon_id}", response_model=PokemonResponse)
async def update_pokemon(
pokemon_id: int,
data: PokemonUpdate,
session: AsyncSession = Depends(get_session),
):
pokemon = await session.get(Pokemon, pokemon_id)
if pokemon is None:
raise HTTPException(status_code=404, detail="Pokemon not found")
update_data = data.model_dump(exclude_unset=True)
if "national_dex" in update_data:
existing = await session.execute(
select(Pokemon).where(
Pokemon.national_dex == update_data["national_dex"],
Pokemon.id != pokemon_id,
)
)
if existing.scalar_one_or_none() is not None:
raise HTTPException(
status_code=409,
detail=f"Pokemon with national dex #{update_data['national_dex']} already exists",
)
for field, value in update_data.items():
setattr(pokemon, field, value)
await session.commit()
await session.refresh(pokemon)
return pokemon
@router.delete("/pokemon/{pokemon_id}", status_code=204)
async def delete_pokemon(
pokemon_id: int, session: AsyncSession = Depends(get_session)
):
result = await session.execute(
select(Pokemon)
.where(Pokemon.id == pokemon_id)
.options(selectinload(Pokemon.encounters))
)
pokemon = result.scalar_one_or_none()
if pokemon is None:
raise HTTPException(status_code=404, detail="Pokemon not found")
if pokemon.encounters:
raise HTTPException(
status_code=409,
detail="Cannot delete pokemon with existing encounters.",
)
await session.delete(pokemon)
await session.commit()
@router.get(
"/routes/{route_id}/pokemon",
response_model=list[RouteEncounterDetailResponse],
@@ -41,3 +175,88 @@ async def list_route_encounters(
.order_by(RouteEncounter.encounter_rate.desc())
)
return result.scalars().unique().all()
@router.post(
"/routes/{route_id}/pokemon",
response_model=RouteEncounterDetailResponse,
status_code=201,
)
async def add_route_encounter(
route_id: int,
data: RouteEncounterCreate,
session: AsyncSession = Depends(get_session),
):
route = await session.get(Route, route_id)
if route is None:
raise HTTPException(status_code=404, detail="Route not found")
pokemon = await session.get(Pokemon, data.pokemon_id)
if pokemon is None:
raise HTTPException(status_code=404, detail="Pokemon not found")
encounter = RouteEncounter(route_id=route_id, **data.model_dump())
session.add(encounter)
await session.commit()
# Reload with pokemon relationship
result = await session.execute(
select(RouteEncounter)
.where(RouteEncounter.id == encounter.id)
.options(joinedload(RouteEncounter.pokemon))
)
return result.scalar_one()
@router.put(
"/routes/{route_id}/pokemon/{encounter_id}",
response_model=RouteEncounterDetailResponse,
)
async def update_route_encounter(
route_id: int,
encounter_id: int,
data: RouteEncounterUpdate,
session: AsyncSession = Depends(get_session),
):
result = await session.execute(
select(RouteEncounter)
.where(RouteEncounter.id == encounter_id, RouteEncounter.route_id == route_id)
.options(joinedload(RouteEncounter.pokemon))
)
encounter = result.scalar_one_or_none()
if encounter is None:
raise HTTPException(status_code=404, detail="Route encounter not found")
for field, value in data.model_dump(exclude_unset=True).items():
setattr(encounter, field, value)
await session.commit()
await session.refresh(encounter)
# Reload with pokemon relationship
result = await session.execute(
select(RouteEncounter)
.where(RouteEncounter.id == encounter.id)
.options(joinedload(RouteEncounter.pokemon))
)
return result.scalar_one()
@router.delete("/routes/{route_id}/pokemon/{encounter_id}", status_code=204)
async def remove_route_encounter(
route_id: int,
encounter_id: int,
session: AsyncSession = Depends(get_session),
):
encounter = await session.execute(
select(RouteEncounter).where(
RouteEncounter.id == encounter_id,
RouteEncounter.route_id == route_id,
)
)
encounter = encounter.scalar_one_or_none()
if encounter is None:
raise HTTPException(status_code=404, detail="Route encounter not found")
await session.delete(encounter)
await session.commit()

View File

@@ -4,25 +4,51 @@ from app.schemas.encounter import (
EncounterResponse,
EncounterUpdate,
)
from app.schemas.game import GameDetailResponse, GameResponse, RouteResponse
from app.schemas.game import (
GameCreate,
GameDetailResponse,
GameResponse,
GameUpdate,
RouteCreate,
RouteReorderRequest,
RouteResponse,
RouteUpdate,
)
from app.schemas.pokemon import (
BulkImportItem,
BulkImportResult,
PokemonCreate,
PokemonResponse,
PokemonUpdate,
RouteEncounterCreate,
RouteEncounterDetailResponse,
RouteEncounterResponse,
RouteEncounterUpdate,
)
from app.schemas.run import RunCreate, RunDetailResponse, RunResponse, RunUpdate
__all__ = [
"BulkImportItem",
"BulkImportResult",
"EncounterCreate",
"EncounterDetailResponse",
"EncounterResponse",
"EncounterUpdate",
"GameCreate",
"GameDetailResponse",
"GameResponse",
"RouteResponse",
"GameUpdate",
"PokemonCreate",
"PokemonResponse",
"PokemonUpdate",
"RouteCreate",
"RouteEncounterCreate",
"RouteEncounterDetailResponse",
"RouteEncounterResponse",
"RouteEncounterUpdate",
"RouteReorderRequest",
"RouteResponse",
"RouteUpdate",
"RunCreate",
"RunDetailResponse",
"RunResponse",

View File

@@ -20,3 +20,43 @@ class GameResponse(CamelModel):
class GameDetailResponse(GameResponse):
routes: list[RouteResponse] = []
# --- Admin schemas ---
class GameCreate(CamelModel):
name: str
slug: str
generation: int
region: str
box_art_url: str | None = None
release_year: int | None = None
class GameUpdate(CamelModel):
name: str | None = None
slug: str | None = None
generation: int | None = None
region: str | None = None
box_art_url: str | None = None
release_year: int | None = None
class RouteCreate(CamelModel):
name: str
order: int
class RouteUpdate(CamelModel):
name: str | None = None
order: int | None = None
class RouteReorderItem(CamelModel):
id: int
order: int
class RouteReorderRequest(CamelModel):
routes: list[RouteReorderItem]

View File

@@ -1,3 +1,5 @@
from pydantic import BaseModel
from app.schemas.base import CamelModel
@@ -21,3 +23,48 @@ class RouteEncounterResponse(CamelModel):
class RouteEncounterDetailResponse(RouteEncounterResponse):
pokemon: PokemonResponse
# --- Admin schemas ---
class PokemonCreate(CamelModel):
national_dex: int
name: str
types: list[str]
sprite_url: str | None = None
class PokemonUpdate(CamelModel):
national_dex: int | None = None
name: str | None = None
types: list[str] | None = None
sprite_url: str | None = None
class RouteEncounterCreate(CamelModel):
pokemon_id: int
encounter_method: str
encounter_rate: int
min_level: int
max_level: int
class RouteEncounterUpdate(CamelModel):
encounter_method: str | None = None
encounter_rate: int | None = None
min_level: int | None = None
max_level: int | None = None
class BulkImportItem(BaseModel):
national_dex: int
name: str
types: list[str]
sprite_url: str | None = None
class BulkImportResult(CamelModel):
created: int
updated: int
errors: list[str]

View File

@@ -1,6 +1,8 @@
import { Routes, Route } from 'react-router-dom'
import { Routes, Route, Navigate } from 'react-router-dom'
import { Layout } from './components'
import { AdminLayout } from './components/admin'
import { Home, NewRun, RunList, RunDashboard, RunEncounters } from './pages'
import { AdminGames, AdminGameDetail, AdminPokemon, AdminRouteDetail } from './pages/admin'
function App() {
return (
@@ -11,6 +13,13 @@ function App() {
<Route path="runs/new" element={<NewRun />} />
<Route path="runs/:runId" element={<RunDashboard />} />
<Route path="runs/:runId/encounters" element={<RunEncounters />} />
<Route path="admin" element={<AdminLayout />}>
<Route index element={<Navigate to="/admin/games" replace />} />
<Route path="games" element={<AdminGames />} />
<Route path="games/:gameId" element={<AdminGameDetail />} />
<Route path="games/:gameId/routes/:routeId" element={<AdminRouteDetail />} />
<Route path="pokemon" element={<AdminPokemon />} />
</Route>
</Route>
</Routes>
)

71
frontend/src/api/admin.ts Normal file
View File

@@ -0,0 +1,71 @@
import { api } from './client'
import type {
Game,
Route,
Pokemon,
RouteEncounterDetail,
CreateGameInput,
UpdateGameInput,
CreateRouteInput,
UpdateRouteInput,
RouteReorderItem,
CreatePokemonInput,
UpdatePokemonInput,
BulkImportResult,
CreateRouteEncounterInput,
UpdateRouteEncounterInput,
} from '../types'
// Games
export const createGame = (data: CreateGameInput) =>
api.post<Game>('/games', data)
export const updateGame = (id: number, data: UpdateGameInput) =>
api.put<Game>(`/games/${id}`, data)
export const deleteGame = (id: number) =>
api.del(`/games/${id}`)
// Routes
export const createRoute = (gameId: number, data: CreateRouteInput) =>
api.post<Route>(`/games/${gameId}/routes`, data)
export const updateRoute = (gameId: number, routeId: number, data: UpdateRouteInput) =>
api.put<Route>(`/games/${gameId}/routes/${routeId}`, data)
export const deleteRoute = (gameId: number, routeId: number) =>
api.del(`/games/${gameId}/routes/${routeId}`)
export const reorderRoutes = (gameId: number, routes: RouteReorderItem[]) =>
api.put<Route[]>(`/games/${gameId}/routes/reorder`, { routes })
// Pokemon
export const listPokemon = (search?: string, limit = 50, offset = 0) => {
const params = new URLSearchParams()
if (search) params.set('search', search)
params.set('limit', String(limit))
params.set('offset', String(offset))
return api.get<Pokemon[]>(`/pokemon?${params}`)
}
export const createPokemon = (data: CreatePokemonInput) =>
api.post<Pokemon>('/pokemon', data)
export const updatePokemon = (id: number, data: UpdatePokemonInput) =>
api.put<Pokemon>(`/pokemon/${id}`, data)
export const deletePokemon = (id: number) =>
api.del(`/pokemon/${id}`)
export const bulkImportPokemon = (items: Array<{ nationalDex: number; name: string; types: string[]; spriteUrl?: string | null }>) =>
api.post<BulkImportResult>('/pokemon/bulk-import', items)
// Route Encounters
export const addRouteEncounter = (routeId: number, data: CreateRouteEncounterInput) =>
api.post<RouteEncounterDetail>(`/routes/${routeId}/pokemon`, data)
export const updateRouteEncounter = (routeId: number, encounterId: number, data: UpdateRouteEncounterInput) =>
api.put<RouteEncounterDetail>(`/routes/${routeId}/pokemon/${encounterId}`, data)
export const removeRouteEncounter = (routeId: number, encounterId: number) =>
api.del(`/routes/${routeId}/pokemon/${encounterId}`)

View File

@@ -46,6 +46,12 @@ export const api = {
body: JSON.stringify(body),
}),
put: <T>(path: string, body: unknown) =>
request<T>(path, {
method: 'PUT',
body: JSON.stringify(body),
}),
del: <T = void>(path: string) =>
request<T>(path, { method: 'DELETE' }),
}

View File

@@ -24,6 +24,12 @@ export function Layout() {
>
My Runs
</Link>
<Link
to="/admin"
className="px-3 py-2 rounded-md text-sm font-medium hover:bg-gray-100 dark:hover:bg-gray-700"
>
Admin
</Link>
</div>
</div>
</div>

View File

@@ -0,0 +1,39 @@
import { NavLink, Outlet } from 'react-router-dom'
const navItems = [
{ to: '/admin/games', label: 'Games' },
{ to: '/admin/pokemon', label: 'Pokemon' },
]
export function AdminLayout() {
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<h1 className="text-2xl font-bold mb-6">Admin Panel</h1>
<div className="flex gap-8">
<nav className="w-48 flex-shrink-0">
<ul className="space-y-1">
{navItems.map((item) => (
<li key={item.to}>
<NavLink
to={item.to}
className={({ isActive }) =>
`block px-3 py-2 rounded-md text-sm font-medium ${
isActive
? 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-200'
: 'hover:bg-gray-100 dark:hover:bg-gray-700'
}`
}
>
{item.label}
</NavLink>
</li>
))}
</ul>
</nav>
<div className="flex-1 min-w-0">
<Outlet />
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,78 @@
import { type ReactNode } from 'react'
export interface Column<T> {
header: string
accessor: (row: T) => ReactNode
className?: string
}
interface AdminTableProps<T> {
columns: Column<T>[]
data: T[]
isLoading?: boolean
emptyMessage?: string
onRowClick?: (row: T) => void
keyFn: (row: T) => string | number
}
export function AdminTable<T>({
columns,
data,
isLoading,
emptyMessage = 'No data found.',
onRowClick,
keyFn,
}: AdminTableProps<T>) {
if (isLoading) {
return (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
Loading...
</div>
)
}
if (data.length === 0) {
return (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
{emptyMessage}
</div>
)
}
return (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-800">
<tr>
{columns.map((col) => (
<th
key={col.header}
className={`px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider ${col.className ?? ''}`}
>
{col.header}
</th>
))}
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
{data.map((row) => (
<tr
key={keyFn(row)}
onClick={onRowClick ? () => onRowClick(row) : undefined}
className={onRowClick ? 'cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800' : ''}
>
{columns.map((col) => (
<td
key={col.header}
className={`px-4 py-3 text-sm whitespace-nowrap ${col.className ?? ''}`}
>
{col.accessor(row)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)
}

View File

@@ -0,0 +1,106 @@
import { type FormEvent, useState } from 'react'
import type { BulkImportResult } from '../../types'
interface BulkImportModalProps {
onSubmit: (items: Array<{ nationalDex: number; name: string; types: string[]; spriteUrl?: string | null }>) => Promise<BulkImportResult>
onClose: () => void
}
const EXAMPLE = `[
{ "nationalDex": 1, "name": "Bulbasaur", "types": ["Grass", "Poison"] },
{ "nationalDex": 4, "name": "Charmander", "types": ["Fire"] }
]`
export function BulkImportModal({ onSubmit, onClose }: BulkImportModalProps) {
const [json, setJson] = useState('')
const [error, setError] = useState<string | null>(null)
const [result, setResult] = useState<BulkImportResult | null>(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 (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="fixed inset-0 bg-black/50" onClick={onClose} />
<div className="relative bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold">Bulk Import Pokemon</h2>
</div>
<form onSubmit={handleSubmit}>
<div className="px-6 py-4 space-y-4">
<div>
<label className="block text-sm font-medium mb-1">
JSON Data
</label>
<textarea
rows={12}
value={json}
onChange={(e) => setJson(e.target.value)}
placeholder={EXAMPLE}
className="w-full px-3 py-2 border rounded-md font-mono text-sm dark:bg-gray-700 dark:border-gray-600"
/>
</div>
{error && (
<div className="p-3 bg-red-50 dark:bg-red-900/30 text-red-700 dark:text-red-300 rounded-md text-sm">
{error}
</div>
)}
{result && (
<div className="p-3 bg-green-50 dark:bg-green-900/30 text-green-700 dark:text-green-300 rounded-md text-sm">
<p>Created: {result.created}, Updated: {result.updated}</p>
{result.errors.length > 0 && (
<ul className="mt-2 list-disc list-inside text-red-600 dark:text-red-400">
{result.errors.map((err, i) => (
<li key={i}>{err}</li>
))}
</ul>
)}
</div>
)}
</div>
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm font-medium rounded-md border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700"
>
Close
</button>
<button
type="submit"
disabled={isSubmitting || !json.trim()}
className="px-4 py-2 text-sm font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50"
>
{isSubmitting ? 'Importing...' : 'Import'}
</button>
</div>
</form>
</div>
</div>
)
}

View File

@@ -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 (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="fixed inset-0 bg-black/50" onClick={onCancel} />
<div className="relative bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full mx-4">
<div className="px-6 py-4">
<h2 className="text-lg font-semibold text-red-600 dark:text-red-400">
{title}
</h2>
<p className="mt-2 text-sm text-gray-600 dark:text-gray-300">
{message}
</p>
</div>
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3">
<button
type="button"
onClick={onCancel}
className="px-4 py-2 text-sm font-medium rounded-md border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700"
>
Cancel
</button>
<button
type="button"
onClick={onConfirm}
disabled={isDeleting}
className="px-4 py-2 text-sm font-medium rounded-md bg-red-600 text-white hover:bg-red-700 disabled:opacity-50"
>
{isDeleting ? 'Deleting...' : 'Delete'}
</button>
</div>
</div>
</div>
)
}

View File

@@ -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 (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="fixed inset-0 bg-black/50" onClick={onClose} />
<div className="relative bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-lg w-full mx-4 max-h-[90vh] overflow-y-auto">
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold">{title}</h2>
</div>
<form onSubmit={onSubmit}>
<div className="px-6 py-4 space-y-4">{children}</div>
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm font-medium rounded-md border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700"
>
Cancel
</button>
<button
type="submit"
disabled={isSubmitting}
className="px-4 py-2 text-sm font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50"
>
{isSubmitting ? 'Saving...' : submitLabel}
</button>
</div>
</form>
</div>
</div>
)
}

View File

@@ -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 (
<FormModal
title={game ? 'Edit Game' : 'Add Game'}
onClose={onClose}
onSubmit={handleSubmit}
isSubmitting={isSubmitting}
>
<div>
<label className="block text-sm font-medium mb-1">Name</label>
<input
type="text"
required
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Slug</label>
<input
type="text"
required
value={slug}
onChange={(e) => {
setSlug(e.target.value)
setAutoSlug(false)
}}
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-1">Generation</label>
<input
type="number"
required
min={1}
value={generation}
onChange={(e) => setGeneration(e.target.value)}
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Region</label>
<input
type="text"
required
value={region}
onChange={(e) => setRegion(e.target.value)}
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium mb-1">Box Art URL</label>
<input
type="text"
value={boxArtUrl}
onChange={(e) => setBoxArtUrl(e.target.value)}
placeholder="Optional"
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Release Year</label>
<input
type="number"
value={releaseYear}
onChange={(e) => setReleaseYear(e.target.value)}
placeholder="Optional"
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
/>
</div>
</FormModal>
)
}

View File

@@ -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 (
<FormModal
title={pokemon ? 'Edit Pokemon' : 'Add Pokemon'}
onClose={onClose}
onSubmit={handleSubmit}
isSubmitting={isSubmitting}
>
<div>
<label className="block text-sm font-medium mb-1">National Dex #</label>
<input
type="number"
required
min={1}
value={nationalDex}
onChange={(e) => setNationalDex(e.target.value)}
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Name</label>
<input
type="text"
required
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Types (comma-separated)</label>
<input
type="text"
required
value={types}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Sprite URL</label>
<input
type="text"
value={spriteUrl}
onChange={(e) => setSpriteUrl(e.target.value)}
placeholder="Optional"
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
/>
</div>
</FormModal>
)
}

View File

@@ -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 (
<FormModal
title={encounter ? 'Edit Route Encounter' : 'Add Pokemon to Route'}
onClose={onClose}
onSubmit={handleSubmit}
isSubmitting={isSubmitting}
>
{!encounter && (
<div>
<label className="block text-sm font-medium mb-1">Pokemon</label>
<input
type="text"
value={search}
onChange={(e) => 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 && (
<select
required
value={pokemonId || ''}
onChange={(e) => setPokemonId(Number(e.target.value))}
size={Math.min(pokemonOptions.length, 6)}
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
>
{!pokemonId && <option value="">Select a pokemon...</option>}
{pokemonOptions.map((p) => (
<option key={p.id} value={p.id}>
#{p.nationalDex} {p.name} ({p.types.join('/')})
</option>
))}
</select>
)}
</div>
)}
<div>
<label className="block text-sm font-medium mb-1">Encounter Method</label>
<input
type="text"
required
value={encounterMethod}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Encounter Rate (%)</label>
<input
type="number"
required
min={1}
max={100}
value={encounterRate}
onChange={(e) => setEncounterRate(e.target.value)}
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-1">Min Level</label>
<input
type="number"
required
min={1}
max={100}
value={minLevel}
onChange={(e) => setMinLevel(e.target.value)}
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Max Level</label>
<input
type="number"
required
min={1}
max={100}
value={maxLevel}
onChange={(e) => setMaxLevel(e.target.value)}
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
/>
</div>
</div>
</FormModal>
)
}

View File

@@ -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 (
<FormModal
title={route ? 'Edit Route' : 'Add Route'}
onClose={onClose}
onSubmit={handleSubmit}
isSubmitting={isSubmitting}
>
<div>
<label className="block text-sm font-medium mb-1">Name</label>
<input
type="text"
required
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Order</label>
<input
type="number"
required
min={0}
value={order}
onChange={(e) => setOrder(e.target.value)}
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
/>
</div>
</FormModal>
)
}

View File

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

View File

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

View File

@@ -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<GameRoute | null>(null)
const [deleting, setDeleting] = useState<GameRoute | null>(null)
if (isLoading) return <div className="py-8 text-center text-gray-500">Loading...</div>
if (!game) return <div className="py-8 text-center text-gray-500">Game not found</div>
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<GameRoute>[] = [
{ 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 (
<div className="flex gap-2" onClick={(e) => e.stopPropagation()}>
<button
onClick={() => moveRoute(r, 'up')}
disabled={idx === 0}
className="text-gray-600 hover:text-gray-800 dark:text-gray-400 disabled:opacity-30 text-sm"
title="Move up"
>
Up
</button>
<button
onClick={() => moveRoute(r, 'down')}
disabled={idx === routes.length - 1}
className="text-gray-600 hover:text-gray-800 dark:text-gray-400 disabled:opacity-30 text-sm"
title="Move down"
>
Down
</button>
<button
onClick={() => setEditing(r)}
className="text-blue-600 hover:text-blue-800 dark:text-blue-400 text-sm"
>
Edit
</button>
<button
onClick={() => setDeleting(r)}
className="text-red-600 hover:text-red-800 dark:text-red-400 text-sm"
>
Delete
</button>
</div>
)
},
},
]
return (
<div>
<nav className="text-sm mb-4 text-gray-500 dark:text-gray-400">
<Link to="/admin/games" className="hover:underline">
Games
</Link>
{' / '}
<span className="text-gray-900 dark:text-gray-100">{game.name}</span>
</nav>
<div className="mb-6">
<h2 className="text-xl font-semibold">{game.name}</h2>
<p className="text-sm text-gray-500 dark:text-gray-400">
{game.region} &middot; Gen {game.generation}
{game.releaseYear ? ` \u00b7 ${game.releaseYear}` : ''}
</p>
</div>
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-medium">Routes ({routes.length})</h3>
<button
onClick={() => setShowCreate(true)}
className="px-4 py-2 text-sm font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700"
>
Add Route
</button>
</div>
<AdminTable
columns={columns}
data={routes}
emptyMessage="No routes yet. Add one to get started."
onRowClick={(r) => navigate(`/admin/games/${id}/routes/${r.id}`)}
keyFn={(r) => r.id}
/>
{showCreate && (
<RouteFormModal
nextOrder={routes.length > 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 && (
<RouteFormModal
route={editing}
onSubmit={(data) =>
updateRoute.mutate(
{ routeId: editing.id, data: data as UpdateRouteInput },
{ onSuccess: () => setEditing(null) },
)
}
onClose={() => setEditing(null)}
isSubmitting={updateRoute.isPending}
/>
)}
{deleting && (
<DeleteConfirmModal
title={`Delete ${deleting.name}?`}
message="This will permanently delete the route. Routes with existing encounters cannot be deleted."
onConfirm={() =>
deleteRoute.mutate(deleting.id, {
onSuccess: () => setDeleting(null),
})
}
onCancel={() => setDeleting(null)}
isDeleting={deleteRoute.isPending}
/>
)}
</div>
)
}

View File

@@ -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<Game | null>(null)
const [deleting, setDeleting] = useState<Game | null>(null)
const columns: Column<Game>[] = [
{ 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) => (
<div className="flex gap-2" onClick={(e) => e.stopPropagation()}>
<button
onClick={() => setEditing(g)}
className="text-blue-600 hover:text-blue-800 dark:text-blue-400 text-sm"
>
Edit
</button>
<button
onClick={() => setDeleting(g)}
className="text-red-600 hover:text-red-800 dark:text-red-400 text-sm"
>
Delete
</button>
</div>
),
},
]
return (
<div>
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-semibold">Games</h2>
<button
onClick={() => setShowCreate(true)}
className="px-4 py-2 text-sm font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700"
>
Add Game
</button>
</div>
<AdminTable
columns={columns}
data={games}
isLoading={isLoading}
emptyMessage="No games yet. Add one to get started."
onRowClick={(g) => navigate(`/admin/games/${g.id}`)}
keyFn={(g) => g.id}
/>
{showCreate && (
<GameFormModal
onSubmit={(data) =>
createGame.mutate(data as CreateGameInput, {
onSuccess: () => setShowCreate(false),
})
}
onClose={() => setShowCreate(false)}
isSubmitting={createGame.isPending}
/>
)}
{editing && (
<GameFormModal
game={editing}
onSubmit={(data) =>
updateGame.mutate(
{ id: editing.id, data: data as UpdateGameInput },
{ onSuccess: () => setEditing(null) },
)
}
onClose={() => setEditing(null)}
isSubmitting={updateGame.isPending}
/>
)}
{deleting && (
<DeleteConfirmModal
title={`Delete ${deleting.name}?`}
message="This will permanently delete the game and all its routes. Games with existing runs cannot be deleted."
onConfirm={() =>
deleteGame.mutate(deleting.id, {
onSuccess: () => setDeleting(null),
})
}
onCancel={() => setDeleting(null)}
isDeleting={deleteGame.isPending}
/>
)}
</div>
)
}

View File

@@ -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<Pokemon | null>(null)
const [deleting, setDeleting] = useState<Pokemon | null>(null)
const columns: Column<Pokemon>[] = [
{ header: 'Dex #', accessor: (p) => p.nationalDex, className: 'w-16' },
{
header: 'Sprite',
className: 'w-16',
accessor: (p) =>
p.spriteUrl ? (
<img src={p.spriteUrl} alt={p.name} className="w-8 h-8" />
) : (
<div className="w-8 h-8 bg-gray-200 dark:bg-gray-700 rounded" />
),
},
{ header: 'Name', accessor: (p) => p.name },
{ header: 'Types', accessor: (p) => p.types.join(', ') },
{
header: 'Actions',
accessor: (p) => (
<div className="flex gap-2">
<button
onClick={() => setEditing(p)}
className="text-blue-600 hover:text-blue-800 dark:text-blue-400 text-sm"
>
Edit
</button>
<button
onClick={() => setDeleting(p)}
className="text-red-600 hover:text-red-800 dark:text-red-400 text-sm"
>
Delete
</button>
</div>
),
},
]
return (
<div>
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-semibold">Pokemon</h2>
<div className="flex gap-2">
<button
onClick={() => setShowBulkImport(true)}
className="px-4 py-2 text-sm font-medium rounded-md border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700"
>
Bulk Import
</button>
<button
onClick={() => setShowCreate(true)}
className="px-4 py-2 text-sm font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700"
>
Add Pokemon
</button>
</div>
</div>
<div className="mb-4">
<input
type="text"
value={search}
onChange={(e) => 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"
/>
</div>
<AdminTable
columns={columns}
data={pokemon}
isLoading={isLoading}
emptyMessage="No pokemon found."
keyFn={(p) => p.id}
/>
{showCreate && (
<PokemonFormModal
onSubmit={(data) =>
createPokemon.mutate(data as CreatePokemonInput, {
onSuccess: () => setShowCreate(false),
})
}
onClose={() => setShowCreate(false)}
isSubmitting={createPokemon.isPending}
/>
)}
{showBulkImport && (
<BulkImportModal
onSubmit={(items) => bulkImport.mutateAsync(items)}
onClose={() => setShowBulkImport(false)}
/>
)}
{editing && (
<PokemonFormModal
pokemon={editing}
onSubmit={(data) =>
updatePokemon.mutate(
{ id: editing.id, data: data as UpdatePokemonInput },
{ onSuccess: () => setEditing(null) },
)
}
onClose={() => setEditing(null)}
isSubmitting={updatePokemon.isPending}
/>
)}
{deleting && (
<DeleteConfirmModal
title={`Delete ${deleting.name}?`}
message="This will permanently delete the pokemon. Pokemon with existing encounters cannot be deleted."
onConfirm={() =>
deletePokemon.mutate(deleting.id, {
onSuccess: () => setDeleting(null),
})
}
onCancel={() => setDeleting(null)}
isDeleting={deletePokemon.isPending}
/>
)}
</div>
)
}

View File

@@ -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<RouteEncounterDetail | null>(null)
const [deleting, setDeleting] = useState<RouteEncounterDetail | null>(null)
const route = game?.routes?.find((r) => r.id === rId)
const columns: Column<RouteEncounterDetail>[] = [
{
header: 'Pokemon',
accessor: (e) => (
<div className="flex items-center gap-2">
{e.pokemon.spriteUrl ? (
<img src={e.pokemon.spriteUrl} alt={e.pokemon.name} className="w-6 h-6" />
) : null}
<span>
#{e.pokemon.nationalDex} {e.pokemon.name}
</span>
</div>
),
},
{ 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) => (
<div className="flex gap-2">
<button
onClick={() => setEditing(e)}
className="text-blue-600 hover:text-blue-800 dark:text-blue-400 text-sm"
>
Edit
</button>
<button
onClick={() => setDeleting(e)}
className="text-red-600 hover:text-red-800 dark:text-red-400 text-sm"
>
Remove
</button>
</div>
),
},
]
return (
<div>
<nav className="text-sm mb-4 text-gray-500 dark:text-gray-400">
<Link to="/admin/games" className="hover:underline">
Games
</Link>
{' / '}
<Link to={`/admin/games/${gId}`} className="hover:underline">
{game?.name ?? '...'}
</Link>
{' / '}
<span className="text-gray-900 dark:text-gray-100">
{route?.name ?? '...'}
</span>
</nav>
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-semibold">
{route?.name ?? 'Route'} - Pokemon ({encounters.length})
</h2>
<button
onClick={() => setShowCreate(true)}
className="px-4 py-2 text-sm font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700"
>
Add Pokemon
</button>
</div>
<AdminTable
columns={columns}
data={encounters}
isLoading={isLoading}
emptyMessage="No pokemon assigned to this route yet."
keyFn={(e) => e.id}
/>
{showCreate && (
<RouteEncounterFormModal
onSubmit={(data) =>
addEncounter.mutate(data as CreateRouteEncounterInput, {
onSuccess: () => setShowCreate(false),
})
}
onClose={() => setShowCreate(false)}
isSubmitting={addEncounter.isPending}
/>
)}
{editing && (
<RouteEncounterFormModal
encounter={editing}
onSubmit={(data) =>
updateEncounter.mutate(
{ encounterId: editing.id, data: data as UpdateRouteEncounterInput },
{ onSuccess: () => setEditing(null) },
)
}
onClose={() => setEditing(null)}
isSubmitting={updateEncounter.isPending}
/>
)}
{deleting && (
<DeleteConfirmModal
title={`Remove ${deleting.pokemon.name}?`}
message="This will remove this pokemon from the route's encounter table."
onConfirm={() =>
removeEncounter.mutate(deleting.id, {
onSuccess: () => setDeleting(null),
})
}
onCancel={() => setDeleting(null)}
isDeleting={removeEncounter.isPending}
/>
)}
</div>
)
}

View File

@@ -0,0 +1,4 @@
export { AdminGames } from './AdminGames'
export { AdminGameDetail } from './AdminGameDetail'
export { AdminPokemon } from './AdminPokemon'
export { AdminRouteDetail } from './AdminRouteDetail'

View File

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

View File

@@ -1,2 +1,3 @@
export * from './admin'
export * from './game'
export * from './rules'