Files
nuzlocke-tracker/frontend/src/components/admin/PokemonFormModal.tsx
Julian Tabel 3a64661760
Some checks failed
CI / backend-lint (push) Failing after 1m4s
CI / actions-lint (push) Failing after 6s
CI / frontend-lint (push) Successful in 59s
Align repo config with global development standards
- Add missing tsconfig strictness flags (noUncheckedIndexedAccess,
  exactOptionalPropertyTypes, noImplicitOverride,
  noPropertyAccessFromIndexSignature) and fix all resulting type errors
- Replace ESLint/Prettier with oxlint 1.48.0 and oxfmt 0.33.0
- Pin all frontend and backend dependencies to exact versions
- Pin GitHub Actions to SHA hashes with persist-credentials: false
- Fix CI Python version mismatch (3.12 -> 3.14) and ruff target-version
- Add vitest 4.0.18 with jsdom environment for frontend testing
- Add ty 0.0.17 for Python type checking (non-blocking in CI)
- Add actionlint and zizmor CI job for workflow linting and security audit
- Add Dependabot config for npm, pip, and github-actions
- Update CLAUDE.md and pre-commit hooks to reflect new tooling
- Ignore Claude Code sandbox artifacts in gitignore

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 20:39:41 +01:00

335 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
onSubmit: (data: CreatePokemonInput | UpdatePokemonInput) => void
onClose: () => void
isSubmitting?: boolean
onDelete?: () => void
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()
const typesList = types
.split(',')
.map((t) => t.trim())
.filter(Boolean)
onSubmit({
pokeapiId: Number(pokeapiId),
nationalDex: Number(nationalDex),
name,
types: typesList,
spriteUrl: spriteUrl || null,
})
}
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 (
<>
<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>
{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}
/>
)}
</>
)
}