Add bulk import for evolutions, routes, and bosses
Add three new bulk import endpoints that accept the same JSON format as
their corresponding export endpoints, enabling round-trip compatibility:
- POST /evolutions/bulk-import (upsert by from/to pokemon pair)
- POST /games/{id}/routes/bulk-import (reuses seed loader for hierarchy)
- POST /games/{id}/bosses/bulk-import (reuses seed loader with team data)
Generalize BulkImportModal to support all entity types with configurable
title, example, and result labels. Wire up Bulk Import buttons on
AdminEvolutions, and AdminGameDetail routes/bosses tabs.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -70,6 +70,15 @@ export const deletePokemon = (id: number) =>
|
||||
export const bulkImportPokemon = (items: Array<{ pokeapiId: number; nationalDex: number; name: string; types: string[]; spriteUrl?: string | null }>) =>
|
||||
api.post<BulkImportResult>('/pokemon/bulk-import', items)
|
||||
|
||||
export const bulkImportEvolutions = (items: unknown[]) =>
|
||||
api.post<BulkImportResult>('/evolutions/bulk-import', items)
|
||||
|
||||
export const bulkImportRoutes = (gameId: number, items: unknown[]) =>
|
||||
api.post<BulkImportResult>(`/games/${gameId}/routes/bulk-import`, items)
|
||||
|
||||
export const bulkImportBosses = (gameId: number, items: unknown[]) =>
|
||||
api.post<BulkImportResult>(`/games/${gameId}/bosses/bulk-import`, items)
|
||||
|
||||
// Evolutions
|
||||
export const listEvolutions = (search?: string, limit = 50, offset = 0) => {
|
||||
const params = new URLSearchParams()
|
||||
|
||||
@@ -2,16 +2,17 @@ import { type FormEvent, useState } from 'react'
|
||||
import type { BulkImportResult } from '../../types'
|
||||
|
||||
interface BulkImportModalProps {
|
||||
onSubmit: (items: Array<{ pokeapiId: number; nationalDex: number; name: string; types: string[]; spriteUrl?: string | null }>) => Promise<BulkImportResult>
|
||||
title: string
|
||||
example?: string
|
||||
onSubmit: (items: unknown[]) => Promise<BulkImportResult>
|
||||
onClose: () => void
|
||||
/** Label for the "created" count in the result summary */
|
||||
createdLabel?: string
|
||||
/** Label for the "updated" count in the result summary */
|
||||
updatedLabel?: string
|
||||
}
|
||||
|
||||
const EXAMPLE = `[
|
||||
{ "pokeapiId": 1, "nationalDex": 1, "name": "Bulbasaur", "types": ["Grass", "Poison"] },
|
||||
{ "pokeapiId": 4, "nationalDex": 4, "name": "Charmander", "types": ["Fire"] }
|
||||
]`
|
||||
|
||||
export function BulkImportModal({ onSubmit, onClose }: BulkImportModalProps) {
|
||||
export function BulkImportModal({ title, example, onSubmit, onClose, createdLabel = 'Created', updatedLabel = 'Updated' }: BulkImportModalProps) {
|
||||
const [json, setJson] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [result, setResult] = useState<BulkImportResult | null>(null)
|
||||
@@ -27,13 +28,13 @@ export function BulkImportModal({ onSubmit, onClose }: BulkImportModalProps) {
|
||||
items = JSON.parse(json)
|
||||
if (!Array.isArray(items)) throw new Error('Must be an array')
|
||||
} catch {
|
||||
setError('Invalid JSON. Must be an array of pokemon objects.')
|
||||
setError('Invalid JSON. Must be an array of objects.')
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
const res = await onSubmit(items as Array<{ pokeapiId: number; nationalDex: number; name: string; types: string[] }>)
|
||||
const res = await onSubmit(items)
|
||||
setResult(res)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Import failed')
|
||||
@@ -47,7 +48,7 @@ export function BulkImportModal({ onSubmit, onClose }: BulkImportModalProps) {
|
||||
<div className="fixed inset-0 bg-black/50" onClick={onClose} />
|
||||
<div className="relative bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
|
||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-lg font-semibold">Bulk Import Pokemon</h2>
|
||||
<h2 className="text-lg font-semibold">{title}</h2>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="px-6 py-4 space-y-4">
|
||||
@@ -59,7 +60,7 @@ export function BulkImportModal({ onSubmit, onClose }: BulkImportModalProps) {
|
||||
rows={12}
|
||||
value={json}
|
||||
onChange={(e) => setJson(e.target.value)}
|
||||
placeholder={EXAMPLE}
|
||||
placeholder={example}
|
||||
className="w-full px-3 py-2 border rounded-md font-mono text-sm dark:bg-gray-700 dark:border-gray-600"
|
||||
/>
|
||||
</div>
|
||||
@@ -72,7 +73,7 @@ export function BulkImportModal({ onSubmit, onClose }: BulkImportModalProps) {
|
||||
|
||||
{result && (
|
||||
<div className="p-3 bg-green-50 dark:bg-green-900/30 text-green-700 dark:text-green-300 rounded-md text-sm">
|
||||
<p>Created: {result.created}, Updated: {result.updated}</p>
|
||||
<p>{createdLabel}: {result.created}, {updatedLabel}: {result.updated}</p>
|
||||
{result.errors.length > 0 && (
|
||||
<ul className="mt-2 list-disc list-inside text-red-600 dark:text-red-400">
|
||||
{result.errors.map((err, i) => (
|
||||
|
||||
@@ -174,6 +174,43 @@ export function useBulkImportPokemon() {
|
||||
})
|
||||
}
|
||||
|
||||
export function useBulkImportEvolutions() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (items: unknown[]) => adminApi.bulkImportEvolutions(items),
|
||||
onSuccess: (result) => {
|
||||
qc.invalidateQueries({ queryKey: ['evolutions'] })
|
||||
toast.success(`Import complete: ${result.created} created, ${result.updated} updated`)
|
||||
},
|
||||
onError: (err) => toast.error(`Import failed: ${err.message}`),
|
||||
})
|
||||
}
|
||||
|
||||
export function useBulkImportRoutes(gameId: number) {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (items: unknown[]) => adminApi.bulkImportRoutes(gameId, items),
|
||||
onSuccess: (result) => {
|
||||
qc.invalidateQueries({ queryKey: ['games', gameId] })
|
||||
qc.invalidateQueries({ queryKey: ['games', gameId, 'routes'] })
|
||||
toast.success(`Import complete: ${result.created} routes, ${result.updated} encounters`)
|
||||
},
|
||||
onError: (err) => toast.error(`Import failed: ${err.message}`),
|
||||
})
|
||||
}
|
||||
|
||||
export function useBulkImportBosses(gameId: number) {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (items: unknown[]) => adminApi.bulkImportBosses(gameId, items),
|
||||
onSuccess: (result) => {
|
||||
qc.invalidateQueries({ queryKey: ['games', gameId, 'bosses'] })
|
||||
toast.success(`Import complete: ${result.created} bosses imported`)
|
||||
},
|
||||
onError: (err) => toast.error(`Import failed: ${err.message}`),
|
||||
})
|
||||
}
|
||||
|
||||
// --- Evolution Queries & Mutations ---
|
||||
|
||||
export function useEvolutionList(search?: string, limit = 50, offset = 0) {
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { useState } from 'react'
|
||||
import { AdminTable, type Column } from '../../components/admin/AdminTable'
|
||||
import { BulkImportModal } from '../../components/admin/BulkImportModal'
|
||||
import { EvolutionFormModal } from '../../components/admin/EvolutionFormModal'
|
||||
import {
|
||||
useEvolutionList,
|
||||
useCreateEvolution,
|
||||
useUpdateEvolution,
|
||||
useDeleteEvolution,
|
||||
useBulkImportEvolutions,
|
||||
} from '../../hooks/useAdmin'
|
||||
import { exportEvolutions } from '../../api/admin'
|
||||
import { downloadJson } from '../../utils/download'
|
||||
@@ -25,8 +27,10 @@ export function AdminEvolutions() {
|
||||
const createEvolution = useCreateEvolution()
|
||||
const updateEvolution = useUpdateEvolution()
|
||||
const deleteEvolution = useDeleteEvolution()
|
||||
const bulkImport = useBulkImportEvolutions()
|
||||
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [showBulkImport, setShowBulkImport] = useState(false)
|
||||
const [editing, setEditing] = useState<EvolutionAdmin | null>(null)
|
||||
|
||||
const columns: Column<EvolutionAdmin>[] = [
|
||||
@@ -71,6 +75,12 @@ export function AdminEvolutions() {
|
||||
>
|
||||
Export
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowBulkImport(true)}
|
||||
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"
|
||||
>
|
||||
Bulk Import
|
||||
</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"
|
||||
@@ -146,6 +156,15 @@ export function AdminEvolutions() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showBulkImport && (
|
||||
<BulkImportModal
|
||||
title="Bulk Import Evolutions"
|
||||
example={`[\n { "from_pokeapi_id": 1, "to_pokeapi_id": 2, "trigger": "level-up", "min_level": 16 }\n]`}
|
||||
onSubmit={(items) => bulkImport.mutateAsync(items)}
|
||||
onClose={() => setShowBulkImport(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showCreate && (
|
||||
<EvolutionFormModal
|
||||
onSubmit={(data) =>
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import { BulkImportModal } from '../../components/admin/BulkImportModal'
|
||||
import { RouteFormModal } from '../../components/admin/RouteFormModal'
|
||||
import { BossBattleFormModal } from '../../components/admin/BossBattleFormModal'
|
||||
import { BossTeamEditor } from '../../components/admin/BossTeamEditor'
|
||||
@@ -27,11 +28,13 @@ import {
|
||||
useUpdateRoute,
|
||||
useDeleteRoute,
|
||||
useReorderRoutes,
|
||||
useBulkImportRoutes,
|
||||
useReorderBosses,
|
||||
useCreateBossBattle,
|
||||
useUpdateBossBattle,
|
||||
useDeleteBossBattle,
|
||||
useSetBossTeam,
|
||||
useBulkImportBosses,
|
||||
} from '../../hooks/useAdmin'
|
||||
import { exportGameRoutes, exportGameBosses } from '../../api/admin'
|
||||
import { downloadJson } from '../../utils/download'
|
||||
@@ -149,16 +152,20 @@ export function AdminGameDetail() {
|
||||
const updateRoute = useUpdateRoute(id)
|
||||
const deleteRoute = useDeleteRoute(id)
|
||||
const reorderRoutes = useReorderRoutes(id)
|
||||
const bulkImportRoutes = useBulkImportRoutes(id)
|
||||
const { data: bosses } = useGameBosses(id)
|
||||
const createBoss = useCreateBossBattle(id)
|
||||
const updateBoss = useUpdateBossBattle(id)
|
||||
const deleteBoss = useDeleteBossBattle(id)
|
||||
const reorderBosses = useReorderBosses(id)
|
||||
const bulkImportBosses = useBulkImportBosses(id)
|
||||
|
||||
const [tab, setTab] = useState<'routes' | 'bosses'>('routes')
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [showBulkImportRoutes, setShowBulkImportRoutes] = useState(false)
|
||||
const [editing, setEditing] = useState<GameRoute | null>(null)
|
||||
const [showCreateBoss, setShowCreateBoss] = useState(false)
|
||||
const [showBulkImportBosses, setShowBulkImportBosses] = useState(false)
|
||||
const [editingBoss, setEditingBoss] = useState<BossBattle | null>(null)
|
||||
const [editingTeam, setEditingTeam] = useState<BossBattle | null>(null)
|
||||
|
||||
@@ -265,6 +272,12 @@ export function AdminGameDetail() {
|
||||
>
|
||||
Export
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowBulkImportRoutes(true)}
|
||||
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"
|
||||
>
|
||||
Bulk Import
|
||||
</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"
|
||||
@@ -273,6 +286,17 @@ export function AdminGameDetail() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showBulkImportRoutes && (
|
||||
<BulkImportModal
|
||||
title="Bulk Import Routes"
|
||||
example={`[\n { "name": "Route 1", "order": 1, "encounters": [...], "children": [...] }\n]`}
|
||||
createdLabel="Routes"
|
||||
updatedLabel="Encounters"
|
||||
onSubmit={(items) => bulkImportRoutes.mutateAsync(items)}
|
||||
onClose={() => setShowBulkImportRoutes(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{routes.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500 dark:text-gray-400 border border-gray-200 dark:border-gray-700 rounded-lg">
|
||||
No routes yet. Add one to get started.
|
||||
@@ -365,6 +389,12 @@ export function AdminGameDetail() {
|
||||
>
|
||||
Export
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowBulkImportBosses(true)}
|
||||
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"
|
||||
>
|
||||
Bulk Import
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowCreateBoss(true)}
|
||||
className="px-4 py-2 text-sm font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700"
|
||||
@@ -373,6 +403,17 @@ export function AdminGameDetail() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showBulkImportBosses && (
|
||||
<BulkImportModal
|
||||
title="Bulk Import Boss Battles"
|
||||
example={`[\n { "name": "Brock", "boss_type": "gym_leader", "level_cap": 15, "order": 1, "location": "Pewter City", "pokemon": [...] }\n]`}
|
||||
createdLabel="Bosses"
|
||||
updatedLabel="Updated"
|
||||
onSubmit={(items) => bulkImportBosses.mutateAsync(items)}
|
||||
onClose={() => setShowBulkImportBosses(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!bosses || bosses.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500 dark:text-gray-400 border border-gray-200 dark:border-gray-700 rounded-lg">
|
||||
No boss battles yet. Add one to get started.
|
||||
|
||||
@@ -158,7 +158,9 @@ export function AdminPokemon() {
|
||||
|
||||
{showBulkImport && (
|
||||
<BulkImportModal
|
||||
onSubmit={(items) => bulkImport.mutateAsync(items)}
|
||||
title="Bulk Import Pokemon"
|
||||
example={`[\n { "pokeapi_id": 1, "national_dex": 1, "name": "Bulbasaur", "types": ["Grass", "Poison"] }\n]`}
|
||||
onSubmit={(items) => bulkImport.mutateAsync(items as Parameters<typeof bulkImport.mutateAsync>[0])}
|
||||
onClose={() => setShowBulkImport(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user