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:
146
backend/src/app/api/evolutions.py
Normal file
146
backend/src/app/api/evolutions.py
Normal 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()
|
||||
@@ -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"])
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user