diff --git a/.beans/nuzlocke-tracker-6kux--admin-panel-tabs-for-routesbosses-boss-export.md b/.beans/nuzlocke-tracker-6kux--admin-panel-tabs-for-routesbosses-boss-export.md new file mode 100644 index 0000000..c9a6d8d --- /dev/null +++ b/.beans/nuzlocke-tracker-6kux--admin-panel-tabs-for-routesbosses-boss-export.md @@ -0,0 +1,11 @@ +--- +# nuzlocke-tracker-6kux +title: 'Admin Panel: Tabs for Routes/Bosses + Boss Export' +status: completed +type: feature +priority: normal +created_at: 2026-02-08T10:49:47Z +updated_at: 2026-02-08T10:51:25Z +--- + +Add tabbed UI to AdminGameDetail (Routes/Bosses tabs) and boss battle export endpoint \ No newline at end of file diff --git a/backend/src/app/api/export.py b/backend/src/app/api/export.py index fb06a00..cdce3aa 100644 --- a/backend/src/app/api/export.py +++ b/backend/src/app/api/export.py @@ -6,6 +6,8 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from app.core.database import get_session +from app.models.boss_battle import BossBattle +from app.models.boss_pokemon import BossPokemon from app.models.evolution import Evolution from app.models.game import Game from app.models.pokemon import Pokemon @@ -46,10 +48,10 @@ async def export_game_routes( if not game: raise HTTPException(status_code=404, detail="Game not found") - # Load all routes for this game with encounters and pokemon + # Load all routes for this game's version group with encounters and pokemon result = await session.execute( select(Route) - .where(Route.game_id == game_id) + .where(Route.version_group_id == game.version_group_id) .options( selectinload(Route.route_encounters).selectinload(RouteEncounter.pokemon), ) @@ -65,6 +67,10 @@ async def export_game_routes( children_by_parent.setdefault(r.parent_route_id, []).append(r) def format_encounters(route: Route) -> list[dict]: + # Filter route_encounters to this specific game + game_encounters = [ + enc for enc in route.route_encounters if enc.game_id == game_id + ] return [ { "pokeapi_id": enc.pokemon.pokeapi_id, @@ -74,7 +80,7 @@ async def export_game_routes( "min_level": enc.min_level, "max_level": enc.max_level, } - for enc in sorted(route.route_encounters, key=lambda e: -e.encounter_rate) + for enc in sorted(game_encounters, key=lambda e: -e.encounter_rate) ] def format_route(route: Route) -> dict: @@ -106,6 +112,53 @@ async def export_game_routes( } +@router.get("/games/{game_id}/bosses") +async def export_game_bosses( + game_id: int, + session: AsyncSession = Depends(get_session), +): + """Export boss battles for a game in seed JSON format.""" + game = await session.get(Game, game_id) + if not game: + raise HTTPException(status_code=404, detail="Game not found") + + result = await session.execute( + select(BossBattle) + .where(BossBattle.version_group_id == game.version_group_id) + .options( + selectinload(BossBattle.pokemon).selectinload(BossPokemon.pokemon), + ) + .order_by(BossBattle.order) + ) + bosses = result.scalars().all() + + return { + "filename": f"{game.slug}-bosses.json", + "data": [ + { + "name": b.name, + "boss_type": b.boss_type, + "badge_name": b.badge_name, + "badge_image_url": b.badge_image_url, + "level_cap": b.level_cap, + "order": b.order, + "location": b.location, + "sprite_url": b.sprite_url, + "pokemon": [ + { + "pokeapi_id": bp.pokemon.pokeapi_id, + "pokemon_name": bp.pokemon.name, + "level": bp.level, + "order": bp.order, + } + for bp in sorted(b.pokemon, key=lambda p: p.order) + ], + } + for b in bosses + ], + } + + @router.get("/pokemon") async def export_pokemon(session: AsyncSession = Depends(get_session)): """Export all pokemon in seed JSON format.""" diff --git a/frontend/src/api/admin.ts b/frontend/src/api/admin.ts index 0ed7570..82f7291 100644 --- a/frontend/src/api/admin.ts +++ b/frontend/src/api/admin.ts @@ -94,6 +94,9 @@ export const exportGames = () => export const exportGameRoutes = (gameId: number) => api.get<{ filename: string; data: unknown }>(`/export/games/${gameId}/routes`) +export const exportGameBosses = (gameId: number) => + api.get<{ filename: string; data: unknown }>(`/export/games/${gameId}/bosses`) + export const exportPokemon = () => api.get[]>('/export/pokemon') diff --git a/frontend/src/pages/admin/AdminGameDetail.tsx b/frontend/src/pages/admin/AdminGameDetail.tsx index a939da2..6cd115f 100644 --- a/frontend/src/pages/admin/AdminGameDetail.tsx +++ b/frontend/src/pages/admin/AdminGameDetail.tsx @@ -32,7 +32,7 @@ import { useDeleteBossBattle, useSetBossTeam, } from '../../hooks/useAdmin' -import { exportGameRoutes } from '../../api/admin' +import { exportGameRoutes, exportGameBosses } from '../../api/admin' import { downloadJson } from '../../utils/download' import type { Route as GameRoute, CreateRouteInput, UpdateRouteInput, BossBattle } from '../../types' import type { CreateBossBattleInput, UpdateBossBattleInput } from '../../types/admin' @@ -118,6 +118,7 @@ export function AdminGameDetail() { const updateBoss = useUpdateBossBattle(id) const deleteBoss = useDeleteBossBattle(id) + const [tab, setTab] = useState<'routes' | 'bosses'>('routes') const [showCreate, setShowCreate] = useState(false) const [editing, setEditing] = useState(null) const [deleting, setDeleting] = useState(null) @@ -174,203 +175,236 @@ export function AdminGameDetail() {

