Add export buttons to all admin panel screens

Backend export endpoints return DB data in seed JSON format
(games, routes+encounters, pokemon, evolutions). Frontend
downloads the JSON via new Export buttons on each admin page.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-08 10:50:14 +01:00
parent 8fbf658a27
commit 5cdcd149b6
9 changed files with 236 additions and 21 deletions

View File

@@ -1,10 +1,11 @@
--- ---
# nuzlocke-tracker-pbzd # nuzlocke-tracker-pbzd
title: Add export to all admin panel screens title: Add export to all admin panel screens
status: todo status: completed
type: feature type: feature
priority: normal
created_at: 2026-02-07T21:08:27Z 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. 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.

View File

@@ -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
]

View File

@@ -1,6 +1,6 @@
from fastapi import APIRouter 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 = APIRouter()
api_router.include_router(health.router) 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(runs.router, prefix="/runs", tags=["runs"])
api_router.include_router(encounters.router, tags=["encounters"]) api_router.include_router(encounters.router, tags=["encounters"])
api_router.include_router(stats.router, prefix="/stats", tags=["stats"]) api_router.include_router(stats.router, prefix="/stats", tags=["stats"])
api_router.include_router(export.router, prefix="/export", tags=["export"])

View File

@@ -83,6 +83,19 @@ export const updateEvolution = (id: number, data: UpdateEvolutionInput) =>
export const deleteEvolution = (id: number) => export const deleteEvolution = (id: number) =>
api.del(`/evolutions/${id}`) api.del(`/evolutions/${id}`)
// Export
export const exportGames = () =>
api.get<Record<string, unknown>[]>('/export/games')
export const exportGameRoutes = (gameId: number) =>
api.get<{ filename: string; data: unknown }>(`/export/games/${gameId}/routes`)
export const exportPokemon = () =>
api.get<Record<string, unknown>[]>('/export/pokemon')
export const exportEvolutions = () =>
api.get<Record<string, unknown>[]>('/export/evolutions')
// 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)

View File

