Improve admin panel UX with toasts, evolution CRUD, sorting, drag-and-drop, and responsive layout

Add sonner toast notifications to all mutations, evolution management backend
(CRUD endpoints with search/pagination) and frontend (form modal with pokemon
selector, paginated list page), sortable AdminTable columns (Region/Gen/Year
on Games), drag-and-drop route reordering via @dnd-kit, skeleton loading states,
card-styled table wrappers, and responsive mobile nav in AdminLayout.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-07 13:09:27 +01:00
parent 574e36ee22
commit 1f198aca4c
20 changed files with 1140 additions and 138 deletions

View File

@@ -0,0 +1,146 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import joinedload
from app.core.database import get_session
from app.models.evolution import Evolution
from app.models.pokemon import Pokemon
from app.schemas.pokemon import (
EvolutionAdminResponse,
EvolutionCreate,
EvolutionUpdate,
PaginatedEvolutionResponse,
)
router = APIRouter()
@router.get("/evolutions", response_model=PaginatedEvolutionResponse)
async def list_evolutions(
search: str | None = Query(None),
limit: int = Query(50, ge=1, le=500),
offset: int = Query(0, ge=0),
session: AsyncSession = Depends(get_session),
):
base_query = (
select(Evolution)
.options(joinedload(Evolution.from_pokemon), joinedload(Evolution.to_pokemon))
)
if search:
search_lower = search.lower()
# Join pokemon to search by name
from_pokemon = select(Pokemon.id).where(
func.lower(Pokemon.name).contains(search_lower)
).scalar_subquery()
base_query = base_query.where(
or_(
Evolution.from_pokemon_id.in_(from_pokemon),
Evolution.to_pokemon_id.in_(from_pokemon),
func.lower(Evolution.trigger).contains(search_lower),
func.lower(Evolution.item).contains(search_lower),
)
)
# Count total (without eager loads)
count_base = select(Evolution)
if search:
search_lower = search.lower()
from_pokemon = select(Pokemon.id).where(
func.lower(Pokemon.name).contains(search_lower)
).scalar_subquery()
count_base = count_base.where(
or_(
Evolution.from_pokemon_id.in_(from_pokemon),
Evolution.to_pokemon_id.in_(from_pokemon),
func.lower(Evolution.trigger).contains(search_lower),
func.lower(Evolution.item).contains(search_lower),
)
)
count_query = select(func.count()).select_from(count_base.subquery())
total = (await session.execute(count_query)).scalar() or 0
items_query = base_query.order_by(Evolution.from_pokemon_id, Evolution.to_pokemon_id).offset(offset).limit(limit)
result = await session.execute(items_query)
items = result.scalars().unique().all()
return PaginatedEvolutionResponse(
items=items,
total=total,
limit=limit,
offset=offset,
)
@router.post("/evolutions", response_model=EvolutionAdminResponse, status_code=201)
async def create_evolution(
data: EvolutionCreate, session: AsyncSession = Depends(get_session)
):
from_pokemon = await session.get(Pokemon, data.from_pokemon_id)
if from_pokemon is None:
raise HTTPException(status_code=404, detail="From pokemon not found")
to_pokemon = await session.get(Pokemon, data.to_pokemon_id)
if to_pokemon is None:
raise HTTPException(status_code=404, detail="To pokemon not found")
evolution = Evolution(**data.model_dump())
session.add(evolution)
await session.commit()
# Reload with relationships
result = await session.execute(
select(Evolution)
.where(Evolution.id == evolution.id)
.options(joinedload(Evolution.from_pokemon), joinedload(Evolution.to_pokemon))
)
return result.scalar_one()
@router.put("/evolutions/{evolution_id}", response_model=EvolutionAdminResponse)
async def update_evolution(
evolution_id: int,
data: EvolutionUpdate,
session: AsyncSession = Depends(get_session),
):
evolution = await session.get(Evolution, evolution_id)
if evolution is None:
raise HTTPException(status_code=404, detail="Evolution not found")
update_data = data.model_dump(exclude_unset=True)
if "from_pokemon_id" in update_data:
from_pokemon = await session.get(Pokemon, update_data["from_pokemon_id"])
if from_pokemon is None:
raise HTTPException(status_code=404, detail="From pokemon not found")
if "to_pokemon_id" in update_data:
to_pokemon = await session.get(Pokemon, update_data["to_pokemon_id"])
if to_pokemon is None:
raise HTTPException(status_code=404, detail="To pokemon not found")
for field, value in update_data.items():
setattr(evolution, field, value)
await session.commit()
# Reload with relationships
result = await session.execute(
select(Evolution)
.where(Evolution.id == evolution.id)
.options(joinedload(Evolution.from_pokemon), joinedload(Evolution.to_pokemon))
)
return result.scalar_one()
@router.delete("/evolutions/{evolution_id}", status_code=204)
async def delete_evolution(
evolution_id: int, session: AsyncSession = Depends(get_session)
):
evolution = await session.get(Evolution, evolution_id)
if evolution is None:
raise HTTPException(status_code=404, detail="Evolution not found")
await session.delete(evolution)
await session.commit()

View File

@@ -1,10 +1,11 @@
from fastapi import APIRouter
from app.api import encounters, games, health, pokemon, runs
from app.api import encounters, evolutions, games, health, pokemon, runs
api_router = APIRouter()
api_router.include_router(health.router)
api_router.include_router(games.router, prefix="/games", tags=["games"])
api_router.include_router(pokemon.router, tags=["pokemon"])
api_router.include_router(evolutions.router, tags=["evolutions"])
api_router.include_router(runs.router, prefix="/runs", tags=["runs"])
api_router.include_router(encounters.router, tags=["encounters"])

View File

@@ -86,3 +86,46 @@ class BulkImportResult(CamelModel):
created: int
updated: int
errors: list[str]
# --- Evolution admin schemas ---
class EvolutionAdminResponse(CamelModel):
id: int
from_pokemon_id: int
to_pokemon_id: int
from_pokemon: PokemonResponse
to_pokemon: PokemonResponse
trigger: str
min_level: int | None
item: str | None
held_item: str | None
condition: str | None
class PaginatedEvolutionResponse(CamelModel):
items: list[EvolutionAdminResponse]
total: int
limit: int
offset: int
class EvolutionCreate(CamelModel):
from_pokemon_id: int
to_pokemon_id: int
trigger: str
min_level: int | None = None
item: str | None = None
held_item: str | None = None
condition: str | None = None
class EvolutionUpdate(CamelModel):
from_pokemon_id: int | None = None
to_pokemon_id: int | None = None
trigger: str | None = None
min_level: int | None = None
item: str | None = None
held_item: str | None = None
condition: str | None = None