2026-02-08 14:03:43 +01:00
|
|
|
|
import { type FormEvent, useState, useEffect } from 'react'
|
|
|
|
|
|
import { Link } from 'react-router-dom'
|
|
|
|
|
|
import { useQueryClient } from '@tanstack/react-query'
|
|
|
|
|
|
import { EvolutionFormModal } from './EvolutionFormModal'
|
2026-02-14 16:41:24 +01:00
|
|
|
|
import type {
|
|
|
|
|
|
Pokemon,
|
|
|
|
|
|
CreatePokemonInput,
|
|
|
|
|
|
UpdatePokemonInput,
|
|
|
|
|
|
EvolutionAdmin,
|
|
|
|
|
|
UpdateEvolutionInput,
|
|
|
|
|
|
} from '../../types'
|
2026-02-16 20:39:41 +01:00
|
|
|
|
import { usePokemonEncounterLocations, usePokemonEvolutionChain } from '../../hooks/usePokemon'
|
2026-02-08 14:03:43 +01:00
|
|
|
|
import { useUpdateEvolution, useDeleteEvolution } from '../../hooks/useAdmin'
|
|
|
|
|
|
import { formatEvolutionMethod } from '../../utils/formatEvolution'
|
2026-02-05 18:36:19 +01:00
|
|
|
|
|
|
|
|
|
|
interface PokemonFormModalProps {
|
|
|
|
|
|
pokemon?: Pokemon
|
|
|
|
|
|
onSubmit: (data: CreatePokemonInput | UpdatePokemonInput) => void
|
|
|
|
|
|
onClose: () => void
|
|
|
|
|
|
isSubmitting?: boolean
|
2026-02-08 13:44:38 +01:00
|
|
|
|
onDelete?: () => void
|
|
|
|
|
|
isDeleting?: boolean
|
2026-02-05 18:36:19 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-08 14:03:43 +01:00
|
|
|
|
type Tab = 'details' | 'evolutions' | 'encounters'
|
|
|
|
|
|
|
2026-02-14 16:41:24 +01:00
|
|
|
|
export function PokemonFormModal({
|
|
|
|
|
|
pokemon,
|
|
|
|
|
|
onSubmit,
|
|
|
|
|
|
onClose,
|
|
|
|
|
|
isSubmitting,
|
|
|
|
|
|
onDelete,
|
|
|
|
|
|
isDeleting,
|
|
|
|
|
|
}: PokemonFormModalProps) {
|
2026-02-07 14:55:06 +01:00
|
|
|
|
const [pokeapiId, setPokeapiId] = useState(String(pokemon?.pokeapiId ?? ''))
|
2026-02-16 20:39:41 +01:00
|
|
|
|
const [nationalDex, setNationalDex] = useState(String(pokemon?.nationalDex ?? ''))
|
2026-02-05 18:36:19 +01:00
|
|
|
|
const [name, setName] = useState(pokemon?.name ?? '')
|
|
|
|
|
|
const [types, setTypes] = useState(pokemon?.types.join(', ') ?? '')
|
|
|
|
|
|
const [spriteUrl, setSpriteUrl] = useState(pokemon?.spriteUrl ?? '')
|
2026-02-08 14:03:43 +01:00
|
|
|
|
const [activeTab, setActiveTab] = useState<Tab>('details')
|
2026-02-16 20:39:41 +01:00
|
|
|
|
const [editingEvolution, setEditingEvolution] = useState<EvolutionAdmin | null>(null)
|
2026-02-08 14:03:43 +01:00
|
|
|
|
const [confirmingDelete, setConfirmingDelete] = useState(false)
|
|
|
|
|
|
|
|
|
|
|
|
const isEdit = !!pokemon
|
|
|
|
|
|
const pokemonId = pokemon?.id ?? null
|
2026-02-14 16:41:24 +01:00
|
|
|
|
const { data: encounterLocations, isLoading: encountersLoading } =
|
|
|
|
|
|
usePokemonEncounterLocations(pokemonId)
|
2026-02-16 20:39:41 +01:00
|
|
|
|
const { data: evolutionChain, isLoading: evolutionsLoading } = usePokemonEvolutionChain(pokemonId)
|
2026-02-08 14:03:43 +01:00
|
|
|
|
|
|
|
|
|
|
const queryClient = useQueryClient()
|
|
|
|
|
|
const updateEvolution = useUpdateEvolution()
|
|
|
|
|
|
const deleteEvolution = useDeleteEvolution()
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
setConfirmingDelete(false)
|
|
|
|
|
|
}, [onDelete])
|
|
|
|
|
|
|
|
|
|
|
|
const invalidateChain = () => {
|
2026-02-14 16:41:24 +01:00
|
|
|
|
queryClient.invalidateQueries({
|
|
|
|
|
|
queryKey: ['pokemon', pokemonId, 'evolution-chain'],
|
|
|
|
|
|
})
|
2026-02-08 14:03:43 +01:00
|
|
|
|
}
|
2026-02-05 18:36:19 +01:00
|
|
|
|
|
|
|
|
|
|
const handleSubmit = (e: FormEvent) => {
|
|
|
|
|
|
e.preventDefault()
|
|
|
|
|
|
const typesList = types
|
|
|
|
|
|
.split(',')
|
|
|
|
|
|
.map((t) => t.trim())
|
|
|
|
|
|
.filter(Boolean)
|
|
|
|
|
|
onSubmit({
|
2026-02-07 14:55:06 +01:00
|
|
|
|
pokeapiId: Number(pokeapiId),
|
2026-02-05 18:36:19 +01:00
|
|
|
|
nationalDex: Number(nationalDex),
|
|
|
|
|
|
name,
|
|
|
|
|
|
types: typesList,
|
|
|
|
|
|
spriteUrl: spriteUrl || null,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-08 14:03:43 +01:00
|
|
|
|
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'
|
2026-02-17 20:48:42 +01:00
|
|
|
|
: 'border-transparent text-text-tertiary hover:text-text-secondary'
|
2026-02-08 14:03:43 +01:00
|
|
|
|
}`
|
|
|
|
|
|
|
2026-02-05 18:36:19 +01:00
|
|
|
|
return (
|
2026-02-08 14:03:43 +01:00
|
|
|
|
<>
|
|
|
|
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
|
|
|
|
|
<div className="fixed inset-0 bg-black/50" onClick={onClose} />
|
2026-02-17 20:48:42 +01:00
|
|
|
|
<div className="relative bg-surface-1 rounded-lg shadow-xl max-w-lg w-full mx-4 max-h-[90vh] flex flex-col">
|
2026-02-08 14:03:43 +01:00
|
|
|
|
{/* Header */}
|
2026-02-17 20:48:42 +01:00
|
|
|
|
<div className="px-6 py-4 border-b border-border-default shrink-0">
|
2026-02-16 20:39:41 +01:00
|
|
|
|
<h2 className="text-lg font-semibold">{pokemon ? 'Edit Pokemon' : 'Add Pokemon'}</h2>
|
2026-02-08 14:03:43 +01:00
|
|
|
|
{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' && (
|
2026-02-16 20:39:41 +01:00
|
|
|
|
<form onSubmit={handleSubmit} className="flex flex-col min-h-0 flex-1">
|
2026-02-08 14:03:43 +01:00
|
|
|
|
<div className="px-6 py-4 space-y-4 overflow-y-auto">
|
|
|
|
|
|
<div>
|
2026-02-16 20:39:41 +01:00
|
|
|
|
<label className="block text-sm font-medium mb-1">PokeAPI ID</label>
|
2026-02-08 14:03:43 +01:00
|
|
|
|
<input
|
|
|
|
|
|
type="number"
|
|
|
|
|
|
required
|
|
|
|
|
|
min={1}
|
|
|
|
|
|
value={pokeapiId}
|
|
|
|
|
|
onChange={(e) => setPokeapiId(e.target.value)}
|
2026-02-17 20:48:42 +01:00
|
|
|
|
className="w-full px-3 py-2 border rounded-md bg-surface-2 border-border-default"
|
2026-02-08 14:03:43 +01:00
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div>
|
2026-02-16 20:39:41 +01:00
|
|
|
|
<label className="block text-sm font-medium mb-1">National Dex #</label>
|
2026-02-08 14:03:43 +01:00
|
|
|
|
<input
|
|
|
|
|
|
type="number"
|
|
|
|
|
|
required
|
|
|
|
|
|
min={1}
|
|
|
|
|
|
value={nationalDex}
|
|
|
|
|
|
onChange={(e) => setNationalDex(e.target.value)}
|
2026-02-17 20:48:42 +01:00
|
|
|
|
className="w-full px-3 py-2 border rounded-md bg-surface-2 border-border-default"
|
2026-02-08 14:03:43 +01:00
|
|
|
|
/>
|
|
|
|
|
|
</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)}
|
2026-02-17 20:48:42 +01:00
|
|
|
|
className="w-full px-3 py-2 border rounded-md bg-surface-2 border-border-default"
|
2026-02-08 14:03:43 +01:00
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div>
|
2026-02-16 20:39:41 +01:00
|
|
|
|
<label className="block text-sm font-medium mb-1">Types (comma-separated)</label>
|
2026-02-08 14:03:43 +01:00
|
|
|
|
<input
|
|
|
|
|
|
type="text"
|
|
|
|
|
|
required
|
|
|
|
|
|
value={types}
|
|
|
|
|
|
onChange={(e) => setTypes(e.target.value)}
|
|
|
|
|
|
placeholder="Fire, Flying"
|
2026-02-17 20:48:42 +01:00
|
|
|
|
className="w-full px-3 py-2 border rounded-md bg-surface-2 border-border-default"
|
2026-02-08 14:03:43 +01:00
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div>
|
2026-02-16 20:39:41 +01:00
|
|
|
|
<label className="block text-sm font-medium mb-1">Sprite URL</label>
|
2026-02-08 14:03:43 +01:00
|
|
|
|
<input
|
|
|
|
|
|
type="text"
|
|
|
|
|
|
value={spriteUrl}
|
|
|
|
|
|
onChange={(e) => setSpriteUrl(e.target.value)}
|
|
|
|
|
|
placeholder="Optional"
|
2026-02-17 20:48:42 +01:00
|
|
|
|
className="w-full px-3 py-2 border rounded-md bg-surface-2 border-border-default"
|
2026-02-08 14:03:43 +01:00
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-02-17 20:48:42 +01:00
|
|
|
|
<div className="px-6 py-4 border-t border-border-default flex items-center gap-3 shrink-0">
|
2026-02-08 14:03:43 +01:00
|
|
|
|
{onDelete && (
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
disabled={isDeleting}
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
if (confirmingDelete) {
|
|
|
|
|
|
onDelete()
|
|
|
|
|
|
} else {
|
|
|
|
|
|
setConfirmingDelete(true)
|
|
|
|
|
|
}
|
|
|
|
|
|
}}
|
|
|
|
|
|
onBlur={() => setConfirmingDelete(false)}
|
2026-02-17 20:48:42 +01:00
|
|
|
|
className="px-4 py-2 text-sm font-medium rounded-md text-status-failed border border-red-300 dark:border-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 disabled:opacity-50"
|
2026-02-08 14:03:43 +01:00
|
|
|
|
>
|
2026-02-16 20:39:41 +01:00
|
|
|
|
{isDeleting ? 'Deleting...' : confirmingDelete ? 'Confirm?' : 'Delete'}
|
2026-02-08 14:03:43 +01:00
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<div className="flex-1" />
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={onClose}
|
2026-02-17 20:48:42 +01:00
|
|
|
|
className="px-4 py-2 text-sm font-medium rounded-md border border-border-default hover:bg-surface-2"
|
2026-02-08 14:03:43 +01:00
|
|
|
|
>
|
|
|
|
|
|
Cancel
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="submit"
|
|
|
|
|
|
disabled={isSubmitting}
|
2026-02-17 20:48:42 +01:00
|
|
|
|
className="px-4 py-2 text-sm font-medium rounded-md bg-accent-600 text-white hover:bg-accent-500 disabled:opacity-50"
|
2026-02-08 14:03:43 +01:00
|
|
|
|
>
|
|
|
|
|
|
{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">
|
2026-02-17 20:48:42 +01:00
|
|
|
|
{evolutionsLoading && <p className="text-sm text-text-tertiary">Loading...</p>}
|
2026-02-16 20:39:41 +01:00
|
|
|
|
{!evolutionsLoading && (!evolutionChain || evolutionChain.length === 0) && (
|
2026-02-17 20:48:42 +01:00
|
|
|
|
<p className="text-sm text-text-tertiary">No evolutions</p>
|
2026-02-16 20:39:41 +01:00
|
|
|
|
)}
|
|
|
|
|
|
{!evolutionsLoading && evolutionChain && evolutionChain.length > 0 && (
|
|
|
|
|
|
<div className="space-y-1">
|
|
|
|
|
|
{evolutionChain.map((evo) => (
|
|
|
|
|
|
<button
|
|
|
|
|
|
key={evo.id}
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={() => setEditingEvolution(evo)}
|
2026-02-17 20:48:42 +01:00
|
|
|
|
className="w-full text-left text-sm text-text-tertiary hover:bg-surface-2 rounded px-2 py-1.5 -mx-2 transition-colors"
|
2026-02-16 20:39:41 +01:00
|
|
|
|
>
|
|
|
|
|
|
{evo.fromPokemon.name} → {evo.toPokemon.name}{' '}
|
2026-02-17 20:48:42 +01:00
|
|
|
|
<span className="text-text-muted">({formatEvolutionMethod(evo)})</span>
|
2026-02-16 20:39:41 +01:00
|
|
|
|
</button>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
2026-02-08 14:03:43 +01:00
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
2026-02-17 20:48:42 +01:00
|
|
|
|
<div className="px-6 py-4 border-t border-border-default flex justify-end shrink-0">
|
2026-02-08 14:03:43 +01:00
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={onClose}
|
2026-02-17 20:48:42 +01:00
|
|
|
|
className="px-4 py-2 text-sm font-medium rounded-md border border-border-default hover:bg-surface-2"
|
2026-02-08 14:03:43 +01:00
|
|
|
|
>
|
|
|
|
|
|
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">
|
2026-02-17 20:48:42 +01:00
|
|
|
|
{encountersLoading && <p className="text-sm text-text-tertiary">Loading...</p>}
|
2026-02-16 20:39:41 +01:00
|
|
|
|
{!encountersLoading && (!encounterLocations || encounterLocations.length === 0) && (
|
2026-02-17 20:48:42 +01:00
|
|
|
|
<p className="text-sm text-text-tertiary">No encounters</p>
|
2026-02-08 14:03:43 +01:00
|
|
|
|
)}
|
2026-02-16 20:39:41 +01:00
|
|
|
|
{!encountersLoading && encounterLocations && encounterLocations.length > 0 && (
|
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
|
{encounterLocations.map((game) => (
|
|
|
|
|
|
<div key={game.gameId}>
|
2026-02-17 20:48:42 +01:00
|
|
|
|
<div className="text-sm font-medium text-text-secondary mb-1">
|
2026-02-16 20:39:41 +01:00
|
|
|
|
{game.gameName}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="space-y-0.5 pl-2">
|
|
|
|
|
|
{game.encounters.map((enc, i) => (
|
|
|
|
|
|
<div
|
|
|
|
|
|
key={i}
|
2026-02-17 20:48:42 +01:00
|
|
|
|
className="text-sm text-text-tertiary flex items-center gap-1"
|
2026-02-16 20:39:41 +01:00
|
|
|
|
>
|
|
|
|
|
|
<Link
|
|
|
|
|
|
to={`/admin/games/${game.gameId}/routes/${enc.routeId}`}
|
2026-02-17 20:48:42 +01:00
|
|
|
|
className="text-text-link hover:underline"
|
2026-02-08 14:03:43 +01:00
|
|
|
|
>
|
2026-02-16 20:39:41 +01:00
|
|
|
|
{enc.routeName}
|
|
|
|
|
|
</Link>
|
2026-02-17 20:48:42 +01:00
|
|
|
|
<span className="text-text-muted">
|
2026-02-16 20:39:41 +01:00
|
|
|
|
— {enc.encounterMethod}, Lv. {enc.minLevel}–{enc.maxLevel}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
2026-02-08 14:03:43 +01:00
|
|
|
|
</div>
|
2026-02-16 20:39:41 +01:00
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2026-02-08 14:03:43 +01:00
|
|
|
|
</div>
|
2026-02-17 20:48:42 +01:00
|
|
|
|
<div className="px-6 py-4 border-t border-border-default flex justify-end shrink-0">
|
2026-02-08 14:03:43 +01:00
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={onClose}
|
2026-02-17 20:48:42 +01:00
|
|
|
|
className="px-4 py-2 text-sm font-medium rounded-md border border-border-default hover:bg-surface-2"
|
2026-02-08 14:03:43 +01:00
|
|
|
|
>
|
|
|
|
|
|
Close
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
2026-02-05 18:36:19 +01:00
|
|
|
|
</div>
|
2026-02-08 14:03:43 +01:00
|
|
|
|
|
|
|
|
|
|
{editingEvolution && (
|
|
|
|
|
|
<EvolutionFormModal
|
|
|
|
|
|
evolution={editingEvolution}
|
|
|
|
|
|
onSubmit={(data) =>
|
|
|
|
|
|
updateEvolution.mutate(
|
|
|
|
|
|
{ id: editingEvolution.id, data: data as UpdateEvolutionInput },
|
|
|
|
|
|
{
|
|
|
|
|
|
onSuccess: () => {
|
|
|
|
|
|
setEditingEvolution(null)
|
|
|
|
|
|
invalidateChain()
|
|
|
|
|
|
},
|
2026-02-14 16:41:24 +01:00
|
|
|
|
}
|
2026-02-08 14:03:43 +01:00
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
onClose={() => setEditingEvolution(null)}
|
|
|
|
|
|
isSubmitting={updateEvolution.isPending}
|
|
|
|
|
|
onDelete={() =>
|
|
|
|
|
|
deleteEvolution.mutate(editingEvolution.id, {
|
|
|
|
|
|
onSuccess: () => {
|
|
|
|
|
|
setEditingEvolution(null)
|
|
|
|
|
|
invalidateChain()
|
|
|
|
|
|
},
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
isDeleting={deleteEvolution.isPending}
|
2026-02-05 18:36:19 +01:00
|
|
|
|
/>
|
2026-02-08 14:03:43 +01:00
|
|
|
|
)}
|
|
|
|
|
|
</>
|
2026-02-05 18:36:19 +01:00
|
|
|
|
)
|
|
|
|
|
|
}
|