diff --git a/.beans/nuzlocke-tracker-pbzd--add-export-to-all-admin-panel-screens.md b/.beans/nuzlocke-tracker-pbzd--add-export-to-all-admin-panel-screens.md index 40d9887..91b367c 100644 --- a/.beans/nuzlocke-tracker-pbzd--add-export-to-all-admin-panel-screens.md +++ b/.beans/nuzlocke-tracker-pbzd--add-export-to-all-admin-panel-screens.md @@ -1,10 +1,11 @@ --- # nuzlocke-tracker-pbzd title: Add export to all admin panel screens -status: todo +status: completed type: feature +priority: normal created_at: 2026-02-07T21:08:27Z -updated_at: 2026-02-07T21:08:27Z +updated_at: 2026-02-08T09:49:09Z --- Add an export button to all screens in the admin panel so that data edited in the UI can be exported and added back to the seed data files. This allows editing game data, routes, and encounters through the admin UI and then persisting those changes to the JSON seed files. \ No newline at end of file diff --git a/backend/src/app/api/export.py b/backend/src/app/api/export.py new file mode 100644 index 0000000..fb06a00 --- /dev/null +++ b/backend/src/app/api/export.py @@ -0,0 +1,152 @@ +"""Export endpoints that return data in seed JSON format.""" + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.core.database import get_session +from app.models.evolution import Evolution +from app.models.game import Game +from app.models.pokemon import Pokemon +from app.models.route import Route +from app.models.route_encounter import RouteEncounter + +router = APIRouter() + + +@router.get("/games") +async def export_games(session: AsyncSession = Depends(get_session)): + """Export all games in seed JSON format.""" + result = await session.execute( + select(Game).order_by(Game.name) + ) + games = result.scalars().all() + return [ + { + "name": g.name, + "slug": g.slug, + "generation": g.generation, + "region": g.region, + "release_year": g.release_year, + "color": g.color, + } + for g in games + ] + + +@router.get("/games/{game_id}/routes") +async def export_game_routes( + game_id: int, + session: AsyncSession = Depends(get_session), +): + """Export routes and encounters for a game in seed JSON format.""" + # Verify game exists + game = await session.get(Game, game_id) + if not game: + raise HTTPException(status_code=404, detail="Game not found") + + # Load all routes for this game with encounters and pokemon + result = await session.execute( + select(Route) + .where(Route.game_id == game_id) + .options( + selectinload(Route.route_encounters).selectinload(RouteEncounter.pokemon), + ) + .order_by(Route.order) + ) + routes = result.scalars().all() + + # Build parent-child mapping + parent_routes = [r for r in routes if r.parent_route_id is None] + children_by_parent: dict[int, list[Route]] = {} + for r in routes: + if r.parent_route_id is not None: + children_by_parent.setdefault(r.parent_route_id, []).append(r) + + def format_encounters(route: Route) -> list[dict]: + return [ + { + "pokeapi_id": enc.pokemon.pokeapi_id, + "pokemon_name": enc.pokemon.name, + "method": enc.encounter_method, + "encounter_rate": enc.encounter_rate, + "min_level": enc.min_level, + "max_level": enc.max_level, + } + for enc in sorted(route.route_encounters, key=lambda e: -e.encounter_rate) + ] + + def format_route(route: Route) -> dict: + data: dict = { + "name": route.name, + "order": route.order, + "encounters": format_encounters(route), + } + children = children_by_parent.get(route.id, []) + if children: + data["children"] = [ + format_child(c) for c in sorted(children, key=lambda r: r.order) + ] + return data + + def format_child(route: Route) -> dict: + data: dict = { + "name": route.name, + "order": route.order, + "encounters": format_encounters(route), + } + if route.pinwheel_zone is not None: + data["pinwheel_zone"] = route.pinwheel_zone + return data + + return { + "filename": f"{game.slug}.json", + "data": [format_route(r) for r in parent_routes], + } + + +@router.get("/pokemon") +async def export_pokemon(session: AsyncSession = Depends(get_session)): + """Export all pokemon in seed JSON format.""" + result = await session.execute( + select(Pokemon).order_by(Pokemon.pokeapi_id) + ) + pokemon_list = result.scalars().all() + return [ + { + "pokeapi_id": p.pokeapi_id, + "national_dex": p.national_dex, + "name": p.name, + "types": p.types, + "sprite_url": p.sprite_url, + } + for p in pokemon_list + ] + + +@router.get("/evolutions") +async def export_evolutions(session: AsyncSession = Depends(get_session)): + """Export all evolutions in seed JSON format.""" + result = await session.execute( + select(Evolution) + .options( + selectinload(Evolution.from_pokemon), + selectinload(Evolution.to_pokemon), + ) + .order_by(Evolution.id) + ) + evolutions = result.scalars().all() + return [ + { + "from_pokeapi_id": e.from_pokemon.pokeapi_id, + "to_pokeapi_id": e.to_pokemon.pokeapi_id, + "trigger": e.trigger, + "min_level": e.min_level, + "item": e.item, + "held_item": e.held_item, + "condition": e.condition, + "region": e.region, + } + for e in evolutions + ] diff --git a/backend/src/app/api/routes.py b/backend/src/app/api/routes.py index 1ee1034..da8b9ee 100644 --- a/backend/src/app/api/routes.py +++ b/backend/src/app/api/routes.py @@ -1,6 +1,6 @@ from fastapi import APIRouter -from app.api import encounters, evolutions, games, health, pokemon, runs, stats +from app.api import encounters, evolutions, export, games, health, pokemon, runs, stats api_router = APIRouter() api_router.include_router(health.router) @@ -10,3 +10,4 @@ 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"]) api_router.include_router(stats.router, prefix="/stats", tags=["stats"]) +api_router.include_router(export.router, prefix="/export", tags=["export"]) diff --git a/frontend/src/api/admin.ts b/frontend/src/api/admin.ts index 3099b94..739ac16 100644 --- a/frontend/src/api/admin.ts +++ b/frontend/src/api/admin.ts @@ -83,6 +83,19 @@ export const updateEvolution = (id: number, data: UpdateEvolutionInput) => export const deleteEvolution = (id: number) => api.del(`/evolutions/${id}`) +// Export +export const exportGames = () => + api.get[]>('/export/games') + +export const exportGameRoutes = (gameId: number) => + api.get<{ filename: string; data: unknown }>(`/export/games/${gameId}/routes`) + +export const exportPokemon = () => + api.get[]>('/export/pokemon') + +export const exportEvolutions = () => + api.get[]>('/export/evolutions') + // Route Encounters export const addRouteEncounter = (routeId: number, data: CreateRouteEncounterInput) => api.post(`/routes/${routeId}/pokemon`, data) diff --git a/frontend/src/pages/admin/AdminEvolutions.tsx b/frontend/src/pages/admin/AdminEvolutions.tsx index aef45ee..416a7e3 100644 --- a/frontend/src/pages/admin/AdminEvolutions.tsx +++ b/frontend/src/pages/admin/AdminEvolutions.tsx @@ -8,6 +8,8 @@ import { useUpdateEvolution, useDeleteEvolution, } from '../../hooks/useAdmin' +import { exportEvolutions } from '../../api/admin' +import { downloadJson } from '../../utils/download' import type { EvolutionAdmin, CreateEvolutionInput, UpdateEvolutionInput } from '../../types' const PAGE_SIZE = 50 @@ -80,12 +82,23 @@ export function AdminEvolutions() {

Evolutions

- +
+ + +
diff --git a/frontend/src/pages/admin/AdminGameDetail.tsx b/frontend/src/pages/admin/AdminGameDetail.tsx index e8e3a00..5c901bb 100644 --- a/frontend/src/pages/admin/AdminGameDetail.tsx +++ b/frontend/src/pages/admin/AdminGameDetail.tsx @@ -1,5 +1,4 @@ import { useState } from 'react' -import { toast } from 'sonner' import { useParams, useNavigate, Link } from 'react-router-dom' import { DndContext, @@ -26,6 +25,8 @@ import { useDeleteRoute, useReorderRoutes, } from '../../hooks/useAdmin' +import { exportGameRoutes } from '../../api/admin' +import { downloadJson } from '../../utils/download' import type { Route as GameRoute, CreateRouteInput, UpdateRouteInput } from '../../types' function SortableRouteRow({ @@ -161,14 +162,13 @@ export function AdminGameDetail() {

Routes ({routes.length})

+
+ + +

Pokemon

+