diff --git a/.beans/nuzlocke-tracker-czeh--improve-admin-panel-ux.md b/.beans/nuzlocke-tracker-czeh--improve-admin-panel-ux.md index 72982f7..4418b8b 100644 --- a/.beans/nuzlocke-tracker-czeh--improve-admin-panel-ux.md +++ b/.beans/nuzlocke-tracker-czeh--improve-admin-panel-ux.md @@ -1,26 +1,29 @@ --- # nuzlocke-tracker-czeh title: Improve admin panel UX -status: todo +status: completed type: feature priority: normal created_at: 2026-02-05T18:28:04Z -updated_at: 2026-02-05T18:28:07Z +updated_at: 2026-02-07T11:59:49Z parent: nuzlocke-tracker-f5ob --- Improve the admin panel with better interactions and missing management capabilities. ## Checklist -- [ ] Evolution data management: - - [ ] List evolutions (searchable/filterable) - - [ ] Add new evolution - - [ ] Edit evolution details (trigger, level, item, conditions) - - [ ] Delete evolution -- [ ] Route reordering: - - [ ] Replace up/down buttons with drag-and-drop -- [ ] General UX improvements: - - [ ] Improve table layouts and spacing - - [ ] Add loading states and better error feedback - - [ ] Add confirmation toasts for successful actions - - [ ] Improve mobile responsiveness of admin views \ No newline at end of file +- [x] Evolution data management: + - [x] List evolutions (searchable/filterable) + - [x] Add new evolution + - [x] Edit evolution details (trigger, level, item, conditions) + - [x] Delete evolution +- [x] Route reordering: + - [x] Replace up/down buttons with drag-and-drop +- [x] Table sorting (Games table): + - [x] Add sortable column support to AdminTable component + - [x] Enable sorting by Region, Generation, and Release Year on Games table +- [x] General UX improvements: + - [x] Improve table layouts and spacing + - [x] Add loading states and better error feedback + - [x] Add confirmation toasts for successful actions + - [x] Improve mobile responsiveness of admin views diff --git a/backend/src/app/api/evolutions.py b/backend/src/app/api/evolutions.py new file mode 100644 index 0000000..053a62b --- /dev/null +++ b/backend/src/app/api/evolutions.py @@ -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() diff --git a/backend/src/app/api/routes.py b/backend/src/app/api/routes.py index 3461243..5af37fb 100644 --- a/backend/src/app/api/routes.py +++ b/backend/src/app/api/routes.py @@ -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"]) diff --git a/backend/src/app/schemas/pokemon.py b/backend/src/app/schemas/pokemon.py index 2cf385b..d301c2e 100644 --- a/backend/src/app/schemas/pokemon.py +++ b/backend/src/app/schemas/pokemon.py @@ -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 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d39b684..ee91394 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,10 +8,14 @@ "name": "frontend", "version": "0.0.0", "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@tanstack/react-query": "^5.90.20", "react": "^19.2.0", "react-dom": "^19.2.0", - "react-router-dom": "^7.13.0" + "react-router-dom": "^7.13.0", + "sonner": "^2.0.7" }, "devDependencies": { "@eslint/js": "^9.39.1", @@ -315,6 +319,60 @@ "node": ">=6.9.0" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", @@ -3659,6 +3717,16 @@ "node": ">=8" } }, + "node_modules/sonner": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", + "integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==", + "license": "MIT", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -3746,6 +3814,12 @@ "typescript": ">=4.8.4" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index c5e48bd..acfd726 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,10 +10,14 @@ "preview": "vite preview" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@tanstack/react-query": "^5.90.20", "react": "^19.2.0", "react-dom": "^19.2.0", - "react-router-dom": "^7.13.0" + "react-router-dom": "^7.13.0", + "sonner": "^2.0.7" }, "devDependencies": { "@eslint/js": "^9.39.1", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b643e94..bf9a71a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2,7 +2,13 @@ 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' +import { + AdminGames, + AdminGameDetail, + AdminPokemon, + AdminRouteDetail, + AdminEvolutions, +} from './pages/admin' function App() { return ( @@ -19,6 +25,7 @@ function App() { } /> } /> } /> + } /> diff --git a/frontend/src/api/admin.ts b/frontend/src/api/admin.ts index 6549eb2..66633e7 100644 --- a/frontend/src/api/admin.ts +++ b/frontend/src/api/admin.ts @@ -15,6 +15,10 @@ import type { PaginatedPokemon, CreateRouteEncounterInput, UpdateRouteEncounterInput, + EvolutionAdmin, + PaginatedEvolutions, + CreateEvolutionInput, + UpdateEvolutionInput, } from '../types' // Games @@ -61,6 +65,24 @@ export const deletePokemon = (id: number) => export const bulkImportPokemon = (items: Array<{ nationalDex: number; name: string; types: string[]; spriteUrl?: string | null }>) => api.post('/pokemon/bulk-import', items) +// Evolutions +export const listEvolutions = (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(`/evolutions?${params}`) +} + +export const createEvolution = (data: CreateEvolutionInput) => + api.post('/evolutions', data) + +export const updateEvolution = (id: number, data: UpdateEvolutionInput) => + api.put(`/evolutions/${id}`, data) + +export const deleteEvolution = (id: number) => + api.del(`/evolutions/${id}`) + // Route Encounters export const addRouteEncounter = (routeId: number, data: CreateRouteEncounterInput) => api.post(`/routes/${routeId}/pokemon`, data) diff --git a/frontend/src/components/admin/AdminLayout.tsx b/frontend/src/components/admin/AdminLayout.tsx index ef5ef0c..af99ee5 100644 --- a/frontend/src/components/admin/AdminLayout.tsx +++ b/frontend/src/components/admin/AdminLayout.tsx @@ -3,21 +3,22 @@ import { NavLink, Outlet } from 'react-router-dom' const navItems = [ { to: '/admin/games', label: 'Games' }, { to: '/admin/pokemon', label: 'Pokemon' }, + { to: '/admin/evolutions', label: 'Evolutions' }, ] export function AdminLayout() { return (

Admin Panel

-
-