Add Pokemon detail card with tabbed encounter/evolution views
Pokemon edit modal now shows three tabs (Details, Evolutions, Encounters)
instead of a single long form. Evolution chain entries are clickable to
open the EvolutionFormModal for direct editing. Encounter locations link
to admin route detail pages. Create mode shows only the form (no tabs).
Backend adds GET /pokemon/{id}/encounter-locations (grouped by game) and
GET /pokemon/{id}/evolution-chain (BFS family discovery). Extracts
formatEvolutionMethod to shared utility.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { api } from './client'
|
||||
import type { Pokemon } from '../types/game'
|
||||
import type { EvolutionAdmin, PokemonEncounterLocation } from '../types/admin'
|
||||
|
||||
export function getPokemon(id: number): Promise<Pokemon> {
|
||||
return api.get(`/pokemon/${id}`)
|
||||
@@ -8,3 +9,11 @@ export function getPokemon(id: number): Promise<Pokemon> {
|
||||
export function fetchPokemonFamilies(): Promise<{ families: number[][] }> {
|
||||
return api.get('/pokemon/families')
|
||||
}
|
||||
|
||||
export function fetchPokemonEncounterLocations(pokemonId: number): Promise<PokemonEncounterLocation[]> {
|
||||
return api.get(`/pokemon/${pokemonId}/encounter-locations`)
|
||||
}
|
||||
|
||||
export function fetchPokemonEvolutionChain(pokemonId: number): Promise<EvolutionAdmin[]> {
|
||||
return api.get(`/pokemon/${pokemonId}/evolution-chain`)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState } from 'react'
|
||||
import type { EncounterDetail, UpdateEncounterInput } from '../types'
|
||||
import { useEvolutions, useForms } from '../hooks/useEncounters'
|
||||
import { TypeBadge } from './TypeBadge'
|
||||
import { formatEvolutionMethod } from '../utils/formatEvolution'
|
||||
|
||||
interface StatusChangeModalProps {
|
||||
encounter: EncounterDetail
|
||||
@@ -14,28 +15,6 @@ interface StatusChangeModalProps {
|
||||
region?: string
|
||||
}
|
||||
|
||||
function formatEvolutionMethod(evo: { trigger: string; minLevel: number | null; item: string | null; heldItem: string | null; condition: string | null }): string {
|
||||
const parts: string[] = []
|
||||
if (evo.trigger === 'level-up' && evo.minLevel) {
|
||||
parts.push(`Level ${evo.minLevel}`)
|
||||
} else if (evo.trigger === 'level-up') {
|
||||
parts.push('Level up')
|
||||
} else if (evo.trigger === 'use-item' && evo.item) {
|
||||
parts.push(evo.item.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase()))
|
||||
} else if (evo.trigger === 'trade') {
|
||||
parts.push('Trade')
|
||||
} else {
|
||||
parts.push(evo.trigger.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase()))
|
||||
}
|
||||
if (evo.heldItem) {
|
||||
parts.push(`holding ${evo.heldItem.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase())}`)
|
||||
}
|
||||
if (evo.condition) {
|
||||
parts.push(evo.condition)
|
||||
}
|
||||
return parts.join(', ')
|
||||
}
|
||||
|
||||
export function StatusChangeModal({
|
||||
encounter,
|
||||
onUpdate,
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { type FormEvent, useState } from 'react'
|
||||
import { FormModal } from './FormModal'
|
||||
import type { Pokemon, CreatePokemonInput, UpdatePokemonInput } from '../../types'
|
||||
import { type FormEvent, useState, useEffect } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { EvolutionFormModal } from './EvolutionFormModal'
|
||||
import type { Pokemon, CreatePokemonInput, UpdatePokemonInput, EvolutionAdmin, UpdateEvolutionInput } from '../../types'
|
||||
import { usePokemonEncounterLocations, usePokemonEvolutionChain } from '../../hooks/usePokemon'
|
||||
import { useUpdateEvolution, useDeleteEvolution } from '../../hooks/useAdmin'
|
||||
import { formatEvolutionMethod } from '../../utils/formatEvolution'
|
||||
|
||||
interface PokemonFormModalProps {
|
||||
pokemon?: Pokemon
|
||||
@@ -11,12 +16,34 @@ interface PokemonFormModalProps {
|
||||
isDeleting?: boolean
|
||||
}
|
||||
|
||||
type Tab = 'details' | 'evolutions' | 'encounters'
|
||||
|
||||
export function PokemonFormModal({ pokemon, onSubmit, onClose, isSubmitting, onDelete, isDeleting }: PokemonFormModalProps) {
|
||||
const [pokeapiId, setPokeapiId] = useState(String(pokemon?.pokeapiId ?? ''))
|
||||
const [nationalDex, setNationalDex] = useState(String(pokemon?.nationalDex ?? ''))
|
||||
const [name, setName] = useState(pokemon?.name ?? '')
|
||||
const [types, setTypes] = useState(pokemon?.types.join(', ') ?? '')
|
||||
const [spriteUrl, setSpriteUrl] = useState(pokemon?.spriteUrl ?? '')
|
||||
const [activeTab, setActiveTab] = useState<Tab>('details')
|
||||
const [editingEvolution, setEditingEvolution] = useState<EvolutionAdmin | null>(null)
|
||||
const [confirmingDelete, setConfirmingDelete] = useState(false)
|
||||
|
||||
const isEdit = !!pokemon
|
||||
const pokemonId = pokemon?.id ?? null
|
||||
const { data: encounterLocations, isLoading: encountersLoading } = usePokemonEncounterLocations(pokemonId)
|
||||
const { data: evolutionChain, isLoading: evolutionsLoading } = usePokemonEvolutionChain(pokemonId)
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
const updateEvolution = useUpdateEvolution()
|
||||
const deleteEvolution = useDeleteEvolution()
|
||||
|
||||
useEffect(() => {
|
||||
setConfirmingDelete(false)
|
||||
}, [onDelete])
|
||||
|
||||
const invalidateChain = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['pokemon', pokemonId, 'evolution-chain'] })
|
||||
}
|
||||
|
||||
const handleSubmit = (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
@@ -33,68 +60,256 @@ export function PokemonFormModal({ pokemon, onSubmit, onClose, isSubmitting, onD
|
||||
})
|
||||
}
|
||||
|
||||
const tabs: { key: Tab; label: string }[] = [
|
||||
{ key: 'details', label: 'Details' },
|
||||
{ key: 'evolutions', label: 'Evolutions' },
|
||||
{ key: 'encounters', label: 'Encounters' },
|
||||
]
|
||||
|
||||
const tabClass = (tab: Tab) =>
|
||||
`px-3 py-1.5 text-sm font-medium rounded-t-md border-b-2 transition-colors ${
|
||||
activeTab === tab
|
||||
? 'border-blue-600 text-blue-600 dark:border-blue-400 dark:text-blue-400'
|
||||
: 'border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'
|
||||
}`
|
||||
|
||||
return (
|
||||
<FormModal
|
||||
title={pokemon ? 'Edit Pokemon' : 'Add Pokemon'}
|
||||
onClose={onClose}
|
||||
onSubmit={handleSubmit}
|
||||
isSubmitting={isSubmitting}
|
||||
onDelete={onDelete}
|
||||
isDeleting={isDeleting}
|
||||
>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">PokeAPI ID</label>
|
||||
<input
|
||||
type="number"
|
||||
required
|
||||
min={1}
|
||||
value={pokeapiId}
|
||||
onChange={(e) => setPokeapiId(e.target.value)}
|
||||
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
|
||||
/>
|
||||
<>
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<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-lg w-full mx-4 max-h-[90vh] flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 shrink-0">
|
||||
<h2 className="text-lg font-semibold">{pokemon ? 'Edit Pokemon' : 'Add Pokemon'}</h2>
|
||||
{isEdit && (
|
||||
<div className="flex gap-1 mt-2">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
type="button"
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
className={tabClass(tab.key)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Details tab (form) */}
|
||||
{activeTab === 'details' && (
|
||||
<form onSubmit={handleSubmit} className="flex flex-col min-h-0 flex-1">
|
||||
<div className="px-6 py-4 space-y-4 overflow-y-auto">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">PokeAPI ID</label>
|
||||
<input
|
||||
type="number"
|
||||
required
|
||||
min={1}
|
||||
value={pokeapiId}
|
||||
onChange={(e) => setPokeapiId(e.target.value)}
|
||||
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">National Dex #</label>
|
||||
<input
|
||||
type="number"
|
||||
required
|
||||
min={1}
|
||||
value={nationalDex}
|
||||
onChange={(e) => setNationalDex(e.target.value)}
|
||||
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Types (comma-separated)</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={types}
|
||||
onChange={(e) => setTypes(e.target.value)}
|
||||
placeholder="Fire, Flying"
|
||||
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Sprite URL</label>
|
||||
<input
|
||||
type="text"
|
||||
value={spriteUrl}
|
||||
onChange={(e) => setSpriteUrl(e.target.value)}
|
||||
placeholder="Optional"
|
||||
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex items-center gap-3 shrink-0">
|
||||
{onDelete && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={isDeleting}
|
||||
onClick={() => {
|
||||
if (confirmingDelete) {
|
||||
onDelete()
|
||||
} else {
|
||||
setConfirmingDelete(true)
|
||||
}
|
||||
}}
|
||||
onBlur={() => setConfirmingDelete(false)}
|
||||
className="px-4 py-2 text-sm font-medium rounded-md text-red-600 dark:text-red-400 border border-red-300 dark:border-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 disabled:opacity-50"
|
||||
>
|
||||
{isDeleting ? 'Deleting...' : confirmingDelete ? 'Confirm?' : 'Delete'}
|
||||
</button>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
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"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="px-4 py-2 text-sm font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{isSubmitting ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* Evolutions tab */}
|
||||
{activeTab === 'evolutions' && (
|
||||
<div className="flex flex-col min-h-0 flex-1">
|
||||
<div className="px-6 py-4 overflow-y-auto">
|
||||
{evolutionsLoading && (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Loading...</p>
|
||||
)}
|
||||
{!evolutionsLoading && (!evolutionChain || evolutionChain.length === 0) && (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">No evolutions</p>
|
||||
)}
|
||||
{!evolutionsLoading && evolutionChain && evolutionChain.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{evolutionChain.map((evo) => (
|
||||
<button
|
||||
key={evo.id}
|
||||
type="button"
|
||||
onClick={() => setEditingEvolution(evo)}
|
||||
className="w-full text-left text-sm text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded px-2 py-1.5 -mx-2 transition-colors"
|
||||
>
|
||||
{evo.fromPokemon.name} → {evo.toPokemon.name}{' '}
|
||||
<span className="text-gray-400 dark:text-gray-500">
|
||||
({formatEvolutionMethod(evo)})
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
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"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Encounters tab */}
|
||||
{activeTab === 'encounters' && (
|
||||
<div className="flex flex-col min-h-0 flex-1">
|
||||
<div className="px-6 py-4 overflow-y-auto">
|
||||
{encountersLoading && (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Loading...</p>
|
||||
)}
|
||||
{!encountersLoading && (!encounterLocations || encounterLocations.length === 0) && (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">No encounters</p>
|
||||
)}
|
||||
{!encountersLoading && encounterLocations && encounterLocations.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{encounterLocations.map((game) => (
|
||||
<div key={game.gameId}>
|
||||
<div className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{game.gameName}
|
||||
</div>
|
||||
<div className="space-y-0.5 pl-2">
|
||||
{game.encounters.map((enc, i) => (
|
||||
<div key={i} className="text-sm text-gray-600 dark:text-gray-400 flex items-center gap-1">
|
||||
<Link
|
||||
to={`/admin/games/${game.gameId}/routes/${enc.routeId}`}
|
||||
className="text-blue-600 dark:text-blue-400 hover:underline"
|
||||
>
|
||||
{enc.routeName}
|
||||
</Link>
|
||||
<span className="text-gray-400 dark:text-gray-500">
|
||||
— {enc.encounterMethod}, Lv. {enc.minLevel}–{enc.maxLevel}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
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"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">National Dex #</label>
|
||||
<input
|
||||
type="number"
|
||||
required
|
||||
min={1}
|
||||
value={nationalDex}
|
||||
onChange={(e) => setNationalDex(e.target.value)}
|
||||
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
|
||||
|
||||
{editingEvolution && (
|
||||
<EvolutionFormModal
|
||||
evolution={editingEvolution}
|
||||
onSubmit={(data) =>
|
||||
updateEvolution.mutate(
|
||||
{ id: editingEvolution.id, data: data as UpdateEvolutionInput },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setEditingEvolution(null)
|
||||
invalidateChain()
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
onClose={() => setEditingEvolution(null)}
|
||||
isSubmitting={updateEvolution.isPending}
|
||||
onDelete={() =>
|
||||
deleteEvolution.mutate(editingEvolution.id, {
|
||||
onSuccess: () => {
|
||||
setEditingEvolution(null)
|
||||
invalidateChain()
|
||||
},
|
||||
})
|
||||
}
|
||||
isDeleting={deleteEvolution.isPending}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Types (comma-separated)</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={types}
|
||||
onChange={(e) => setTypes(e.target.value)}
|
||||
placeholder="Fire, Flying"
|
||||
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Sprite URL</label>
|
||||
<input
|
||||
type="text"
|
||||
value={spriteUrl}
|
||||
onChange={(e) => setSpriteUrl(e.target.value)}
|
||||
placeholder="Optional"
|
||||
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
|
||||
/>
|
||||
</div>
|
||||
</FormModal>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { getPokemon, fetchPokemonFamilies } from '../api/pokemon'
|
||||
import { getPokemon, fetchPokemonFamilies, fetchPokemonEncounterLocations, fetchPokemonEvolutionChain } from '../api/pokemon'
|
||||
|
||||
export function usePokemon(id: number | null) {
|
||||
return useQuery({
|
||||
@@ -16,3 +16,19 @@ export function usePokemonFamilies() {
|
||||
staleTime: Infinity,
|
||||
})
|
||||
}
|
||||
|
||||
export function usePokemonEncounterLocations(pokemonId: number | null) {
|
||||
return useQuery({
|
||||
queryKey: ['pokemon', pokemonId, 'encounter-locations'],
|
||||
queryFn: () => fetchPokemonEncounterLocations(pokemonId!),
|
||||
enabled: pokemonId !== null,
|
||||
})
|
||||
}
|
||||
|
||||
export function usePokemonEvolutionChain(pokemonId: number | null) {
|
||||
return useQuery({
|
||||
queryKey: ['pokemon', pokemonId, 'evolution-chain'],
|
||||
queryFn: () => fetchPokemonEvolutionChain(pokemonId!),
|
||||
enabled: pokemonId !== null,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -121,6 +121,22 @@ export interface UpdateEvolutionInput {
|
||||
region?: string | null
|
||||
}
|
||||
|
||||
// Pokemon encounter locations (detail card)
|
||||
export interface PokemonEncounterLocationItem {
|
||||
routeId: number
|
||||
routeName: string
|
||||
encounterMethod: string
|
||||
encounterRate: number
|
||||
minLevel: number
|
||||
maxLevel: number
|
||||
}
|
||||
|
||||
export interface PokemonEncounterLocation {
|
||||
gameId: number
|
||||
gameName: string
|
||||
encounters: PokemonEncounterLocationItem[]
|
||||
}
|
||||
|
||||
// Boss battles admin
|
||||
export interface CreateBossBattleInput {
|
||||
name: string
|
||||
|
||||
21
frontend/src/utils/formatEvolution.ts
Normal file
21
frontend/src/utils/formatEvolution.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export function formatEvolutionMethod(evo: { trigger: string; minLevel: number | null; item: string | null; heldItem: string | null; condition: string | null }): string {
|
||||
const parts: string[] = []
|
||||
if (evo.trigger === 'level-up' && evo.minLevel) {
|
||||
parts.push(`Level ${evo.minLevel}`)
|
||||
} else if (evo.trigger === 'level-up') {
|
||||
parts.push('Level up')
|
||||
} else if (evo.trigger === 'use-item' && evo.item) {
|
||||
parts.push(evo.item.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase()))
|
||||
} else if (evo.trigger === 'trade') {
|
||||
parts.push('Trade')
|
||||
} else {
|
||||
parts.push(evo.trigger.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase()))
|
||||
}
|
||||
if (evo.heldItem) {
|
||||
parts.push(`holding ${evo.heldItem.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase())}`)
|
||||
}
|
||||
if (evo.condition) {
|
||||
parts.push(evo.condition)
|
||||
}
|
||||
return parts.join(', ')
|
||||
}
|
||||
Reference in New Issue
Block a user