-
-

Routes ({routes.length})

-
- - -
+
+ +
- {routes.length === 0 ? ( -
- No routes yet. Add one to get started. -
- ) : ( -
-
- - - - - - - - - - r.id)} - strategy={verticalListSortingStrategy} - > + {tab === 'routes' && ( + <> +
+ + +
+ + {routes.length === 0 ? ( +
+ No routes yet. Add one to get started. +
+ ) : ( +
+
+
- - Order - - Name - - Actions -
+ + + + + + + + + r.id)} + strategy={verticalListSortingStrategy} + > + + {routes.map((route) => ( + navigate(`/admin/games/${id}/routes/${r.id}`)} + /> + ))} + + + +
+ + Order + + Name + + Actions +
+
+
+ )} + + {showCreate && ( + 0 ? Math.max(...routes.map((r) => r.order)) + 1 : 1} + onSubmit={(data) => + createRoute.mutate(data as CreateRouteInput, { + onSuccess: () => setShowCreate(false), + }) + } + onClose={() => setShowCreate(false)} + isSubmitting={createRoute.isPending} + /> + )} + + {editing && ( + + updateRoute.mutate( + { routeId: editing.id, data: data as UpdateRouteInput }, + { onSuccess: () => setEditing(null) }, + ) + } + onClose={() => setEditing(null)} + isSubmitting={updateRoute.isPending} + /> + )} + + {deleting && ( + + deleteRoute.mutate(deleting.id, { + onSuccess: () => setDeleting(null), + }) + } + onCancel={() => setDeleting(null)} + isDeleting={deleteRoute.isPending} + /> + )} + + )} + + {tab === 'bosses' && ( + <> +
+ + +
+ + {!bosses || bosses.length === 0 ? ( +
+ No boss battles yet. Add one to get started. +
+ ) : ( +
+
+ + + + + + + + + + + + - {routes.map((route) => ( - navigate(`/admin/games/${id}/routes/${r.id}`)} - /> + {bosses.map((boss) => ( + + + + + + + + + ))} - - -
+ Order + + Name + + Type + + Location + + Lv Cap + + Team + + Actions +
{boss.order}{boss.name} + {boss.bossType.replace('_', ' ')} + {boss.location}{boss.levelCap}{boss.pokemon.length} +
+ + + +
+
-
-
- )} - - {showCreate && ( - 0 ? Math.max(...routes.map((r) => r.order)) + 1 : 1} - onSubmit={(data) => - createRoute.mutate(data as CreateRouteInput, { - onSuccess: () => setShowCreate(false), - }) - } - onClose={() => setShowCreate(false)} - isSubmitting={createRoute.isPending} - /> - )} - - {editing && ( - - updateRoute.mutate( - { routeId: editing.id, data: data as UpdateRouteInput }, - { onSuccess: () => setEditing(null) }, - ) - } - onClose={() => setEditing(null)} - isSubmitting={updateRoute.isPending} - /> - )} - - {deleting && ( - - deleteRoute.mutate(deleting.id, { - onSuccess: () => setDeleting(null), - }) - } - onCancel={() => setDeleting(null)} - isDeleting={deleteRoute.isPending} - /> - )} - - {/* Boss Battles Section */} -
-
-

Boss Battles ({bosses?.length ?? 0})

- -
- - {!bosses || bosses.length === 0 ? ( -
- No boss battles yet. Add one to get started. -
- ) : ( -
-
- - - - - - - - - - - - - - {bosses.map((boss) => ( - - - - - - - - - - ))} - -
- Order - - Name - - Type - - Location - - Lv Cap - - Team - - Actions -
{boss.order}{boss.name} - {boss.bossType.replace('_', ' ')} - {boss.location}{boss.levelCap}{boss.pokemon.length} -
- - - -
-
+ +
-
- )} -
+ )} + + )} {/* Boss Battle Modals */} {showCreateBoss && (