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>
This commit is contained in:
@@ -79,10 +79,7 @@ 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>
|
||||
))}
|
||||
@@ -114,9 +111,7 @@ 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">
|
||||
@@ -138,9 +133,7 @@ export function AdminTable<T>({
|
||||
key={keyFn(row)}
|
||||
onClick={onRowClick ? () => onRowClick(row) : undefined}
|
||||
className={
|
||||
onRowClick
|
||||
? 'cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800'
|
||||
: ''
|
||||
onRowClick ? 'cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800' : ''
|
||||
}
|
||||
>
|
||||
{columns.map((col) => (
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
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
|
||||
@@ -70,9 +67,7 @@ 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 ?? '')
|
||||
@@ -212,9 +207,7 @@ 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)}
|
||||
@@ -232,9 +225,7 @@ 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)}
|
||||
@@ -261,9 +252,7 @@ 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}
|
||||
|
||||
@@ -53,34 +53,22 @@ 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)))
|
||||
}
|
||||
|
||||
@@ -108,16 +96,10 @@ export function BossTeamEditor({
|
||||
}))
|
||||
}
|
||||
|
||||
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)),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -138,8 +120,9 @@ export function BossTeamEditor({
|
||||
}
|
||||
|
||||
const removeVariant = (tabIndex: number) => {
|
||||
if (variants[tabIndex].label === null) return
|
||||
if (!window.confirm(`Remove variant "${variants[tabIndex].label}"?`)) return
|
||||
const variant = variants[tabIndex]
|
||||
if (!variant || variant.label === null) return
|
||||
if (!window.confirm(`Remove variant "${variant.label}"?`)) return
|
||||
setVariants((prev) => prev.filter((_, i) => i !== tabIndex))
|
||||
setActiveTab((prev) => Math.min(prev, variants.length - 2))
|
||||
}
|
||||
@@ -148,15 +131,14 @@ export function BossTeamEditor({
|
||||
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++) {
|
||||
const p = validPokemon[i]
|
||||
if (!p?.pokemonId) continue
|
||||
allPokemon.push({
|
||||
pokemonId: validPokemon[i].pokemonId!,
|
||||
level: Number(validPokemon[i].level),
|
||||
pokemonId: p.pokemonId,
|
||||
level: Number(p.level),
|
||||
order: i + 1,
|
||||
conditionLabel,
|
||||
})
|
||||
@@ -247,11 +229,8 @@ export function BossTeamEditor({
|
||||
|
||||
<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"
|
||||
>
|
||||
{activeVariant?.pokemon.map((slot, index) => (
|
||||
<div key={`${activeTab}-${index}`} className="flex items-end gap-2">
|
||||
<div className="flex-1">
|
||||
<PokemonSelector
|
||||
label={`Pokemon ${index + 1}`}
|
||||
@@ -261,9 +240,7 @@ export function BossTeamEditor({
|
||||
/>
|
||||
</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}
|
||||
@@ -284,7 +261,7 @@ export function BossTeamEditor({
|
||||
</div>
|
||||
))}
|
||||
|
||||
{activeVariant.pokemon.length < 6 && (
|
||||
{activeVariant && activeVariant.pokemon.length < 6 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={addSlot}
|
||||
|
||||
@@ -60,9 +60,7 @@ export function BulkImportModal({
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="px-6 py-4 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">
|
||||
JSON Data
|
||||
</label>
|
||||
<label className="block text-sm font-medium mb-1">JSON Data</label>
|
||||
<textarea
|
||||
rows={12}
|
||||
value={json}
|
||||
@@ -81,8 +79,7 @@ export function BulkImportModal({
|
||||
{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}
|
||||
{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">
|
||||
|
||||
@@ -20,17 +20,9 @@ export function DeleteConfirmModal({
|
||||
<div className="fixed inset-0 bg-black/50" onClick={onCancel} />
|
||||
<div className="relative bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full mx-4">
|
||||
<div className="px-6 py-4">
|
||||
<h2 className="text-lg font-semibold text-red-600 dark:text-red-400">
|
||||
{title}
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-gray-600 dark:text-gray-300">
|
||||
{message}
|
||||
</p>
|
||||
{error && (
|
||||
<p className="mt-2 text-sm text-red-600 dark:text-red-400">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<h2 className="text-lg font-semibold text-red-600 dark:text-red-400">{title}</h2>
|
||||
<p className="mt-2 text-sm text-gray-600 dark:text-gray-300">{message}</p>
|
||||
{error && <p className="mt-2 text-sm text-red-600 dark:text-red-400">{error}</p>}
|
||||
</div>
|
||||
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3">
|
||||
<button
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
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
|
||||
@@ -29,9 +25,7 @@ export function EvolutionFormModal({
|
||||
const [fromPokemonId, setFromPokemonId] = useState<number | null>(
|
||||
evolution?.fromPokemonId ?? null
|
||||
)
|
||||
const [toPokemonId, setToPokemonId] = useState<number | null>(
|
||||
evolution?.toPokemonId ?? null
|
||||
)
|
||||
const [toPokemonId, setToPokemonId] = useState<number | null>(evolution?.toPokemonId ?? null)
|
||||
const [trigger, setTrigger] = useState(evolution?.trigger ?? 'level-up')
|
||||
const [minLevel, setMinLevel] = useState(String(evolution?.minLevel ?? ''))
|
||||
const [item, setItem] = useState(evolution?.item ?? '')
|
||||
|
||||
@@ -5,11 +5,11 @@ interface FormModalProps {
|
||||
onClose: () => void
|
||||
onSubmit: (e: FormEvent) => void
|
||||
children: ReactNode
|
||||
submitLabel?: string
|
||||
isSubmitting?: boolean
|
||||
onDelete?: () => void
|
||||
isDeleting?: boolean
|
||||
headerExtra?: ReactNode
|
||||
submitLabel?: string | undefined
|
||||
isSubmitting?: boolean | undefined
|
||||
onDelete?: (() => void) | undefined
|
||||
isDeleting?: boolean | undefined
|
||||
headerExtra?: ReactNode | undefined
|
||||
}
|
||||
|
||||
export function FormModal({
|
||||
@@ -55,11 +55,7 @@ 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" />
|
||||
|
||||
@@ -34,9 +34,7 @@ export function GameFormModal({
|
||||
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(() => {
|
||||
@@ -65,10 +63,7 @@ export function GameFormModal({
|
||||
isDeleting={isDeleting}
|
||||
headerExtra={
|
||||
detailUrl ? (
|
||||
<Link
|
||||
to={detailUrl}
|
||||
className="text-sm text-blue-600 dark:text-blue-400 hover:underline"
|
||||
>
|
||||
<Link to={detailUrl} className="text-sm text-blue-600 dark:text-blue-400 hover:underline">
|
||||
View Routes & Bosses
|
||||
</Link>
|
||||
) : undefined
|
||||
|
||||
@@ -9,10 +9,7 @@ import type {
|
||||
EvolutionAdmin,
|
||||
UpdateEvolutionInput,
|
||||
} from '../../types'
|
||||
import {
|
||||
usePokemonEncounterLocations,
|
||||
usePokemonEvolutionChain,
|
||||
} from '../../hooks/usePokemon'
|
||||
import { usePokemonEncounterLocations, usePokemonEvolutionChain } from '../../hooks/usePokemon'
|
||||
import { useUpdateEvolution, useDeleteEvolution } from '../../hooks/useAdmin'
|
||||
import { formatEvolutionMethod } from '../../utils/formatEvolution'
|
||||
|
||||
@@ -36,23 +33,19 @@ export function PokemonFormModal({
|
||||
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: evolutionChain, isLoading: evolutionsLoading } = usePokemonEvolutionChain(pokemonId)
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
const updateEvolution = useUpdateEvolution()
|
||||
@@ -103,9 +96,7 @@ export function PokemonFormModal({
|
||||
<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) => (
|
||||
@@ -124,15 +115,10 @@ export function PokemonFormModal({
|
||||
|
||||
{/* 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
|
||||
@@ -143,9 +129,7 @@ export function PokemonFormModal({
|
||||
/>
|
||||
</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
|
||||
@@ -166,9 +150,7 @@ export function PokemonFormModal({
|
||||
/>
|
||||
</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
|
||||
@@ -179,9 +161,7 @@ export function PokemonFormModal({
|
||||
/>
|
||||
</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}
|
||||
@@ -206,11 +186,7 @@ export function PokemonFormModal({
|
||||
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" />
|
||||
@@ -237,35 +213,28 @@ export function PokemonFormModal({
|
||||
<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>
|
||||
<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>
|
||||
)}
|
||||
{!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
|
||||
@@ -284,48 +253,40 @@ export function PokemonFormModal({
|
||||
<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"
|
||||
>
|
||||
{enc.routeName}
|
||||
</Link>
|
||||
<span className="text-gray-400 dark:text-gray-500">
|
||||
— {enc.encounterMethod}, Lv. {enc.minLevel}–
|
||||
{enc.maxLevel}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{!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>
|
||||
)}
|
||||
<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
|
||||
|
||||
@@ -4,7 +4,7 @@ import { usePokemonList } from '../../hooks/useAdmin'
|
||||
interface PokemonSelectorProps {
|
||||
label: string
|
||||
selectedId: number | null
|
||||
initialName?: string
|
||||
initialName?: string | undefined
|
||||
onChange: (id: number | null) => void
|
||||
}
|
||||
|
||||
@@ -46,9 +46,7 @@ export function PokemonSelector({
|
||||
placeholder="Search pokemon..."
|
||||
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
|
||||
/>
|
||||
{selectedId && (
|
||||
<input type="hidden" name={label} value={selectedId} required />
|
||||
)}
|
||||
{selectedId && <input type="hidden" name={label} value={selectedId} required />}
|
||||
{open && pokemon.length > 0 && (
|
||||
<ul className="absolute z-10 mt-1 w-full bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-md shadow-lg max-h-48 overflow-y-auto">
|
||||
{pokemon.map((p) => (
|
||||
@@ -63,9 +61,7 @@ export function PokemonSelector({
|
||||
p.id === selectedId ? 'bg-blue-50 dark:bg-blue-900/30' : ''
|
||||
}`}
|
||||
>
|
||||
{p.spriteUrl && (
|
||||
<img src={p.spriteUrl} alt="" className="w-6 h-6" />
|
||||
)}
|
||||
{p.spriteUrl && <img src={p.spriteUrl} alt="" className="w-6 h-6" />}
|
||||
<span>
|
||||
#{p.nationalDex} {p.name}
|
||||
</span>
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { type FormEvent, useState } from 'react'
|
||||
import { FormModal } from './FormModal'
|
||||
import { PokemonSelector } from './PokemonSelector'
|
||||
import {
|
||||
METHOD_ORDER,
|
||||
METHOD_CONFIG,
|
||||
getMethodLabel,
|
||||
} from '../EncounterMethodBadge'
|
||||
import { METHOD_ORDER, METHOD_CONFIG, getMethodLabel } from '../EncounterMethodBadge'
|
||||
import type {
|
||||
RouteEncounterDetail,
|
||||
CreateRouteEncounterInput,
|
||||
@@ -14,9 +10,7 @@ import type {
|
||||
|
||||
interface RouteEncounterFormModalProps {
|
||||
encounter?: RouteEncounterDetail
|
||||
onSubmit: (
|
||||
data: CreateRouteEncounterInput | UpdateRouteEncounterInput
|
||||
) => void
|
||||
onSubmit: (data: CreateRouteEncounterInput | UpdateRouteEncounterInput) => void
|
||||
onClose: () => void
|
||||
isSubmitting?: boolean
|
||||
onDelete?: () => void
|
||||
@@ -38,15 +32,10 @@ export function RouteEncounterFormModal({
|
||||
const [selectedMethod, setSelectedMethod] = useState(
|
||||
isKnownMethod ? initialMethod : initialMethod ? 'other' : ''
|
||||
)
|
||||
const [customMethod, setCustomMethod] = useState(
|
||||
isKnownMethod ? '' : initialMethod
|
||||
)
|
||||
const encounterMethod =
|
||||
selectedMethod === 'other' ? customMethod : selectedMethod
|
||||
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 ?? ''))
|
||||
|
||||
@@ -87,9 +76,7 @@ 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}
|
||||
@@ -126,9 +113,7 @@ 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
|
||||
|
||||
@@ -49,10 +49,7 @@ export function RouteFormModal({
|
||||
isDeleting={isDeleting}
|
||||
headerExtra={
|
||||
detailUrl ? (
|
||||
<Link
|
||||
to={detailUrl}
|
||||
className="text-sm text-blue-600 dark:text-blue-400 hover:underline"
|
||||
>
|
||||
<Link to={detailUrl} className="text-sm text-blue-600 dark:text-blue-400 hover:underline">
|
||||
View Encounters
|
||||
</Link>
|
||||
) : undefined
|
||||
@@ -90,8 +87,7 @@ export function RouteFormModal({
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user