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:
@@ -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
|
||||
---
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
71
frontend/src/api/admin.ts
Normal 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}`)
|
||||
@@ -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' }),
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
39
frontend/src/components/admin/AdminLayout.tsx
Normal file
39
frontend/src/components/admin/AdminLayout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
78
frontend/src/components/admin/AdminTable.tsx
Normal file
78
frontend/src/components/admin/AdminTable.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
106
frontend/src/components/admin/BulkImportModal.tsx
Normal file
106
frontend/src/components/admin/BulkImportModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
48
frontend/src/components/admin/DeleteConfirmModal.tsx
Normal file
48
frontend/src/components/admin/DeleteConfirmModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
49
frontend/src/components/admin/FormModal.tsx
Normal file
49
frontend/src/components/admin/FormModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
119
frontend/src/components/admin/GameFormModal.tsx
Normal file
119
frontend/src/components/admin/GameFormModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
83
frontend/src/components/admin/PokemonFormModal.tsx
Normal file
83
frontend/src/components/admin/PokemonFormModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
134
frontend/src/components/admin/RouteEncounterFormModal.tsx
Normal file
134
frontend/src/components/admin/RouteEncounterFormModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
52
frontend/src/components/admin/RouteFormModal.tsx
Normal file
52
frontend/src/components/admin/RouteFormModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
9
frontend/src/components/admin/index.ts
Normal file
9
frontend/src/components/admin/index.ts
Normal 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'
|
||||
164
frontend/src/hooks/useAdmin.ts
Normal file
164
frontend/src/hooks/useAdmin.ts
Normal 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'] }),
|
||||
})
|
||||
}
|
||||
171
frontend/src/pages/admin/AdminGameDetail.tsx
Normal file
171
frontend/src/pages/admin/AdminGameDetail.tsx
Normal 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} · 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>
|
||||
)
|
||||
}
|
||||
110
frontend/src/pages/admin/AdminGames.tsx
Normal file
110
frontend/src/pages/admin/AdminGames.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
149
frontend/src/pages/admin/AdminPokemon.tsx
Normal file
149
frontend/src/pages/admin/AdminPokemon.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
155
frontend/src/pages/admin/AdminRouteDetail.tsx
Normal file
155
frontend/src/pages/admin/AdminRouteDetail.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
4
frontend/src/pages/admin/index.ts
Normal file
4
frontend/src/pages/admin/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { AdminGames } from './AdminGames'
|
||||
export { AdminGameDetail } from './AdminGameDetail'
|
||||
export { AdminPokemon } from './AdminPokemon'
|
||||
export { AdminRouteDetail } from './AdminRouteDetail'
|
||||
67
frontend/src/types/admin.ts
Normal file
67
frontend/src/types/admin.ts
Normal 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
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './admin'
|
||||
export * from './game'
|
||||
export * from './rules'
|
||||
|
||||
Reference in New Issue
Block a user