@@ -8,6 +8,8 @@ import {
useUpdateEvolution, useUpdateEvolution,
useDeleteEvolution, useDeleteEvolution,
} from '../../hooks/useAdmin' } from '../../hooks/useAdmin'
import { exportEvolutions } from '../../api/admin'
import { downloadJson } from '../../utils/download'
import type { EvolutionAdmin, CreateEvolutionInput, UpdateEvolutionInput } from '../../types' import type { EvolutionAdmin, CreateEvolutionInput, UpdateEvolutionInput } from '../../types'
const PAGE_SIZE = 50 const PAGE_SIZE = 50
@@ -80,12 +82,23 @@ export function AdminEvolutions() {
<div> <div>
<div className="flex justify-between items-center mb-4"> <div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-semibold">Evolutions</h2> <h2 className="text-xl font-semibold">Evolutions</h2>
<button <div className="flex gap-2">
onClick={() => setShowCreate(true)} <button
className="px-4 py-2 text-sm font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700" onClick={async () => {
> const data = await exportEvolutions()
Add Evolution downloadJson(data, 'evolutions.json')
</button> }}
className="px-4 py-2 text-sm font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800"
>
Export
</button>
<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> </div>
<div className="mb-4 flex items-center gap-4"> <div className="mb-4 flex items-center gap-4">

View File

@@ -1,5 +1,4 @@
import { useState } from 'react' import { useState } from 'react'
import { toast } from 'sonner'
import { useParams, useNavigate, Link } from 'react-router-dom' import { useParams, useNavigate, Link } from 'react-router-dom'
import { import {
DndContext, DndContext,
@@ -26,6 +25,8 @@ import {
useDeleteRoute, useDeleteRoute,
useReorderRoutes, useReorderRoutes,
} from '../../hooks/useAdmin' } from '../../hooks/useAdmin'
import { exportGameRoutes } from '../../api/admin'
import { downloadJson } from '../../utils/download'
import type { Route as GameRoute, CreateRouteInput, UpdateRouteInput } from '../../types' import type { Route as GameRoute, CreateRouteInput, UpdateRouteInput } from '../../types'
function SortableRouteRow({ function SortableRouteRow({
@@ -161,14 +162,13 @@ export function AdminGameDetail() {
<h3 className="text-lg font-medium">Routes ({routes.length})</h3> <h3 className="text-lg font-medium">Routes ({routes.length})</h3>
<div className="flex gap-2"> <div className="flex gap-2">
<button <button
onClick={() => { onClick={async () => {
const names = routes.map((r) => r.name) const result = await exportGameRoutes(id)
navigator.clipboard.writeText(JSON.stringify(names, null, 2)) downloadJson(result.data, result.filename)
toast.success('Route order copied to clipboard')
}} }}
className="px-4 py-2 text-sm font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800" className="px-4 py-2 text-sm font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800"
> >
Export Order Export
</button> </button>
<button <button
onClick={() => setShowCreate(true)} onClick={() => setShowCreate(true)}

View File

@@ -5,6 +5,8 @@ import { GameFormModal } from '../../components/admin/GameFormModal'
import { DeleteConfirmModal } from '../../components/admin/DeleteConfirmModal' import { DeleteConfirmModal } from '../../components/admin/DeleteConfirmModal'
import { useGames } from '../../hooks/useGames' import { useGames } from '../../hooks/useGames'
import { useCreateGame, useUpdateGame, useDeleteGame } from '../../hooks/useAdmin' import { useCreateGame, useUpdateGame, useDeleteGame } from '../../hooks/useAdmin'
import { exportGames } from '../../api/admin'
import { downloadJson } from '../../utils/download'
import type { Game, CreateGameInput, UpdateGameInput } from '../../types' import type { Game, CreateGameInput, UpdateGameInput } from '../../types'
export function AdminGames() { export function AdminGames() {
@@ -49,12 +51,23 @@ export function AdminGames() {
<div> <div>
<div className="flex justify-between items-center mb-4"> <div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-semibold">Games</h2> <h2 className="text-xl font-semibold">Games</h2>
<button <div className="flex gap-2">
onClick={() => setShowCreate(true)} <button
className="px-4 py-2 text-sm font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700" onClick={async () => {
> const data = await exportGames()
Add Game downloadJson(data, 'games.json')
</button> }}
className="px-4 py-2 text-sm font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800"
>
Export
</button>
<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 Game
</button>
</div>
</div> </div>
<AdminTable <AdminTable

View File

@@ -10,6 +10,8 @@ import {
useDeletePokemon, useDeletePokemon,
useBulkImportPokemon, useBulkImportPokemon,
} from '../../hooks/useAdmin' } from '../../hooks/useAdmin'
import { exportPokemon } from '../../api/admin'
import { downloadJson } from '../../utils/download'
import type { Pokemon, CreatePokemonInput, UpdatePokemonInput } from '../../types' import type { Pokemon, CreatePokemonInput, UpdatePokemonInput } from '../../types'
const PAGE_SIZE = 50 const PAGE_SIZE = 50
@@ -72,6 +74,15 @@ export function AdminPokemon() {
<div className="flex justify-between items-center mb-4"> <div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-semibold">Pokemon</h2> <h2 className="text-xl font-semibold">Pokemon</h2>
<div className="flex gap-2"> <div className="flex gap-2">
<button
onClick={async () => {
const data = await exportPokemon()
downloadJson(data, 'pokemon.json')
}}
className="px-4 py-2 text-sm font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800"
>
Export
</button>
<button <button
onClick={() => setShowBulkImport(true)} onClick={() => setShowBulkImport(true)}
className="px-4 py-2 text-sm font-medium rounded-md border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700" className="px-4 py-2 text-sm font-medium rounded-md border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700"

View File

@@ -0,0 +1,11 @@
export function downloadJson(data: unknown, filename: string) {
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}