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:
@@ -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.
|
||||||
152
backend/src/app/api/export.py
Normal file
152
backend/src/app/api/export.py
Normal 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
|
||||||
|
]
|
||||||
@@ -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"])
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
11
frontend/src/utils/download.ts
Normal file
11
frontend/src/utils/download.ts
Normal 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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user