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:
@@ -1,26 +1,29 @@
|
|||||||
---
|
---
|
||||||
# nuzlocke-tracker-czeh
|
# nuzlocke-tracker-czeh
|
||||||
title: Improve admin panel UX
|
title: Improve admin panel UX
|
||||||
status: todo
|
status: completed
|
||||||
type: feature
|
type: feature
|
||||||
priority: normal
|
priority: normal
|
||||||
created_at: 2026-02-05T18:28:04Z
|
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
|
parent: nuzlocke-tracker-f5ob
|
||||||
---
|
---
|
||||||
|
|
||||||
Improve the admin panel with better interactions and missing management capabilities.
|
Improve the admin panel with better interactions and missing management capabilities.
|
||||||
|
|
||||||
## Checklist
|
## Checklist
|
||||||
- [ ] Evolution data management:
|
- [x] Evolution data management:
|
||||||
- [ ] List evolutions (searchable/filterable)
|
- [x] List evolutions (searchable/filterable)
|
||||||
- [ ] Add new evolution
|
- [x] Add new evolution
|
||||||
- [ ] Edit evolution details (trigger, level, item, conditions)
|
- [x] Edit evolution details (trigger, level, item, conditions)
|
||||||
- [ ] Delete evolution
|
- [x] Delete evolution
|
||||||
- [ ] Route reordering:
|
- [x] Route reordering:
|
||||||
- [ ] Replace up/down buttons with drag-and-drop
|
- [x] Replace up/down buttons with drag-and-drop
|
||||||
- [ ] General UX improvements:
|
- [x] Table sorting (Games table):
|
||||||
- [ ] Improve table layouts and spacing
|
- [x] Add sortable column support to AdminTable component
|
||||||
- [ ] Add loading states and better error feedback
|
- [x] Enable sorting by Region, Generation, and Release Year on Games table
|
||||||
- [ ] Add confirmation toasts for successful actions
|
- [x] General UX improvements:
|
||||||
- [ ] Improve mobile responsiveness of admin views
|
- [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
|
||||||
|
|||||||
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 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 = APIRouter()
|
||||||
api_router.include_router(health.router)
|
api_router.include_router(health.router)
|
||||||
api_router.include_router(games.router, prefix="/games", tags=["games"])
|
api_router.include_router(games.router, prefix="/games", tags=["games"])
|
||||||
api_router.include_router(pokemon.router, tags=["pokemon"])
|
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(runs.router, prefix="/runs", tags=["runs"])
|
||||||
api_router.include_router(encounters.router, tags=["encounters"])
|
api_router.include_router(encounters.router, tags=["encounters"])
|
||||||
|
|||||||
@@ -86,3 +86,46 @@ class BulkImportResult(CamelModel):
|
|||||||
created: int
|
created: int
|
||||||
updated: int
|
updated: int
|
||||||
errors: list[str]
|
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
|
||||||
|
|||||||
76
frontend/package-lock.json
generated
76
frontend/package-lock.json
generated
@@ -8,10 +8,14 @@
|
|||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"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",
|
"@tanstack/react-query": "^5.90.20",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^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": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.1",
|
"@eslint/js": "^9.39.1",
|
||||||
@@ -315,6 +319,60 @@
|
|||||||
"node": ">=6.9.0"
|
"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": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
"version": "0.27.2",
|
"version": "0.27.2",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz",
|
||||||
@@ -3659,6 +3717,16 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/source-map-js": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||||
@@ -3746,6 +3814,12 @@
|
|||||||
"typescript": ">=4.8.4"
|
"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": {
|
"node_modules/type-check": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
||||||
|
|||||||
@@ -10,10 +10,14 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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",
|
"@tanstack/react-query": "^5.90.20",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^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": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.1",
|
"@eslint/js": "^9.39.1",
|
||||||
|
|||||||
@@ -2,7 +2,13 @@ import { Routes, Route, Navigate } from 'react-router-dom'
|
|||||||
import { Layout } from './components'
|
import { Layout } from './components'
|
||||||
import { AdminLayout } from './components/admin'
|
import { AdminLayout } from './components/admin'
|
||||||
import { Home, NewRun, RunList, RunDashboard, RunEncounters } from './pages'
|
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() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
@@ -19,6 +25,7 @@ function App() {
|
|||||||
<Route path="games/:gameId" element={<AdminGameDetail />} />
|
<Route path="games/:gameId" element={<AdminGameDetail />} />
|
||||||
<Route path="games/:gameId/routes/:routeId" element={<AdminRouteDetail />} />
|
<Route path="games/:gameId/routes/:routeId" element={<AdminRouteDetail />} />
|
||||||
<Route path="pokemon" element={<AdminPokemon />} />
|
<Route path="pokemon" element={<AdminPokemon />} />
|
||||||
|
<Route path="evolutions" element={<AdminEvolutions />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ import type {
|
|||||||
PaginatedPokemon,
|
PaginatedPokemon,
|
||||||
CreateRouteEncounterInput,
|
CreateRouteEncounterInput,
|
||||||
UpdateRouteEncounterInput,
|
UpdateRouteEncounterInput,
|
||||||
|
EvolutionAdmin,
|
||||||
|
PaginatedEvolutions,
|
||||||
|
CreateEvolutionInput,
|
||||||
|
UpdateEvolutionInput,
|
||||||
} from '../types'
|
} from '../types'
|
||||||
|
|
||||||
// Games
|
// Games
|
||||||
@@ -61,6 +65,24 @@ export const deletePokemon = (id: number) =>
|
|||||||
export const bulkImportPokemon = (items: Array<{ nationalDex: number; name: string; types: string[]; spriteUrl?: string | null }>) =>
|
export const bulkImportPokemon = (items: Array<{ nationalDex: number; name: string; types: string[]; spriteUrl?: string | null }>) =>
|
||||||
api.post<BulkImportResult>('/pokemon/bulk-import', items)
|
api.post<BulkImportResult>('/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<PaginatedEvolutions>(`/evolutions?${params}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createEvolution = (data: CreateEvolutionInput) =>
|
||||||
|
api.post<EvolutionAdmin>('/evolutions', data)
|
||||||
|
|
||||||
|
export const updateEvolution = (id: number, data: UpdateEvolutionInput) =>
|
||||||
|
api.put<EvolutionAdmin>(`/evolutions/${id}`, data)
|
||||||
|
|
||||||
|
export const deleteEvolution = (id: number) =>
|
||||||
|
api.del(`/evolutions/${id}`)
|
||||||
|
|
||||||
// Route Encounters
|
// Route Encounters
|
||||||
export const addRouteEncounter = (routeId: number, data: CreateRouteEncounterInput) =>
|
export const addRouteEncounter = (routeId: number, data: CreateRouteEncounterInput) =>
|
||||||
api.post<RouteEncounterDetail>(`/routes/${routeId}/pokemon`, data)
|
api.post<RouteEncounterDetail>(`/routes/${routeId}/pokemon`, data)
|
||||||
|
|||||||
@@ -3,21 +3,22 @@ import { NavLink, Outlet } from 'react-router-dom'
|
|||||||
const navItems = [
|
const navItems = [
|
||||||
{ to: '/admin/games', label: 'Games' },
|
{ to: '/admin/games', label: 'Games' },
|
||||||
{ to: '/admin/pokemon', label: 'Pokemon' },
|
{ to: '/admin/pokemon', label: 'Pokemon' },
|
||||||
|
{ to: '/admin/evolutions', label: 'Evolutions' },
|
||||||
]
|
]
|
||||||
|
|
||||||
export function AdminLayout() {
|
export function AdminLayout() {
|
||||||
return (
|
return (
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<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>
|
<h1 className="text-2xl font-bold mb-6">Admin Panel</h1>
|
||||||
<div className="flex gap-8">
|
<div className="flex flex-col sm:flex-row gap-6 sm:gap-8">
|
||||||
<nav className="w-48 flex-shrink-0">
|
<nav className="flex-shrink-0 sm:w-48">
|
||||||
<ul className="space-y-1">
|
<ul className="flex sm:flex-col gap-1 overflow-x-auto sm:overflow-visible">
|
||||||
{navItems.map((item) => (
|
{navItems.map((item) => (
|
||||||
<li key={item.to}>
|
<li key={item.to} className="flex-shrink-0">
|
||||||
<NavLink
|
<NavLink
|
||||||
to={item.to}
|
to={item.to}
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
`block px-3 py-2 rounded-md text-sm font-medium ${
|
`block px-3 py-2 rounded-md text-sm font-medium whitespace-nowrap ${
|
||||||
isActive
|
isActive
|
||||||
? 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-200'
|
? 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-200'
|
||||||
: 'hover:bg-gray-100 dark:hover:bg-gray-700'
|
: 'hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import { type ReactNode } from 'react'
|
import { type ReactNode, useMemo, useState } from 'react'
|
||||||
|
|
||||||
export interface Column<T> {
|
export interface Column<T> {
|
||||||
header: string
|
header: string
|
||||||
accessor: (row: T) => ReactNode
|
accessor: (row: T) => ReactNode
|
||||||
className?: string
|
className?: string
|
||||||
|
sortKey?: (row: T) => string | number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SortDir = 'asc' | 'desc'
|
||||||
|
|
||||||
interface AdminTableProps<T> {
|
interface AdminTableProps<T> {
|
||||||
columns: Column<T>[]
|
columns: Column<T>[]
|
||||||
data: T[]
|
data: T[]
|
||||||
@@ -23,24 +26,42 @@ export function AdminTable<T>({
|
|||||||
onRowClick,
|
onRowClick,
|
||||||
keyFn,
|
keyFn,
|
||||||
}: AdminTableProps<T>) {
|
}: AdminTableProps<T>) {
|
||||||
|
const [sortCol, setSortCol] = useState<string | null>(null)
|
||||||
|
const [sortDir, setSortDir] = useState<SortDir>('asc')
|
||||||
|
|
||||||
|
const handleSort = (header: string) => {
|
||||||
|
if (sortCol === header) {
|
||||||
|
if (sortDir === 'asc') {
|
||||||
|
setSortDir('desc')
|
||||||
|
} else {
|
||||||
|
// Third click: clear sort
|
||||||
|
setSortCol(null)
|
||||||
|
setSortDir('asc')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setSortCol(header)
|
||||||
|
setSortDir('asc')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedData = useMemo(() => {
|
||||||
|
if (!sortCol) return data
|
||||||
|
const col = columns.find((c) => c.header === sortCol)
|
||||||
|
if (!col?.sortKey) return data
|
||||||
|
const key = col.sortKey
|
||||||
|
const sorted = [...data].sort((a, b) => {
|
||||||
|
const va = key(a)
|
||||||
|
const vb = key(b)
|
||||||
|
if (va < vb) return -1
|
||||||
|
if (va > vb) return 1
|
||||||
|
return 0
|
||||||
|
})
|
||||||
|
return sortDir === 'desc' ? sorted.reverse() : sorted
|
||||||
|
}, [data, sortCol, sortDir, columns])
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
<div className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||||
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">
|
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
<thead className="bg-gray-50 dark:bg-gray-800">
|
<thead className="bg-gray-50 dark:bg-gray-800">
|
||||||
<tr>
|
<tr>
|
||||||
@@ -55,7 +76,59 @@ export function AdminTable<T>({
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
|
<tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
{data.map((row) => (
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<tr key={i}>
|
||||||
|
{columns.map((col) => (
|
||||||
|
<td key={col.header} className={`px-4 py-3 ${col.className ?? ''}`}>
|
||||||
|
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-8 text-gray-500 dark:text-gray-400 border border-gray-200 dark:border-gray-700 rounded-lg">
|
||||||
|
{emptyMessage}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||||
|
<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) => {
|
||||||
|
const sortable = !!col.sortKey
|
||||||
|
const active = sortCol === col.header
|
||||||
|
return (
|
||||||
|
<th
|
||||||
|
key={col.header}
|
||||||
|
onClick={sortable ? () => handleSort(col.header) : undefined}
|
||||||
|
className={`px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider ${col.className ?? ''} ${sortable ? 'cursor-pointer select-none hover:text-gray-700 dark:hover:text-gray-200' : ''}`}
|
||||||
|
>
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
{col.header}
|
||||||
|
{sortable && active && (
|
||||||
|
<span className="text-blue-500">
|
||||||
|
{sortDir === 'asc' ? '\u2191' : '\u2193'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</th>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
{sortedData.map((row) => (
|
||||||
<tr
|
<tr
|
||||||
key={keyFn(row)}
|
key={keyFn(row)}
|
||||||
onClick={onRowClick ? () => onRowClick(row) : undefined}
|
onClick={onRowClick ? () => onRowClick(row) : undefined}
|
||||||
@@ -74,5 +147,6 @@ export function AdminTable<T>({
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
124
frontend/src/components/admin/EvolutionFormModal.tsx
Normal file
124
frontend/src/components/admin/EvolutionFormModal.tsx
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import { type FormEvent, useState } from 'react'
|
||||||
|
import { FormModal } from './FormModal'
|
||||||
|
import { PokemonSelector } from './PokemonSelector'
|
||||||
|
import type { EvolutionAdmin, CreateEvolutionInput, UpdateEvolutionInput } from '../../types'
|
||||||
|
|
||||||
|
interface EvolutionFormModalProps {
|
||||||
|
evolution?: EvolutionAdmin
|
||||||
|
onSubmit: (data: CreateEvolutionInput | UpdateEvolutionInput) => void
|
||||||
|
onClose: () => void
|
||||||
|
isSubmitting?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const TRIGGER_OPTIONS = ['level-up', 'trade', 'use-item', 'shed', 'other']
|
||||||
|
|
||||||
|
export function EvolutionFormModal({
|
||||||
|
evolution,
|
||||||
|
onSubmit,
|
||||||
|
onClose,
|
||||||
|
isSubmitting,
|
||||||
|
}: EvolutionFormModalProps) {
|
||||||
|
const [fromPokemonId, setFromPokemonId] = useState<number | null>(
|
||||||
|
evolution?.fromPokemonId ?? null,
|
||||||
|
)
|
||||||
|
const [toPokemonId, setToPokemonId] = useState<number | null>(
|
||||||
|
evolution?.toPokemonId ?? null,
|
||||||
|
)
|
||||||
|
const [trigger, setTrigger] = useState(evolution?.trigger ?? 'level-up')
|
||||||
|
const [minLevel, setMinLevel] = useState(String(evolution?.minLevel ?? ''))
|
||||||
|
const [item, setItem] = useState(evolution?.item ?? '')
|
||||||
|
const [heldItem, setHeldItem] = useState(evolution?.heldItem ?? '')
|
||||||
|
const [condition, setCondition] = useState(evolution?.condition ?? '')
|
||||||
|
|
||||||
|
const handleSubmit = (e: FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!fromPokemonId || !toPokemonId) return
|
||||||
|
onSubmit({
|
||||||
|
fromPokemonId,
|
||||||
|
toPokemonId,
|
||||||
|
trigger,
|
||||||
|
minLevel: minLevel ? Number(minLevel) : null,
|
||||||
|
item: item || null,
|
||||||
|
heldItem: heldItem || null,
|
||||||
|
condition: condition || null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormModal
|
||||||
|
title={evolution ? 'Edit Evolution' : 'Add Evolution'}
|
||||||
|
onClose={onClose}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
isSubmitting={isSubmitting}
|
||||||
|
>
|
||||||
|
<PokemonSelector
|
||||||
|
label="From Pokemon"
|
||||||
|
selectedId={fromPokemonId}
|
||||||
|
initialName={evolution?.fromPokemon.name}
|
||||||
|
onChange={setFromPokemonId}
|
||||||
|
/>
|
||||||
|
<PokemonSelector
|
||||||
|
label="To Pokemon"
|
||||||
|
selectedId={toPokemonId}
|
||||||
|
initialName={evolution?.toPokemon.name}
|
||||||
|
onChange={setToPokemonId}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Trigger</label>
|
||||||
|
<select
|
||||||
|
value={trigger}
|
||||||
|
onChange={(e) => setTrigger(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
|
||||||
|
>
|
||||||
|
{TRIGGER_OPTIONS.map((t) => (
|
||||||
|
<option key={t} value={t}>
|
||||||
|
{t}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Min Level</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={100}
|
||||||
|
value={minLevel}
|
||||||
|
onChange={(e) => setMinLevel(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">Item</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={item}
|
||||||
|
onChange={(e) => setItem(e.target.value)}
|
||||||
|
placeholder="e.g. thunder-stone"
|
||||||
|
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">Held Item</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={heldItem}
|
||||||
|
onChange={(e) => setHeldItem(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">Condition</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={condition}
|
||||||
|
onChange={(e) => setCondition(e.target.value)}
|
||||||
|
placeholder="e.g. high-happiness, daytime"
|
||||||
|
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</FormModal>
|
||||||
|
)
|
||||||
|
}
|
||||||
78
frontend/src/components/admin/PokemonSelector.tsx
Normal file
78
frontend/src/components/admin/PokemonSelector.tsx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { useState, useRef, useEffect } from 'react'
|
||||||
|
import { usePokemonList } from '../../hooks/useAdmin'
|
||||||
|
|
||||||
|
interface PokemonSelectorProps {
|
||||||
|
label: string
|
||||||
|
selectedId: number | null
|
||||||
|
initialName?: string
|
||||||
|
onChange: (id: number | null) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PokemonSelector({
|
||||||
|
label,
|
||||||
|
selectedId,
|
||||||
|
initialName,
|
||||||
|
onChange,
|
||||||
|
}: PokemonSelectorProps) {
|
||||||
|
const [search, setSearch] = useState(initialName ?? '')
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
|
const { data } = usePokemonList(search || undefined, 20, 0)
|
||||||
|
const pokemon = data?.items ?? []
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleClick(e: MouseEvent) {
|
||||||
|
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||||
|
setOpen(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', handleClick)
|
||||||
|
return () => document.removeEventListener('mousedown', handleClick)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className="relative">
|
||||||
|
<label className="block text-sm font-medium mb-1">{label}</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required={!selectedId}
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSearch(e.target.value)
|
||||||
|
setOpen(true)
|
||||||
|
if (!e.target.value) onChange(null)
|
||||||
|
}}
|
||||||
|
onFocus={() => setOpen(true)}
|
||||||
|
placeholder="Search pokemon..."
|
||||||
|
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
|
||||||
|
/>
|
||||||
|
{selectedId && (
|
||||||
|
<input type="hidden" name={label} value={selectedId} required />
|
||||||
|
)}
|
||||||
|
{open && pokemon.length > 0 && (
|
||||||
|
<ul className="absolute z-10 mt-1 w-full bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-md shadow-lg max-h-48 overflow-y-auto">
|
||||||
|
{pokemon.map((p) => (
|
||||||
|
<li
|
||||||
|
key={p.id}
|
||||||
|
onClick={() => {
|
||||||
|
onChange(p.id)
|
||||||
|
setSearch(p.name)
|
||||||
|
setOpen(false)
|
||||||
|
}}
|
||||||
|
className={`px-3 py-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700 text-sm flex items-center gap-2 ${
|
||||||
|
p.id === selectedId ? 'bg-blue-50 dark:bg-blue-900/30' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{p.spriteUrl && (
|
||||||
|
<img src={p.spriteUrl} alt="" className="w-6 h-6" />
|
||||||
|
)}
|
||||||
|
<span>
|
||||||
|
#{p.nationalDex} {p.name}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -7,3 +7,5 @@ export { RouteFormModal } from './RouteFormModal'
|
|||||||
export { PokemonFormModal } from './PokemonFormModal'
|
export { PokemonFormModal } from './PokemonFormModal'
|
||||||
export { BulkImportModal } from './BulkImportModal'
|
export { BulkImportModal } from './BulkImportModal'
|
||||||
export { RouteEncounterFormModal } from './RouteEncounterFormModal'
|
export { RouteEncounterFormModal } from './RouteEncounterFormModal'
|
||||||
|
export { EvolutionFormModal } from './EvolutionFormModal'
|
||||||
|
export { PokemonSelector } from './PokemonSelector'
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { toast } from 'sonner'
|
||||||
import * as adminApi from '../api/admin'
|
import * as adminApi from '../api/admin'
|
||||||
import type {
|
import type {
|
||||||
CreateGameInput,
|
CreateGameInput,
|
||||||
@@ -10,6 +11,8 @@ import type {
|
|||||||
UpdatePokemonInput,
|
UpdatePokemonInput,
|
||||||
CreateRouteEncounterInput,
|
CreateRouteEncounterInput,
|
||||||
UpdateRouteEncounterInput,
|
UpdateRouteEncounterInput,
|
||||||
|
CreateEvolutionInput,
|
||||||
|
UpdateEvolutionInput,
|
||||||
} from '../types'
|
} from '../types'
|
||||||
|
|
||||||
// --- Queries ---
|
// --- Queries ---
|
||||||
@@ -27,7 +30,11 @@ export function useCreateGame() {
|
|||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (data: CreateGameInput) => adminApi.createGame(data),
|
mutationFn: (data: CreateGameInput) => adminApi.createGame(data),
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['games'] }),
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['games'] })
|
||||||
|
toast.success('Game created')
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(`Failed to create game: ${err.message}`),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,7 +43,11 @@ export function useUpdateGame() {
|
|||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: ({ id, data }: { id: number; data: UpdateGameInput }) =>
|
mutationFn: ({ id, data }: { id: number; data: UpdateGameInput }) =>
|
||||||
adminApi.updateGame(id, data),
|
adminApi.updateGame(id, data),
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['games'] }),
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['games'] })
|
||||||
|
toast.success('Game updated')
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(`Failed to update game: ${err.message}`),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,7 +55,11 @@ export function useDeleteGame() {
|
|||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (id: number) => adminApi.deleteGame(id),
|
mutationFn: (id: number) => adminApi.deleteGame(id),
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['games'] }),
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['games'] })
|
||||||
|
toast.success('Game deleted')
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(`Failed to delete game: ${err.message}`),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,7 +72,9 @@ export function useCreateRoute(gameId: number) {
|
|||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ['games', gameId] })
|
qc.invalidateQueries({ queryKey: ['games', gameId] })
|
||||||
qc.invalidateQueries({ queryKey: ['games', gameId, 'routes'] })
|
qc.invalidateQueries({ queryKey: ['games', gameId, 'routes'] })
|
||||||
|
toast.success('Route created')
|
||||||
},
|
},
|
||||||
|
onError: (err) => toast.error(`Failed to create route: ${err.message}`),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,7 +86,9 @@ export function useUpdateRoute(gameId: number) {
|
|||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ['games', gameId] })
|
qc.invalidateQueries({ queryKey: ['games', gameId] })
|
||||||
qc.invalidateQueries({ queryKey: ['games', gameId, 'routes'] })
|
qc.invalidateQueries({ queryKey: ['games', gameId, 'routes'] })
|
||||||
|
toast.success('Route updated')
|
||||||
},
|
},
|
||||||
|
onError: (err) => toast.error(`Failed to update route: ${err.message}`),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,7 +99,9 @@ export function useDeleteRoute(gameId: number) {
|
|||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ['games', gameId] })
|
qc.invalidateQueries({ queryKey: ['games', gameId] })
|
||||||
qc.invalidateQueries({ queryKey: ['games', gameId, 'routes'] })
|
qc.invalidateQueries({ queryKey: ['games', gameId, 'routes'] })
|
||||||
|
toast.success('Route deleted')
|
||||||
},
|
},
|
||||||
|
onError: (err) => toast.error(`Failed to delete route: ${err.message}`),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,7 +112,9 @@ export function useReorderRoutes(gameId: number) {
|
|||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ['games', gameId] })
|
qc.invalidateQueries({ queryKey: ['games', gameId] })
|
||||||
qc.invalidateQueries({ queryKey: ['games', gameId, 'routes'] })
|
qc.invalidateQueries({ queryKey: ['games', gameId, 'routes'] })
|
||||||
|
toast.success('Routes reordered')
|
||||||
},
|
},
|
||||||
|
onError: (err) => toast.error(`Failed to reorder routes: ${err.message}`),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,7 +124,11 @@ export function useCreatePokemon() {
|
|||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (data: CreatePokemonInput) => adminApi.createPokemon(data),
|
mutationFn: (data: CreatePokemonInput) => adminApi.createPokemon(data),
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['pokemon'] }),
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['pokemon'] })
|
||||||
|
toast.success('Pokemon created')
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(`Failed to create pokemon: ${err.message}`),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,7 +137,11 @@ export function useUpdatePokemon() {
|
|||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: ({ id, data }: { id: number; data: UpdatePokemonInput }) =>
|
mutationFn: ({ id, data }: { id: number; data: UpdatePokemonInput }) =>
|
||||||
adminApi.updatePokemon(id, data),
|
adminApi.updatePokemon(id, data),
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['pokemon'] }),
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['pokemon'] })
|
||||||
|
toast.success('Pokemon updated')
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(`Failed to update pokemon: ${err.message}`),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,7 +149,11 @@ export function useDeletePokemon() {
|
|||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (id: number) => adminApi.deletePokemon(id),
|
mutationFn: (id: number) => adminApi.deletePokemon(id),
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['pokemon'] }),
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['pokemon'] })
|
||||||
|
toast.success('Pokemon deleted')
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(`Failed to delete pokemon: ${err.message}`),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,7 +162,57 @@ export function useBulkImportPokemon() {
|
|||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (items: Array<{ nationalDex: number; name: string; types: string[]; spriteUrl?: string | null }>) =>
|
mutationFn: (items: Array<{ nationalDex: number; name: string; types: string[]; spriteUrl?: string | null }>) =>
|
||||||
adminApi.bulkImportPokemon(items),
|
adminApi.bulkImportPokemon(items),
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['pokemon'] }),
|
onSuccess: (result) => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['pokemon'] })
|
||||||
|
toast.success(`Import complete: ${result.created} created, ${result.updated} updated`)
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(`Import failed: ${err.message}`),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Evolution Queries & Mutations ---
|
||||||
|
|
||||||
|
export function useEvolutionList(search?: string, limit = 50, offset = 0) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['evolutions', { search, limit, offset }],
|
||||||
|
queryFn: () => adminApi.listEvolutions(search, limit, offset),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateEvolution() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (data: CreateEvolutionInput) => adminApi.createEvolution(data),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['evolutions'] })
|
||||||
|
toast.success('Evolution created')
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(`Failed to create evolution: ${err.message}`),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateEvolution() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, data }: { id: number; data: UpdateEvolutionInput }) =>
|
||||||
|
adminApi.updateEvolution(id, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['evolutions'] })
|
||||||
|
toast.success('Evolution updated')
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(`Failed to update evolution: ${err.message}`),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteEvolution() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: number) => adminApi.deleteEvolution(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['evolutions'] })
|
||||||
|
toast.success('Evolution deleted')
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(`Failed to delete evolution: ${err.message}`),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,8 +223,11 @@ export function useAddRouteEncounter(routeId: number) {
|
|||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (data: CreateRouteEncounterInput) =>
|
mutationFn: (data: CreateRouteEncounterInput) =>
|
||||||
adminApi.addRouteEncounter(routeId, data),
|
adminApi.addRouteEncounter(routeId, data),
|
||||||
onSuccess: () =>
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ['routes', routeId, 'pokemon'] }),
|
qc.invalidateQueries({ queryKey: ['routes', routeId, 'pokemon'] })
|
||||||
|
toast.success('Encounter added')
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(`Failed to add encounter: ${err.message}`),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,8 +236,11 @@ export function useUpdateRouteEncounter(routeId: number) {
|
|||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: ({ encounterId, data }: { encounterId: number; data: UpdateRouteEncounterInput }) =>
|
mutationFn: ({ encounterId, data }: { encounterId: number; data: UpdateRouteEncounterInput }) =>
|
||||||
adminApi.updateRouteEncounter(routeId, encounterId, data),
|
adminApi.updateRouteEncounter(routeId, encounterId, data),
|
||||||
onSuccess: () =>
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ['routes', routeId, 'pokemon'] }),
|
qc.invalidateQueries({ queryKey: ['routes', routeId, 'pokemon'] })
|
||||||
|
toast.success('Encounter updated')
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(`Failed to update encounter: ${err.message}`),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,7 +249,10 @@ export function useRemoveRouteEncounter(routeId: number) {
|
|||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (encounterId: number) =>
|
mutationFn: (encounterId: number) =>
|
||||||
adminApi.removeRouteEncounter(routeId, encounterId),
|
adminApi.removeRouteEncounter(routeId, encounterId),
|
||||||
onSuccess: () =>
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ['routes', routeId, 'pokemon'] }),
|
qc.invalidateQueries({ queryKey: ['routes', routeId, 'pokemon'] })
|
||||||
|
toast.success('Encounter removed')
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(`Failed to remove encounter: ${err.message}`),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { StrictMode } from 'react'
|
|||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import { BrowserRouter } from 'react-router-dom'
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
import { Toaster } from 'sonner'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
import App from './App.tsx'
|
import App from './App.tsx'
|
||||||
|
|
||||||
@@ -19,6 +20,7 @@ createRoot(document.getElementById('root')!).render(
|
|||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<App />
|
<App />
|
||||||
|
<Toaster position="bottom-right" richColors />
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
|
|||||||
197
frontend/src/pages/admin/AdminEvolutions.tsx
Normal file
197
frontend/src/pages/admin/AdminEvolutions.tsx
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { AdminTable, type Column } from '../../components/admin/AdminTable'
|
||||||
|
import { EvolutionFormModal } from '../../components/admin/EvolutionFormModal'
|
||||||
|
import { DeleteConfirmModal } from '../../components/admin/DeleteConfirmModal'
|
||||||
|
import {
|
||||||
|
useEvolutionList,
|
||||||
|
useCreateEvolution,
|
||||||
|
useUpdateEvolution,
|
||||||
|
useDeleteEvolution,
|
||||||
|
} from '../../hooks/useAdmin'
|
||||||
|
import type { EvolutionAdmin, CreateEvolutionInput, UpdateEvolutionInput } from '../../types'
|
||||||
|
|
||||||
|
const PAGE_SIZE = 50
|
||||||
|
|
||||||
|
export function AdminEvolutions() {
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [page, setPage] = useState(0)
|
||||||
|
const offset = page * PAGE_SIZE
|
||||||
|
const { data, isLoading } = useEvolutionList(search || undefined, PAGE_SIZE, offset)
|
||||||
|
const evolutions = data?.items ?? []
|
||||||
|
const total = data?.total ?? 0
|
||||||
|
const totalPages = Math.ceil(total / PAGE_SIZE)
|
||||||
|
|
||||||
|
const createEvolution = useCreateEvolution()
|
||||||
|
const updateEvolution = useUpdateEvolution()
|
||||||
|
const deleteEvolution = useDeleteEvolution()
|
||||||
|
|
||||||
|
const [showCreate, setShowCreate] = useState(false)
|
||||||
|
const [editing, setEditing] = useState<EvolutionAdmin | null>(null)
|
||||||
|
const [deleting, setDeleting] = useState<EvolutionAdmin | null>(null)
|
||||||
|
|
||||||
|
const columns: Column<EvolutionAdmin>[] = [
|
||||||
|
{
|
||||||
|
header: 'From',
|
||||||
|
accessor: (e) => (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{e.fromPokemon.spriteUrl && (
|
||||||
|
<img src={e.fromPokemon.spriteUrl} alt="" className="w-6 h-6" />
|
||||||
|
)}
|
||||||
|
<span>{e.fromPokemon.name}</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'To',
|
||||||
|
accessor: (e) => (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{e.toPokemon.spriteUrl && (
|
||||||
|
<img src={e.toPokemon.spriteUrl} alt="" className="w-6 h-6" />
|
||||||
|
)}
|
||||||
|
<span>{e.toPokemon.name}</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ header: 'Trigger', accessor: (e) => e.trigger },
|
||||||
|
{ header: 'Level', accessor: (e) => e.minLevel ?? '-' },
|
||||||
|
{ header: 'Item', accessor: (e) => e.item ?? '-' },
|
||||||
|
{
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<h2 className="text-xl font-semibold">Evolutions</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 Evolution
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4 flex items-center gap-4">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSearch(e.target.value)
|
||||||
|
setPage(0)
|
||||||
|
}}
|
||||||
|
placeholder="Search by pokemon name, trigger, or item..."
|
||||||
|
className="w-full max-w-sm px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{total} evolutions
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AdminTable
|
||||||
|
columns={columns}
|
||||||
|
data={evolutions}
|
||||||
|
isLoading={isLoading}
|
||||||
|
emptyMessage="No evolutions found."
|
||||||
|
keyFn={(e) => e.id}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="mt-4 flex items-center justify-between">
|
||||||
|
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Showing {offset + 1}-{Math.min(offset + PAGE_SIZE, total)} of {total}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setPage(0)}
|
||||||
|
disabled={page === 0}
|
||||||
|
className="px-3 py-1 text-sm rounded border border-gray-300 dark:border-gray-600 disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||||
|
>
|
||||||
|
First
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||||
|
disabled={page === 0}
|
||||||
|
className="px-3 py-1 text-sm rounded border border-gray-300 dark:border-gray-600 disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||||
|
>
|
||||||
|
Prev
|
||||||
|
</button>
|
||||||
|
<span className="text-sm text-gray-600 dark:text-gray-300 px-2">
|
||||||
|
Page {page + 1} of {totalPages}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
|
||||||
|
disabled={page >= totalPages - 1}
|
||||||
|
className="px-3 py-1 text-sm rounded border border-gray-300 dark:border-gray-600 disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setPage(totalPages - 1)}
|
||||||
|
disabled={page >= totalPages - 1}
|
||||||
|
className="px-3 py-1 text-sm rounded border border-gray-300 dark:border-gray-600 disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||||
|
>
|
||||||
|
Last
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showCreate && (
|
||||||
|
<EvolutionFormModal
|
||||||
|
onSubmit={(data) =>
|
||||||
|
createEvolution.mutate(data as CreateEvolutionInput, {
|
||||||
|
onSuccess: () => setShowCreate(false),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
onClose={() => setShowCreate(false)}
|
||||||
|
isSubmitting={createEvolution.isPending}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{editing && (
|
||||||
|
<EvolutionFormModal
|
||||||
|
evolution={editing}
|
||||||
|
onSubmit={(data) =>
|
||||||
|
updateEvolution.mutate(
|
||||||
|
{ id: editing.id, data: data as UpdateEvolutionInput },
|
||||||
|
{ onSuccess: () => setEditing(null) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onClose={() => setEditing(null)}
|
||||||
|
isSubmitting={updateEvolution.isPending}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{deleting && (
|
||||||
|
<DeleteConfirmModal
|
||||||
|
title={`Delete evolution?`}
|
||||||
|
message={`This will permanently delete the evolution from ${deleting.fromPokemon.name} to ${deleting.toPokemon.name}.`}
|
||||||
|
onConfirm={() =>
|
||||||
|
deleteEvolution.mutate(deleting.id, {
|
||||||
|
onSuccess: () => setDeleting(null),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
onCancel={() => setDeleting(null)}
|
||||||
|
isDeleting={deleteEvolution.isPending}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,21 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useParams, useNavigate, Link } from 'react-router-dom'
|
import { useParams, useNavigate, Link } from 'react-router-dom'
|
||||||
import { AdminTable, type Column } from '../../components/admin/AdminTable'
|
import {
|
||||||
|
DndContext,
|
||||||
|
closestCenter,
|
||||||
|
KeyboardSensor,
|
||||||
|
PointerSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
type DragEndEvent,
|
||||||
|
} from '@dnd-kit/core'
|
||||||
|
import {
|
||||||
|
SortableContext,
|
||||||
|
sortableKeyboardCoordinates,
|
||||||
|
useSortable,
|
||||||
|
verticalListSortingStrategy,
|
||||||
|
} from '@dnd-kit/sortable'
|
||||||
|
import { CSS } from '@dnd-kit/utilities'
|
||||||
import { RouteFormModal } from '../../components/admin/RouteFormModal'
|
import { RouteFormModal } from '../../components/admin/RouteFormModal'
|
||||||
import { DeleteConfirmModal } from '../../components/admin/DeleteConfirmModal'
|
import { DeleteConfirmModal } from '../../components/admin/DeleteConfirmModal'
|
||||||
import { useGame } from '../../hooks/useGames'
|
import { useGame } from '../../hooks/useGames'
|
||||||
@@ -12,6 +27,72 @@ import {
|
|||||||
} from '../../hooks/useAdmin'
|
} from '../../hooks/useAdmin'
|
||||||
import type { Route as GameRoute, CreateRouteInput, UpdateRouteInput } from '../../types'
|
import type { Route as GameRoute, CreateRouteInput, UpdateRouteInput } from '../../types'
|
||||||
|
|
||||||
|
function SortableRouteRow({
|
||||||
|
route,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
route: GameRoute
|
||||||
|
onEdit: (r: GameRoute) => void
|
||||||
|
onDelete: (r: GameRoute) => void
|
||||||
|
onClick: (r: GameRoute) => void
|
||||||
|
}) {
|
||||||
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
|
||||||
|
useSortable({ id: route.id })
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
className={`${isDragging ? 'opacity-50 bg-blue-50 dark:bg-blue-900/20' : ''} hover:bg-gray-50 dark:hover:bg-gray-800 cursor-pointer`}
|
||||||
|
onClick={() => onClick(route)}
|
||||||
|
>
|
||||||
|
<td className="px-4 py-3 text-sm w-12">
|
||||||
|
<button
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="cursor-grab active:cursor-grabbing text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 touch-none"
|
||||||
|
title="Drag to reorder"
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||||
|
<circle cx="5" cy="3" r="1.5" />
|
||||||
|
<circle cx="11" cy="3" r="1.5" />
|
||||||
|
<circle cx="5" cy="8" r="1.5" />
|
||||||
|
<circle cx="11" cy="8" r="1.5" />
|
||||||
|
<circle cx="5" cy="13" r="1.5" />
|
||||||
|
<circle cx="11" cy="13" r="1.5" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm whitespace-nowrap w-16">{route.order}</td>
|
||||||
|
<td className="px-4 py-3 text-sm whitespace-nowrap">{route.name}</td>
|
||||||
|
<td className="px-4 py-3 text-sm whitespace-nowrap w-32">
|
||||||
|
<div className="flex gap-2" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<button
|
||||||
|
onClick={() => onEdit(route)}
|
||||||
|
className="text-blue-600 hover:text-blue-800 dark:text-blue-400 text-sm"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onDelete(route)}
|
||||||
|
className="text-red-600 hover:text-red-800 dark:text-red-400 text-sm"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function AdminGameDetail() {
|
export function AdminGameDetail() {
|
||||||
const { gameId } = useParams<{ gameId: string }>()
|
const { gameId } = useParams<{ gameId: string }>()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
@@ -27,69 +108,36 @@ export function AdminGameDetail() {
|
|||||||
const [editing, setEditing] = useState<GameRoute | null>(null)
|
const [editing, setEditing] = useState<GameRoute | null>(null)
|
||||||
const [deleting, setDeleting] = useState<GameRoute | null>(null)
|
const [deleting, setDeleting] = useState<GameRoute | null>(null)
|
||||||
|
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||||
|
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
||||||
|
)
|
||||||
|
|
||||||
if (isLoading) return <div className="py-8 text-center text-gray-500">Loading...</div>
|
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>
|
if (!game) return <div className="py-8 text-center text-gray-500">Game not found</div>
|
||||||
|
|
||||||
const routes = game.routes ?? []
|
const routes = game.routes ?? []
|
||||||
|
|
||||||
const moveRoute = (route: GameRoute, direction: 'up' | 'down') => {
|
const handleDragEnd = (event: DragEndEvent) => {
|
||||||
const idx = routes.findIndex((r) => r.id === route.id)
|
const { active, over } = event
|
||||||
if (direction === 'up' && idx <= 0) return
|
if (!over || active.id === over.id) return
|
||||||
if (direction === 'down' && idx >= routes.length - 1) return
|
|
||||||
|
|
||||||
const swapIdx = direction === 'up' ? idx - 1 : idx + 1
|
const oldIndex = routes.findIndex((r) => r.id === active.id)
|
||||||
const newRoutes = routes.map((r, i) => {
|
const newIndex = routes.findIndex((r) => r.id === over.id)
|
||||||
if (i === idx) return { id: r.id, order: routes[swapIdx].order }
|
if (oldIndex === -1 || newIndex === -1) return
|
||||||
if (i === swapIdx) return { id: r.id, order: routes[idx].order }
|
|
||||||
return { id: r.id, order: r.order }
|
// Build new order assignments based on rearranged positions
|
||||||
})
|
const reordered = [...routes]
|
||||||
reorderRoutes.mutate(newRoutes)
|
const [moved] = reordered.splice(oldIndex, 1)
|
||||||
|
reordered.splice(newIndex, 0, moved)
|
||||||
|
|
||||||
|
const newOrders = reordered.map((r, i) => ({
|
||||||
|
id: r.id,
|
||||||
|
order: i + 1,
|
||||||
|
}))
|
||||||
|
reorderRoutes.mutate(newOrders)
|
||||||
}
|
}
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<nav className="text-sm mb-4 text-gray-500 dark:text-gray-400">
|
<nav className="text-sm mb-4 text-gray-500 dark:text-gray-400">
|
||||||
@@ -118,13 +166,54 @@ export function AdminGameDetail() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AdminTable
|
{routes.length === 0 ? (
|
||||||
columns={columns}
|
<div className="text-center py-8 text-gray-500 dark:text-gray-400 border border-gray-200 dark:border-gray-700 rounded-lg">
|
||||||
data={routes}
|
No routes yet. Add one to get started.
|
||||||
emptyMessage="No routes yet. Add one to get started."
|
</div>
|
||||||
onRowClick={(r) => navigate(`/admin/games/${id}/routes/${r.id}`)}
|
) : (
|
||||||
keyFn={(r) => r.id}
|
<div className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||||
|
<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>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider w-12" />
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider w-16">
|
||||||
|
Order
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
Name
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider w-32">
|
||||||
|
Actions
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
collisionDetection={closestCenter}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
>
|
||||||
|
<SortableContext
|
||||||
|
items={routes.map((r) => r.id)}
|
||||||
|
strategy={verticalListSortingStrategy}
|
||||||
|
>
|
||||||
|
<tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
{routes.map((route) => (
|
||||||
|
<SortableRouteRow
|
||||||
|
key={route.id}
|
||||||
|
route={route}
|
||||||
|
onEdit={setEditing}
|
||||||
|
onDelete={setDeleting}
|
||||||
|
onClick={(r) => navigate(`/admin/games/${id}/routes/${r.id}`)}
|
||||||
/>
|
/>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{showCreate && (
|
{showCreate && (
|
||||||
<RouteFormModal
|
<RouteFormModal
|
||||||
|
|||||||
@@ -21,9 +21,9 @@ export function AdminGames() {
|
|||||||
const columns: Column<Game>[] = [
|
const columns: Column<Game>[] = [
|
||||||
{ header: 'Name', accessor: (g) => g.name },
|
{ header: 'Name', accessor: (g) => g.name },
|
||||||
{ header: 'Slug', accessor: (g) => g.slug },
|
{ header: 'Slug', accessor: (g) => g.slug },
|
||||||
{ header: 'Region', accessor: (g) => g.region },
|
{ header: 'Region', accessor: (g) => g.region, sortKey: (g) => g.region },
|
||||||
{ header: 'Gen', accessor: (g) => g.generation },
|
{ header: 'Gen', accessor: (g) => g.generation, sortKey: (g) => g.generation },
|
||||||
{ header: 'Year', accessor: (g) => g.releaseYear ?? '-' },
|
{ header: 'Year', accessor: (g) => g.releaseYear ?? '-', sortKey: (g) => g.releaseYear ?? 0 },
|
||||||
{
|
{
|
||||||
header: 'Actions',
|
header: 'Actions',
|
||||||
accessor: (g) => (
|
accessor: (g) => (
|
||||||
|
|||||||
@@ -2,3 +2,4 @@ export { AdminGames } from './AdminGames'
|
|||||||
export { AdminGameDetail } from './AdminGameDetail'
|
export { AdminGameDetail } from './AdminGameDetail'
|
||||||
export { AdminPokemon } from './AdminPokemon'
|
export { AdminPokemon } from './AdminPokemon'
|
||||||
export { AdminRouteDetail } from './AdminRouteDetail'
|
export { AdminRouteDetail } from './AdminRouteDetail'
|
||||||
|
export { AdminEvolutions } from './AdminEvolutions'
|
||||||
|
|||||||
@@ -72,3 +72,43 @@ export interface UpdateRouteEncounterInput {
|
|||||||
minLevel?: number
|
minLevel?: number
|
||||||
maxLevel?: number
|
maxLevel?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface EvolutionAdmin {
|
||||||
|
id: number
|
||||||
|
fromPokemonId: number
|
||||||
|
toPokemonId: number
|
||||||
|
fromPokemon: import('./game').Pokemon
|
||||||
|
toPokemon: import('./game').Pokemon
|
||||||
|
trigger: string
|
||||||
|
minLevel: number | null
|
||||||
|
item: string | null
|
||||||
|
heldItem: string | null
|
||||||
|
condition: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginatedEvolutions {
|
||||||
|
items: EvolutionAdmin[]
|
||||||
|
total: number
|
||||||
|
limit: number
|
||||||
|
offset: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateEvolutionInput {
|
||||||
|
fromPokemonId: number
|
||||||
|
toPokemonId: number
|
||||||
|
trigger: string
|
||||||
|
minLevel?: number | null
|
||||||
|
item?: string | null
|
||||||
|
heldItem?: string | null
|
||||||
|
condition?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateEvolutionInput {
|
||||||
|
fromPokemonId?: number
|
||||||
|
toPokemonId?: number
|
||||||
|
trigger?: string
|
||||||
|
minLevel?: number | null
|
||||||
|
item?: string | null
|
||||||
|
heldItem?: string | null
|
||||||
|
condition?: string | null
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user