Add pre-commit hooks for linting and formatting
All checks were successful
CI / backend-lint (push) Successful in 9s
CI / frontend-lint (push) Successful in 33s

Set up pre-commit framework with ruff (backend) and ESLint/Prettier/tsc
(frontend) hooks to catch issues locally before CI. Auto-format all
frontend files with Prettier to comply with the new check.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-14 16:41:24 +01:00
parent b05a75f7f2
commit 2963f16aa4
67 changed files with 1905 additions and 792 deletions

View File

@@ -79,7 +79,10 @@ export function AdminTable<T>({
{Array.from({ length: 5 }).map((_, i) => (
<tr key={i}>
{columns.map((col) => (
<td key={col.header} className={`px-4 py-3 ${col.className ?? ''}`}>
<td
key={col.header}
className={`px-4 py-3 ${col.className ?? ''}`}
>
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
</td>
))}
@@ -111,7 +114,9 @@ export function AdminTable<T>({
return (
<th
key={col.header}
onClick={sortable ? () => handleSort(col.header) : undefined}
onClick={
sortable ? () => handleSort(col.header) : undefined
}
className={`px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider ${col.className ?? ''} ${sortable ? 'cursor-pointer select-none hover:text-gray-700 dark:hover:text-gray-200' : ''}`}
>
<span className="inline-flex items-center gap-1">
@@ -132,7 +137,11 @@ export function AdminTable<T>({
<tr
key={keyFn(row)}
onClick={onRowClick ? () => onRowClick(row) : undefined}
className={onRowClick ? 'cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800' : ''}
className={
onRowClick
? 'cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800'
: ''
}
>
{columns.map((col) => (
<td

View File

@@ -1,7 +1,10 @@
import { type FormEvent, useState } from 'react'
import { FormModal } from './FormModal'
import type { BossBattle, Game, Route } from '../../types/game'
import type { CreateBossBattleInput, UpdateBossBattleInput } from '../../types/admin'
import type {
CreateBossBattleInput,
UpdateBossBattleInput,
} from '../../types/admin'
interface BossBattleFormModalProps {
boss?: BossBattle
@@ -17,9 +20,24 @@ interface BossBattleFormModalProps {
}
const POKEMON_TYPES = [
'normal', 'fire', 'water', 'electric', 'grass', 'ice',
'fighting', 'poison', 'ground', 'flying', 'psychic', 'bug',
'rock', 'ghost', 'dragon', 'dark', 'steel', 'fairy',
'normal',
'fire',
'water',
'electric',
'grass',
'ice',
'fighting',
'poison',
'ground',
'flying',
'psychic',
'bug',
'rock',
'ghost',
'dragon',
'dark',
'steel',
'fairy',
]
const BOSS_TYPES = [
@@ -52,7 +70,9 @@ export function BossBattleFormModal({
const [badgeImageUrl, setBadgeImageUrl] = useState(boss?.badgeImageUrl ?? '')
const [levelCap, setLevelCap] = useState(String(boss?.levelCap ?? ''))
const [order, setOrder] = useState(String(boss?.order ?? nextOrder))
const [afterRouteId, setAfterRouteId] = useState(String(boss?.afterRouteId ?? ''))
const [afterRouteId, setAfterRouteId] = useState(
String(boss?.afterRouteId ?? '')
)
const [location, setLocation] = useState(boss?.location ?? '')
const [section, setSection] = useState(boss?.section ?? '')
const [spriteUrl, setSpriteUrl] = useState(boss?.spriteUrl ?? '')
@@ -87,15 +107,17 @@ export function BossBattleFormModal({
isSubmitting={isSubmitting}
onDelete={onDelete}
isDeleting={isDeleting}
headerExtra={onEditTeam ? (
<button
type="button"
onClick={onEditTeam}
className="text-sm text-blue-600 dark:text-blue-400 hover:underline"
>
Edit Team ({boss?.pokemon.length ?? 0})
</button>
) : undefined}
headerExtra={
onEditTeam ? (
<button
type="button"
onClick={onEditTeam}
className="text-sm text-blue-600 dark:text-blue-400 hover:underline"
>
Edit Team ({boss?.pokemon.length ?? 0})
</button>
) : undefined
}
>
<div className="grid grid-cols-3 gap-4">
<div>
@@ -190,7 +212,9 @@ export function BossBattleFormModal({
</div>
{games && games.length > 1 && (
<div>
<label className="block text-sm font-medium mb-1">Game (version exclusive)</label>
<label className="block text-sm font-medium mb-1">
Game (version exclusive)
</label>
<select
value={gameId}
onChange={(e) => setGameId(e.target.value)}
@@ -208,7 +232,9 @@ export function BossBattleFormModal({
</div>
<div>
<label className="block text-sm font-medium mb-1">Position After Route</label>
<label className="block text-sm font-medium mb-1">
Position After Route
</label>
<select
value={afterRouteId}
onChange={(e) => setAfterRouteId(e.target.value)}
@@ -235,7 +261,9 @@ export function BossBattleFormModal({
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Badge Image URL</label>
<label className="block text-sm font-medium mb-1">
Badge Image URL
</label>
<input
type="text"
value={badgeImageUrl}

View File

@@ -38,7 +38,12 @@ function groupByVariant(boss: BossBattle): Variant[] {
}
if (map.size === 0) {
return [{ label: null, pokemon: [{ pokemonId: null, pokemonName: '', level: '', order: 1 }] }]
return [
{
label: null,
pokemon: [{ pokemonId: null, pokemonName: '', level: '', order: 1 }],
},
]
}
const variants: Variant[] = []
@@ -48,43 +53,71 @@ function groupByVariant(boss: BossBattle): Variant[] {
map.delete(null)
}
// Then alphabetical
const remaining = [...map.entries()].sort((a, b) => (a[0] ?? '').localeCompare(b[0] ?? ''))
const remaining = [...map.entries()].sort((a, b) =>
(a[0] ?? '').localeCompare(b[0] ?? '')
)
for (const [label, pokemon] of remaining) {
variants.push({ label, pokemon })
}
return variants
}
export function BossTeamEditor({ boss, onSave, onClose, isSaving }: BossTeamEditorProps) {
const [variants, setVariants] = useState<Variant[]>(() => groupByVariant(boss))
export function BossTeamEditor({
boss,
onSave,
onClose,
isSaving,
}: BossTeamEditorProps) {
const [variants, setVariants] = useState<Variant[]>(() =>
groupByVariant(boss)
)
const [activeTab, setActiveTab] = useState(0)
const [newVariantName, setNewVariantName] = useState('')
const [showAddVariant, setShowAddVariant] = useState(false)
const activeVariant = variants[activeTab] ?? variants[0]
const updateVariant = (tabIndex: number, updater: (v: Variant) => Variant) => {
const updateVariant = (
tabIndex: number,
updater: (v: Variant) => Variant
) => {
setVariants((prev) => prev.map((v, i) => (i === tabIndex ? updater(v) : v)))
}
const addSlot = () => {
updateVariant(activeTab, (v) => ({
...v,
pokemon: [...v.pokemon, { pokemonId: null, pokemonName: '', level: '', order: v.pokemon.length + 1 }],
pokemon: [
...v.pokemon,
{
pokemonId: null,
pokemonName: '',
level: '',
order: v.pokemon.length + 1,
},
],
}))
}
const removeSlot = (index: number) => {
updateVariant(activeTab, (v) => ({
...v,
pokemon: v.pokemon.filter((_, i) => i !== index).map((item, i) => ({ ...item, order: i + 1 })),
pokemon: v.pokemon
.filter((_, i) => i !== index)
.map((item, i) => ({ ...item, order: i + 1 })),
}))
}
const updateSlot = (index: number, field: string, value: number | string | null) => {
const updateSlot = (
index: number,
field: string,
value: number | string | null
) => {
updateVariant(activeTab, (v) => ({
...v,
pokemon: v.pokemon.map((item, i) => (i === index ? { ...item, [field]: value } : item)),
pokemon: v.pokemon.map((item, i) =>
i === index ? { ...item, [field]: value } : item
),
}))
}
@@ -92,7 +125,13 @@ export function BossTeamEditor({ boss, onSave, onClose, isSaving }: BossTeamEdit
const name = newVariantName.trim()
if (!name) return
if (variants.some((v) => v.label === name)) return
setVariants((prev) => [...prev, { label: name, pokemon: [{ pokemonId: null, pokemonName: '', level: '', order: 1 }] }])
setVariants((prev) => [
...prev,
{
label: name,
pokemon: [{ pokemonId: null, pokemonName: '', level: '', order: 1 }],
},
])
setActiveTab(variants.length)
setNewVariantName('')
setShowAddVariant(false)
@@ -109,8 +148,11 @@ export function BossTeamEditor({ boss, onSave, onClose, isSaving }: BossTeamEdit
e.preventDefault()
const allPokemon: BossPokemonInput[] = []
for (const variant of variants) {
const conditionLabel = variants.length === 1 && variant.label === null ? null : variant.label
const validPokemon = variant.pokemon.filter((t) => t.pokemonId != null && t.level)
const conditionLabel =
variants.length === 1 && variant.label === null ? null : variant.label
const validPokemon = variant.pokemon.filter(
(t) => t.pokemonId != null && t.level
)
for (let i = 0; i < validPokemon.length; i++) {
allPokemon.push({
pokemonId: validPokemon[i].pokemonId!,
@@ -147,7 +189,10 @@ export function BossTeamEditor({ boss, onSave, onClose, isSaving }: BossTeamEdit
{v.label ?? 'Default'}
{v.label !== null && (
<span
onClick={(e) => { e.stopPropagation(); removeVariant(i) }}
onClick={(e) => {
e.stopPropagation()
removeVariant(i)
}}
className="ml-1.5 text-gray-400 hover:text-red-500 cursor-pointer"
title="Remove variant"
>
@@ -171,13 +216,31 @@ export function BossTeamEditor({ boss, onSave, onClose, isSaving }: BossTeamEdit
type="text"
value={newVariantName}
onChange={(e) => setNewVariantName(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addVariant() } if (e.key === 'Escape') setShowAddVariant(false) }}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
addVariant()
}
if (e.key === 'Escape') setShowAddVariant(false)
}}
placeholder="Variant name..."
className="px-2 py-1 text-sm border rounded dark:bg-gray-700 dark:border-gray-600 w-40"
autoFocus
/>
<button type="button" onClick={addVariant} className="px-2 py-1 text-sm text-blue-600 dark:text-blue-400">Add</button>
<button type="button" onClick={() => setShowAddVariant(false)} className="px-1 py-1 text-sm text-gray-400">&#10005;</button>
<button
type="button"
onClick={addVariant}
className="px-2 py-1 text-sm text-blue-600 dark:text-blue-400"
>
Add
</button>
<button
type="button"
onClick={() => setShowAddVariant(false)}
className="px-1 py-1 text-sm text-gray-400"
>
&#10005;
</button>
</div>
)}
</div>
@@ -185,7 +248,10 @@ export function BossTeamEditor({ boss, onSave, onClose, isSaving }: BossTeamEdit
<form onSubmit={handleSubmit}>
<div className="px-6 py-4 space-y-3">
{activeVariant.pokemon.map((slot, index) => (
<div key={`${activeTab}-${index}`} className="flex items-end gap-2">
<div
key={`${activeTab}-${index}`}
className="flex items-end gap-2"
>
<div className="flex-1">
<PokemonSelector
label={`Pokemon ${index + 1}`}
@@ -195,7 +261,9 @@ export function BossTeamEditor({ boss, onSave, onClose, isSaving }: BossTeamEdit
/>
</div>
<div className="w-20">
<label className="block text-sm font-medium mb-1">Level</label>
<label className="block text-sm font-medium mb-1">
Level
</label>
<input
type="number"
min={1}

View File

@@ -12,7 +12,14 @@ interface BulkImportModalProps {
updatedLabel?: string
}
export function BulkImportModal({ title, example, onSubmit, onClose, createdLabel = 'Created', updatedLabel = 'Updated' }: 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)
@@ -73,7 +80,10 @@ export function BulkImportModal({ title, example, onSubmit, onClose, createdLabe
{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>{createdLabel}: {result.created}, {updatedLabel}: {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) => (

View File

@@ -1,7 +1,11 @@
import { type FormEvent, useState } from 'react'
import { FormModal } from './FormModal'
import { PokemonSelector } from './PokemonSelector'
import type { EvolutionAdmin, CreateEvolutionInput, UpdateEvolutionInput } from '../../types'
import type {
EvolutionAdmin,
CreateEvolutionInput,
UpdateEvolutionInput,
} from '../../types'
interface EvolutionFormModalProps {
evolution?: EvolutionAdmin
@@ -23,10 +27,10 @@ export function EvolutionFormModal({
isDeleting,
}: EvolutionFormModalProps) {
const [fromPokemonId, setFromPokemonId] = useState<number | null>(
evolution?.fromPokemonId ?? null,
evolution?.fromPokemonId ?? null
)
const [toPokemonId, setToPokemonId] = useState<number | null>(
evolution?.toPokemonId ?? null,
evolution?.toPokemonId ?? null
)
const [trigger, setTrigger] = useState(evolution?.trigger ?? 'level-up')
const [minLevel, setMinLevel] = useState(String(evolution?.minLevel ?? ''))

View File

@@ -55,7 +55,11 @@ export function FormModal({
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'}
{isDeleting
? 'Deleting...'
: confirmingDelete
? 'Confirm?'
: 'Delete'}
</button>
)}
<div className="flex-1" />

View File

@@ -20,13 +20,23 @@ function slugify(name: string) {
.replace(/^-|-$/g, '')
}
export function GameFormModal({ game, onSubmit, onClose, isSubmitting, onDelete, isDeleting, detailUrl }: GameFormModalProps) {
export function GameFormModal({
game,
onSubmit,
onClose,
isSubmitting,
onDelete,
isDeleting,
detailUrl,
}: GameFormModalProps) {
const [name, setName] = useState(game?.name ?? '')
const [slug, setSlug] = useState(game?.slug ?? '')
const [generation, setGeneration] = useState(String(game?.generation ?? ''))
const [region, setRegion] = useState(game?.region ?? '')
const [boxArtUrl, setBoxArtUrl] = useState(game?.boxArtUrl ?? '')
const [releaseYear, setReleaseYear] = useState(game?.releaseYear ? String(game.releaseYear) : '')
const [releaseYear, setReleaseYear] = useState(
game?.releaseYear ? String(game.releaseYear) : ''
)
const [autoSlug, setAutoSlug] = useState(!game)
useEffect(() => {
@@ -53,14 +63,16 @@ export function GameFormModal({ game, onSubmit, onClose, isSubmitting, onDelete,
isSubmitting={isSubmitting}
onDelete={onDelete}
isDeleting={isDeleting}
headerExtra={detailUrl ? (
<Link
to={detailUrl}
className="text-sm text-blue-600 dark:text-blue-400 hover:underline"
>
View Routes & Bosses
</Link>
) : undefined}
headerExtra={
detailUrl ? (
<Link
to={detailUrl}
className="text-sm text-blue-600 dark:text-blue-400 hover:underline"
>
View Routes & Bosses
</Link>
) : undefined
}
>
<div>
<label className="block text-sm font-medium mb-1">Name</label>

View File

@@ -2,8 +2,17 @@ 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 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'
@@ -18,20 +27,32 @@ interface PokemonFormModalProps {
type Tab = 'details' | 'evolutions' | 'encounters'
export function PokemonFormModal({ pokemon, onSubmit, onClose, isSubmitting, onDelete, isDeleting }: PokemonFormModalProps) {
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 [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 [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 { data: encounterLocations, isLoading: encountersLoading } =
usePokemonEncounterLocations(pokemonId)
const { data: evolutionChain, isLoading: evolutionsLoading } =
usePokemonEvolutionChain(pokemonId)
const queryClient = useQueryClient()
const updateEvolution = useUpdateEvolution()
@@ -42,7 +63,9 @@ export function PokemonFormModal({ pokemon, onSubmit, onClose, isSubmitting, onD
}, [onDelete])
const invalidateChain = () => {
queryClient.invalidateQueries({ queryKey: ['pokemon', pokemonId, 'evolution-chain'] })
queryClient.invalidateQueries({
queryKey: ['pokemon', pokemonId, 'evolution-chain'],
})
}
const handleSubmit = (e: FormEvent) => {
@@ -80,7 +103,9 @@ export function PokemonFormModal({ pokemon, onSubmit, onClose, isSubmitting, onD
<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>
<h2 className="text-lg font-semibold">
{pokemon ? 'Edit Pokemon' : 'Add Pokemon'}
</h2>
{isEdit && (
<div className="flex gap-1 mt-2">
{tabs.map((tab) => (
@@ -99,10 +124,15 @@ export function PokemonFormModal({ pokemon, onSubmit, onClose, isSubmitting, onD
{/* Details tab (form) */}
{activeTab === 'details' && (
<form onSubmit={handleSubmit} className="flex flex-col min-h-0 flex-1">
<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>
<label className="block text-sm font-medium mb-1">
PokeAPI ID
</label>
<input
type="number"
required
@@ -113,7 +143,9 @@ export function PokemonFormModal({ pokemon, onSubmit, onClose, isSubmitting, onD
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">National Dex #</label>
<label className="block text-sm font-medium mb-1">
National Dex #
</label>
<input
type="number"
required
@@ -134,7 +166,9 @@ export function PokemonFormModal({ pokemon, onSubmit, onClose, isSubmitting, onD
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Types (comma-separated)</label>
<label className="block text-sm font-medium mb-1">
Types (comma-separated)
</label>
<input
type="text"
required
@@ -145,7 +179,9 @@ export function PokemonFormModal({ pokemon, onSubmit, onClose, isSubmitting, onD
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Sprite URL</label>
<label className="block text-sm font-medium mb-1">
Sprite URL
</label>
<input
type="text"
value={spriteUrl}
@@ -170,7 +206,11 @@ export function PokemonFormModal({ pokemon, onSubmit, onClose, isSubmitting, onD
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'}
{isDeleting
? 'Deleting...'
: confirmingDelete
? 'Confirm?'
: 'Delete'}
</button>
)}
<div className="flex-1" />
@@ -197,28 +237,35 @@ export function PokemonFormModal({ pokemon, onSubmit, onClose, isSubmitting, onD
<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>
<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
@@ -237,37 +284,48 @@ export function PokemonFormModal({ pokemon, onSubmit, onClose, isSubmitting, onD
<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>
<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"
{!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"
>
{enc.routeName}
</Link>
<span className="text-gray-400 dark:text-gray-500">
{enc.encounterMethod}, Lv. {enc.minLevel}{enc.maxLevel}
</span>
</div>
))}
<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>
)}
</div>
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end shrink-0">
<button
@@ -294,7 +352,7 @@ export function PokemonFormModal({ pokemon, onSubmit, onClose, isSubmitting, onD
setEditingEvolution(null)
invalidateChain()
},
},
}
)
}
onClose={() => setEditingEvolution(null)}

View File

@@ -1,12 +1,22 @@
import { type FormEvent, useState } from 'react'
import { FormModal } from './FormModal'
import { PokemonSelector } from './PokemonSelector'
import { METHOD_ORDER, METHOD_CONFIG, getMethodLabel } from '../EncounterMethodBadge'
import type { RouteEncounterDetail, CreateRouteEncounterInput, UpdateRouteEncounterInput } from '../../types'
import {
METHOD_ORDER,
METHOD_CONFIG,
getMethodLabel,
} from '../EncounterMethodBadge'
import type {
RouteEncounterDetail,
CreateRouteEncounterInput,
UpdateRouteEncounterInput,
} from '../../types'
interface RouteEncounterFormModalProps {
encounter?: RouteEncounterDetail
onSubmit: (data: CreateRouteEncounterInput | UpdateRouteEncounterInput) => void
onSubmit: (
data: CreateRouteEncounterInput | UpdateRouteEncounterInput
) => void
onClose: () => void
isSubmitting?: boolean
onDelete?: () => void
@@ -25,11 +35,18 @@ export function RouteEncounterFormModal({
const initialMethod = encounter?.encounterMethod ?? ''
const isKnownMethod = METHOD_ORDER.includes(initialMethod)
const [selectedMethod, setSelectedMethod] = useState(isKnownMethod ? initialMethod : initialMethod ? 'other' : '')
const [customMethod, setCustomMethod] = useState(isKnownMethod ? '' : initialMethod)
const encounterMethod = selectedMethod === 'other' ? customMethod : selectedMethod
const [selectedMethod, setSelectedMethod] = useState(
isKnownMethod ? initialMethod : initialMethod ? 'other' : ''
)
const [customMethod, setCustomMethod] = useState(
isKnownMethod ? '' : initialMethod
)
const encounterMethod =
selectedMethod === 'other' ? customMethod : selectedMethod
const [encounterRate, setEncounterRate] = useState(String(encounter?.encounterRate ?? ''))
const [encounterRate, setEncounterRate] = useState(
String(encounter?.encounterRate ?? '')
)
const [minLevel, setMinLevel] = useState(String(encounter?.minLevel ?? ''))
const [maxLevel, setMaxLevel] = useState(String(encounter?.maxLevel ?? ''))
@@ -70,7 +87,9 @@ export function RouteEncounterFormModal({
/>
)}
<div>
<label className="block text-sm font-medium mb-1">Encounter Method</label>
<label className="block text-sm font-medium mb-1">
Encounter Method
</label>
<select
required
value={selectedMethod}
@@ -107,7 +126,9 @@ export function RouteEncounterFormModal({
)}
</div>
<div>
<label className="block text-sm font-medium mb-1">Encounter Rate (%)</label>
<label className="block text-sm font-medium mb-1">
Encounter Rate (%)
</label>
<input
type="number"
required

View File

@@ -14,7 +14,16 @@ interface RouteFormModalProps {
detailUrl?: string
}
export function RouteFormModal({ route, nextOrder, onSubmit, onClose, isSubmitting, onDelete, isDeleting, detailUrl }: RouteFormModalProps) {
export function RouteFormModal({
route,
nextOrder,
onSubmit,
onClose,
isSubmitting,
onDelete,
isDeleting,
detailUrl,
}: RouteFormModalProps) {
const [name, setName] = useState(route?.name ?? '')
const [order, setOrder] = useState(String(route?.order ?? nextOrder ?? 0))
const [pinwheelZone, setPinwheelZone] = useState(
@@ -38,14 +47,16 @@ export function RouteFormModal({ route, nextOrder, onSubmit, onClose, isSubmitti
isSubmitting={isSubmitting}
onDelete={onDelete}
isDeleting={isDeleting}
headerExtra={detailUrl ? (
<Link
to={detailUrl}
className="text-sm text-blue-600 dark:text-blue-400 hover:underline"
>
View Encounters
</Link>
) : undefined}
headerExtra={
detailUrl ? (
<Link
to={detailUrl}
className="text-sm text-blue-600 dark:text-blue-400 hover:underline"
>
View Encounters
</Link>
) : undefined
}
>
<div>
<label className="block text-sm font-medium mb-1">Name</label>
@@ -79,7 +90,8 @@ export function RouteFormModal({ route, nextOrder, onSubmit, onClose, isSubmitti
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
/>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Routes in the same zone share an encounter when the Pinwheel Clause is active
Routes in the same zone share an encounter when the Pinwheel Clause is
active
</p>
</div>
</FormModal>