Align repo config with global development standards
Some checks failed
CI / backend-lint (push) Failing after 1m4s
CI / actions-lint (push) Failing after 6s
CI / frontend-lint (push) Successful in 59s

- 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:
2026-02-16 20:39:41 +01:00
parent e4814250db
commit 3a64661760
91 changed files with 2073 additions and 3215 deletions

View File

@@ -10,14 +10,11 @@ interface BossDefeatModalProps {
starterName?: string | null
}
function matchVariant(
labels: string[],
starterName?: string | null
): string | null {
function matchVariant(labels: string[], starterName?: string | null): string | null {
if (!starterName || labels.length === 0) return null
const lower = starterName.toLowerCase()
const matches = labels.filter((l) => l.toLowerCase().includes(lower))
return matches.length === 1 ? matches[0] : null
return matches.length === 1 ? (matches[0] ?? null) : null
}
export function BossDefeatModal({
@@ -46,14 +43,13 @@ export function BossDefeatModal({
)
const showPills = hasVariants && autoMatch === null
const [selectedVariant, setSelectedVariant] = useState<string | null>(
autoMatch ?? (hasVariants ? variantLabels[0] : null)
autoMatch ?? (hasVariants ? (variantLabels[0] ?? null) : null)
)
const displayedPokemon = useMemo(() => {
if (!hasVariants) return boss.pokemon
return boss.pokemon.filter(
(bp) =>
bp.conditionLabel === selectedVariant || bp.conditionLabel === null
(bp) => bp.conditionLabel === selectedVariant || bp.conditionLabel === null
)
}, [boss.pokemon, hasVariants, selectedVariant])
@@ -72,9 +68,7 @@ export function BossDefeatModal({
<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 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold">Battle: {boss.name}</h2>
<p className="text-sm text-gray-500 dark:text-gray-400">
{boss.location}
</p>
<p className="text-sm text-gray-500 dark:text-gray-400">{boss.location}</p>
</div>
{/* Boss team preview */}
@@ -104,11 +98,7 @@ export function BossDefeatModal({
.map((bp) => (
<div key={bp.id} className="flex flex-col items-center">
{bp.pokemon.spriteUrl ? (
<img
src={bp.pokemon.spriteUrl}
alt={bp.pokemon.name}
className="w-10 h-10"
/>
<img src={bp.pokemon.spriteUrl} alt={bp.pokemon.name} className="w-10 h-10" />
) : (
<div className="w-10 h-10 bg-gray-200 dark:bg-gray-700 rounded-full" />
)}
@@ -158,9 +148,7 @@ export function BossDefeatModal({
{!hardcoreMode && (
<div>
<label className="block text-sm font-medium mb-1">
Attempts
</label>
<label className="block text-sm font-medium mb-1">Attempts</label>
<input
type="number"
min={1}

View File

@@ -7,9 +7,9 @@ interface EggEncounterModalProps {
onSubmit: (data: {
routeId: number
pokemonId: number
nickname?: string
nickname?: string | undefined
status: 'caught'
catchLevel?: number
catchLevel?: number | undefined
origin: 'egg'
}) => void
onClose: () => void
@@ -87,12 +87,7 @@ export function EggEncounterModal({
onClick={onClose}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
@@ -142,7 +137,7 @@ export function EggEncounterModal({
/>
) : (
<div className="w-10 h-10 rounded-full bg-gray-200 dark:bg-gray-600 flex items-center justify-center text-xs font-bold">
{selectedPokemon.name[0].toUpperCase()}
{selectedPokemon.name[0]?.toUpperCase()}
</div>
)}
<span className="font-medium text-gray-900 dark:text-gray-100 capitalize">
@@ -183,14 +178,10 @@ export function EggEncounterModal({
className="flex flex-col items-center p-2 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-green-400 dark:hover:border-green-600 text-center transition-colors"
>
{p.spriteUrl ? (
<img
src={p.spriteUrl}
alt={p.name}
className="w-10 h-10"
/>
<img src={p.spriteUrl} alt={p.name} className="w-10 h-10" />
) : (
<div className="w-10 h-10 rounded-full bg-gray-200 dark:bg-gray-600 flex items-center justify-center text-xs font-bold">
{p.name[0].toUpperCase()}
{p.name[0]?.toUpperCase()}
</div>
)}
<span className="text-xs text-gray-700 dark:text-gray-300 mt-1 capitalize">
@@ -200,13 +191,9 @@ export function EggEncounterModal({
))}
</div>
)}
{search.length >= 2 &&
!isSearching &&
searchResults.length === 0 && (
<p className="text-sm text-gray-500 dark:text-gray-400 py-2">
No pokemon found
</p>
)}
{search.length >= 2 && !isSearching && searchResults.length === 0 && (
<p className="text-sm text-gray-500 dark:text-gray-400 py-2">No pokemon found</p>
)}
</>
)}
</div>

View File

@@ -1,8 +1,7 @@
export const METHOD_CONFIG: Record<string, { label: string; color: string }> = {
starter: {
label: 'Starter',
color:
'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/40 dark:text-yellow-300',
color: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/40 dark:text-yellow-300',
},
gift: {
label: 'Gift',
@@ -10,18 +9,15 @@ export const METHOD_CONFIG: Record<string, { label: string; color: string }> = {
},
fossil: {
label: 'Fossil',
color:
'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300',
color: 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300',
},
trade: {
label: 'Trade',
color:
'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-300',
color: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-300',
},
walk: {
label: 'Grass',
color:
'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300',
color: 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300',
},
headbutt: {
label: 'Headbutt',
@@ -33,8 +29,7 @@ export const METHOD_CONFIG: Record<string, { label: string; color: string }> = {
},
'rock-smash': {
label: 'Rock Smash',
color:
'bg-orange-100 text-orange-800 dark:bg-orange-900/40 dark:text-orange-300',
color: 'bg-orange-100 text-orange-800 dark:bg-orange-900/40 dark:text-orange-300',
},
'old-rod': {
label: 'Old Rod',
@@ -46,8 +41,7 @@ export const METHOD_CONFIG: Record<string, { label: string; color: string }> = {
},
'super-rod': {
label: 'Super Rod',
color:
'bg-indigo-100 text-indigo-800 dark:bg-indigo-900/40 dark:text-indigo-300',
color: 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900/40 dark:text-indigo-300',
},
}
@@ -75,8 +69,7 @@ export function getMethodLabel(method: string): string {
export function getMethodColor(method: string): string {
return (
METHOD_CONFIG[method]?.color ??
'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300'
METHOD_CONFIG[method]?.color ?? 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300'
)
}
@@ -89,12 +82,9 @@ export function EncounterMethodBadge({
}) {
const config = METHOD_CONFIG[method]
if (!config) return null
const sizeClass =
size === 'xs' ? 'text-[8px] px-1 py-0' : 'text-[9px] px-1.5 py-0.5'
const sizeClass = size === 'xs' ? 'text-[8px] px-1 py-0' : 'text-[9px] px-1.5 py-0.5'
return (
<span
className={`${sizeClass} font-medium rounded-full whitespace-nowrap ${config.color}`}
>
<span className={`${sizeClass} font-medium rounded-full whitespace-nowrap ${config.color}`}>
{config.label}
</span>
)

View File

@@ -1,43 +1,36 @@
import { useState, useEffect, useMemo } from 'react'
import { useRoutePokemon } from '../hooks/useGames'
import { useNameSuggestions } from '../hooks/useRuns'
import {
EncounterMethodBadge,
getMethodLabel,
METHOD_ORDER,
} from './EncounterMethodBadge'
import type {
Route,
EncounterDetail,
EncounterStatus,
RouteEncounterDetail,
} from '../types'
import { EncounterMethodBadge, getMethodLabel, METHOD_ORDER } from './EncounterMethodBadge'
import type { Route, EncounterDetail, EncounterStatus, RouteEncounterDetail } from '../types'
interface EncounterModalProps {
route: Route
gameId: number
runId: number
namingScheme?: string | null
isGenlocke?: boolean
existing?: EncounterDetail
dupedPokemonIds?: Set<number>
retiredPokemonIds?: Set<number>
namingScheme?: string | null | undefined
isGenlocke?: boolean | undefined
existing?: EncounterDetail | undefined
dupedPokemonIds?: Set<number> | undefined
retiredPokemonIds?: Set<number> | undefined
onSubmit: (data: {
routeId: number
pokemonId: number
nickname?: string
nickname?: string | undefined
status: EncounterStatus
catchLevel?: number
}) => void
onUpdate?: (data: {
id: number
data: {
nickname?: string
status?: EncounterStatus
faintLevel?: number
deathCause?: string
}
catchLevel?: number | undefined
}) => void
onUpdate?:
| ((data: {
id: number
data: {
nickname?: string | undefined
status?: EncounterStatus | undefined
faintLevel?: number | undefined
deathCause?: string | undefined
}
}) => void)
| undefined
onClose: () => void
isPending: boolean
}
@@ -91,11 +84,9 @@ function pickRandomPokemon(
pokemon: RouteEncounterDetail[],
dupedIds?: Set<number>
): RouteEncounterDetail | null {
const eligible = dupedIds
? pokemon.filter((rp) => !dupedIds.has(rp.pokemonId))
: pokemon
const eligible = dupedIds ? pokemon.filter((rp) => !dupedIds.has(rp.pokemonId)) : pokemon
if (eligible.length === 0) return null
return eligible[Math.floor(Math.random() * eligible.length)]
return eligible[Math.floor(Math.random() * eligible.length)] ?? null
}
export function EncounterModal({
@@ -112,20 +103,12 @@ export function EncounterModal({
onClose,
isPending,
}: EncounterModalProps) {
const { data: routePokemon, isLoading: loadingPokemon } = useRoutePokemon(
route.id,
gameId
)
const { data: routePokemon, isLoading: loadingPokemon } = useRoutePokemon(route.id, gameId)
const [selectedPokemon, setSelectedPokemon] =
useState<RouteEncounterDetail | null>(null)
const [status, setStatus] = useState<EncounterStatus>(
existing?.status ?? 'caught'
)
const [selectedPokemon, setSelectedPokemon] = useState<RouteEncounterDetail | null>(null)
const [status, setStatus] = useState<EncounterStatus>(existing?.status ?? 'caught')
const [nickname, setNickname] = useState(existing?.nickname ?? '')
const [catchLevel, setCatchLevel] = useState<string>(
existing?.catchLevel?.toString() ?? ''
)
const [catchLevel, setCatchLevel] = useState<string>(existing?.catchLevel?.toString() ?? '')
const [faintLevel, setFaintLevel] = useState<string>('')
const [deathCause, setDeathCause] = useState('')
const [search, setSearch] = useState('')
@@ -133,8 +116,7 @@ export function EncounterModal({
const isEditing = !!existing
const showSuggestions = !!namingScheme && status === 'caught' && !isEditing
const lineagePokemonId =
isGenlocke && selectedPokemon ? selectedPokemon.pokemonId : null
const lineagePokemonId = isGenlocke && selectedPokemon ? selectedPokemon.pokemonId : null
const {
data: suggestions,
refetch: regenerate,
@@ -144,9 +126,7 @@ export function EncounterModal({
// Pre-select pokemon when editing
useEffect(() => {
if (existing && routePokemon) {
const match = routePokemon.find(
(rp) => rp.pokemonId === existing.pokemonId
)
const match = routePokemon.find((rp) => rp.pokemonId === existing.pokemonId)
if (match) setSelectedPokemon(match)
}
}, [existing, routePokemon])
@@ -198,12 +178,7 @@ export function EncounterModal({
onClick={onClose}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
@@ -213,9 +188,7 @@ export function EncounterModal({
</svg>
</button>
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
{route.name}
</p>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">{route.name}</p>
</div>
<div className="px-6 py-4 space-y-4">
@@ -233,16 +206,12 @@ export function EncounterModal({
loadingPokemon ||
!routePokemon ||
(dupedPokemonIds
? routePokemon.every((rp) =>
dupedPokemonIds.has(rp.pokemonId)
)
? routePokemon.every((rp) => dupedPokemonIds.has(rp.pokemonId))
: false)
}
onClick={() => {
if (routePokemon) {
setSelectedPokemon(
pickRandomPokemon(routePokemon, dupedPokemonIds)
)
setSelectedPokemon(pickRandomPokemon(routePokemon, dupedPokemonIds))
}
}}
className="px-2.5 py-1 text-xs font-medium rounded-lg border border-purple-300 dark:border-purple-600 text-purple-600 dark:text-purple-400 hover:bg-purple-50 dark:hover:bg-purple-900/20 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
@@ -279,15 +248,12 @@ export function EncounterModal({
)}
<div className="grid grid-cols-3 gap-2">
{pokemon.map((rp) => {
const isDuped =
dupedPokemonIds?.has(rp.pokemonId) ?? false
const isDuped = dupedPokemonIds?.has(rp.pokemonId) ?? false
return (
<button
key={rp.id}
type="button"
onClick={() =>
!isDuped && setSelectedPokemon(rp)
}
onClick={() => !isDuped && setSelectedPokemon(rp)}
disabled={isDuped}
className={`flex flex-col items-center p-2 rounded-lg border text-center transition-colors ${
isDuped
@@ -305,7 +271,7 @@ export function EncounterModal({
/>
) : (
<div className="w-10 h-10 rounded-full bg-gray-200 dark:bg-gray-600 flex items-center justify-center text-xs font-bold">
{rp.pokemon.name[0].toUpperCase()}
{rp.pokemon.name[0]?.toUpperCase()}
</div>
)}
<span className="text-xs text-gray-700 dark:text-gray-300 mt-1 capitalize">
@@ -318,19 +284,13 @@ export function EncounterModal({
: 'already caught'}
</span>
)}
{!isDuped &&
SPECIAL_METHODS.includes(
rp.encounterMethod
) && (
<EncounterMethodBadge
method={rp.encounterMethod}
/>
)}
{!isDuped && SPECIAL_METHODS.includes(rp.encounterMethod) && (
<EncounterMethodBadge method={rp.encounterMethod} />
)}
{!isDuped && (
<span className="text-[10px] text-gray-400">
Lv. {rp.minLevel}
{rp.maxLevel !== rp.minLevel &&
`${rp.maxLevel}`}
{rp.maxLevel !== rp.minLevel && `${rp.maxLevel}`}
</span>
)}
</button>
@@ -360,7 +320,7 @@ export function EncounterModal({
/>
) : (
<div className="w-12 h-12 rounded-full bg-gray-200 dark:bg-gray-600 flex items-center justify-center text-lg font-bold">
{existing.pokemon.name[0].toUpperCase()}
{existing.pokemon.name[0]?.toUpperCase()}
</div>
)}
<div>
@@ -477,53 +437,45 @@ export function EncounterModal({
)}
{/* Faint Level + Death Cause (only when editing a caught pokemon to mark dead) */}
{isEditing &&
existing?.status === 'caught' &&
existing?.faintLevel === null && (
<>
<div>
<label
htmlFor="faint-level"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
Faint Level{' '}
<span className="font-normal text-gray-400">
(mark as dead)
</span>
</label>
<input
id="faint-level"
type="number"
min={1}
max={100}
value={faintLevel}
onChange={(e) => setFaintLevel(e.target.value)}
placeholder="Leave empty if still alive"
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label
htmlFor="death-cause"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
Cause of Death{' '}
<span className="font-normal text-gray-400">
(optional)
</span>
</label>
<input
id="death-cause"
type="text"
maxLength={100}
value={deathCause}
onChange={(e) => setDeathCause(e.target.value)}
placeholder="e.g. Crit from rival's Charizard"
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</>
)}
{isEditing && existing?.status === 'caught' && existing?.faintLevel === null && (
<>
<div>
<label
htmlFor="faint-level"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
Faint Level <span className="font-normal text-gray-400">(mark as dead)</span>
</label>
<input
id="faint-level"
type="number"
min={1}
max={100}
value={faintLevel}
onChange={(e) => setFaintLevel(e.target.value)}
placeholder="Leave empty if still alive"
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label
htmlFor="death-cause"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
Cause of Death <span className="font-normal text-gray-400">(optional)</span>
</label>
<input
id="death-cause"
type="text"
maxLength={100}
value={deathCause}
onChange={(e) => setDeathCause(e.target.value)}
placeholder="e.g. Crit from rival's Charizard"
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</>
)}
</div>
<div className="sticky bottom-0 bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 px-6 py-4 rounded-b-xl flex justify-end gap-3">

View File

@@ -7,12 +7,7 @@ interface EndRunModalProps {
genlockeContext?: RunGenlockeContext | null
}
export function EndRunModal({
onConfirm,
onClose,
isPending,
genlockeContext,
}: EndRunModalProps) {
export function EndRunModal({ onConfirm, onClose, isPending, genlockeContext }: EndRunModalProps) {
const victoryDescription = genlockeContext
? genlockeContext.isFinalLeg
? 'Complete the final leg of your genlocke!'
@@ -31,9 +26,7 @@ export function EndRunModal({
<h2 className="text-lg font-semibold">End Run</h2>
</div>
<div className="px-6 py-6">
<p className="text-gray-600 dark:text-gray-400 mb-6">
How did your run end?
</p>
<p className="text-gray-600 dark:text-gray-400 mb-6">How did your run end?</p>
<div className="flex flex-col gap-3">
<button
onClick={() => onConfirm('completed')}

View File

@@ -32,27 +32,20 @@ export function GameCard({ game, selected, onSelect }: GameCardProps) {
/>
</div>
) : (
<div
className="w-full h-48 flex items-center justify-center"
style={{ backgroundColor }}
>
<div className="w-full h-48 flex items-center justify-center" style={{ backgroundColor }}>
<span className="text-white text-2xl font-bold text-center px-4 drop-shadow-md">
{game.name.replace('Pokemon ', '')}
</span>
</div>
)}
<div className="p-3 bg-white dark:bg-gray-800 text-left">
<h3 className="font-semibold text-gray-900 dark:text-gray-100">
{game.name}
</h3>
<h3 className="font-semibold text-gray-900 dark:text-gray-100">{game.name}</h3>
<div className="flex items-center gap-2 mt-1">
<span className="text-xs px-2 py-0.5 rounded-full bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400">
{game.region.charAt(0).toUpperCase() + game.region.slice(1)}
</span>
{game.releaseYear && (
<span className="text-xs text-gray-500 dark:text-gray-400">
{game.releaseYear}
</span>
<span className="text-xs text-gray-500 dark:text-gray-400">{game.releaseYear}</span>
)}
</div>
</div>
@@ -65,11 +58,7 @@ export function GameCard({ game, selected, onSelect }: GameCardProps) {
stroke="currentColor"
strokeWidth={3}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M5 13l4 4L19 7"
/>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
</div>
)}

View File

@@ -18,7 +18,7 @@ interface GameGridProps {
games: Game[]
selectedId: number | null
onSelect: (game: Game) => void
runs?: NuzlockeRun[]
runs?: NuzlockeRun[] | undefined
}
export function GameGrid({ games, selectedId, onSelect, runs }: GameGridProps) {
@@ -27,38 +27,26 @@ export function GameGrid({ games, selectedId, onSelect, runs }: GameGridProps) {
const [hideWithActiveRun, setHideWithActiveRun] = useState(false)
const [hideCompleted, setHideCompleted] = useState(false)
const generations = useMemo(
() => [...new Set(games.map((g) => g.generation))].sort(),
[games]
)
const generations = useMemo(() => [...new Set(games.map((g) => g.generation))].sort(), [games])
const regions = useMemo(
() => [...new Set(games.map((g) => g.region))].sort(),
[games]
)
const regions = useMemo(() => [...new Set(games.map((g) => g.region))].sort(), [games])
const activeRunGameIds = useMemo(() => {
if (!runs) return new Set<number>()
return new Set(
runs.filter((r) => r.status === 'active').map((r) => r.gameId)
)
return new Set(runs.filter((r) => r.status === 'active').map((r) => r.gameId))
}, [runs])
const completedRunGameIds = useMemo(() => {
if (!runs) return new Set<number>()
return new Set(
runs.filter((r) => r.status === 'completed').map((r) => r.gameId)
)
return new Set(runs.filter((r) => r.status === 'completed').map((r) => r.gameId))
}, [runs])
const filtered = useMemo(() => {
let result = games
if (filter) result = result.filter((g) => g.generation === filter)
if (regionFilter) result = result.filter((g) => g.region === regionFilter)
if (hideWithActiveRun)
result = result.filter((g) => !activeRunGameIds.has(g.id))
if (hideCompleted)
result = result.filter((g) => !completedRunGameIds.has(g.id))
if (hideWithActiveRun) result = result.filter((g) => !activeRunGameIds.has(g.id))
if (hideCompleted) result = result.filter((g) => !completedRunGameIds.has(g.id))
return result
}, [
games,
@@ -91,9 +79,7 @@ export function GameGrid({ games, selectedId, onSelect, runs }: GameGridProps) {
<div className="space-y-6">
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs font-medium text-gray-500 dark:text-gray-400 mr-1">
Gen:
</span>
<span className="text-xs font-medium text-gray-500 dark:text-gray-400 mr-1">Gen:</span>
<button
type="button"
onClick={() => setFilter(null)}
@@ -114,9 +100,7 @@ export function GameGrid({ games, selectedId, onSelect, runs }: GameGridProps) {
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs font-medium text-gray-500 dark:text-gray-400 mr-1">
Region:
</span>
<span className="text-xs font-medium text-gray-500 dark:text-gray-400 mr-1">Region:</span>
<button
type="button"
onClick={() => setRegionFilter(null)}

View File

@@ -24,7 +24,7 @@ function GraveyardCard({ entry }: { entry: GraveyardEntry }) {
/>
) : (
<div className="w-25 h-25 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center text-xl font-bold text-gray-600 dark:text-gray-300">
{displayPokemon.name[0].toUpperCase()}
{displayPokemon.name[0]?.toUpperCase()}
</div>
)}
@@ -35,9 +35,7 @@ function GraveyardCard({ entry }: { entry: GraveyardEntry }) {
</span>
</div>
{entry.nickname && (
<div className="text-xs text-gray-500 dark:text-gray-400">
{displayPokemon.name}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400">{displayPokemon.name}</div>
)}
<div className="flex flex-col items-center gap-0.5 mt-1">
@@ -50,9 +48,7 @@ function GraveyardCard({ entry }: { entry: GraveyardEntry }) {
Lv. {entry.catchLevel} &rarr; {entry.faintLevel}
</div>
<div className="text-xs text-gray-400 dark:text-gray-500 mt-0.5">
{entry.routeName}
</div>
<div className="text-xs text-gray-400 dark:text-gray-500 mt-0.5">{entry.routeName}</div>
<div className="text-[10px] text-purple-600 dark:text-purple-400 mt-0.5 font-medium">
Leg {entry.legOrder} &mdash; {entry.gameName}
@@ -134,8 +130,8 @@ export function GenlockeGraveyard({ genlockeId }: GenlockeGraveyardProps) {
</span>
{data.deadliestLeg && (
<span className="text-gray-500 dark:text-gray-400">
Deadliest: Leg {data.deadliestLeg.legOrder} &mdash;{' '}
{data.deadliestLeg.gameName} ({data.deadliestLeg.deathCount})
Deadliest: Leg {data.deadliestLeg.legOrder} &mdash; {data.deadliestLeg.gameName} (
{data.deadliestLeg.deathCount})
</span>
)}
</div>
@@ -144,9 +140,7 @@ export function GenlockeGraveyard({ genlockeId }: GenlockeGraveyardProps) {
<div className="flex flex-wrap items-center gap-3">
<select
value={filterLeg ?? ''}
onChange={(e) =>
setFilterLeg(e.target.value ? Number(e.target.value) : null)
}
onChange={(e) => setFilterLeg(e.target.value ? Number(e.target.value) : null)}
className="text-sm border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-1.5 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
>
<option value="">All Legs</option>

View File

@@ -38,21 +38,13 @@ function LegDot({ leg }: { leg: LineageLegEntry }) {
<div className="font-semibold">{leg.gameName}</div>
<div className="flex items-center gap-1.5">
{displayPokemon.spriteUrl && (
<img
src={displayPokemon.spriteUrl}
alt={displayPokemon.name}
className="w-6 h-6"
/>
<img src={displayPokemon.spriteUrl} alt={displayPokemon.name} className="w-6 h-6" />
)}
<span>{displayPokemon.name}</span>
</div>
{leg.catchLevel !== null && <div>Caught Lv. {leg.catchLevel}</div>}
{leg.faintLevel !== null && (
<div className="text-red-300">Died Lv. {leg.faintLevel}</div>
)}
{leg.deathCause && (
<div className="text-red-300 italic">{leg.deathCause}</div>
)}
{leg.faintLevel !== null && <div className="text-red-300">Died Lv. {leg.faintLevel}</div>}
{leg.deathCause && <div className="text-red-300 italic">{leg.deathCause}</div>}
<div
className={`font-medium ${
leg.faintLevel !== null
@@ -84,8 +76,8 @@ function TimelineGrid({
allLegOrders: number[]
}) {
const legMap = new Map(lineage.legs.map((l) => [l.legOrder, l]))
const minLeg = lineage.legs[0].legOrder
const maxLeg = lineage.legs[lineage.legs.length - 1].legOrder
const minLeg = lineage.legs[0]!.legOrder
const maxLeg = lineage.legs[lineage.legs.length - 1]!.legOrder
return (
<div
@@ -97,18 +89,12 @@ function TimelineGrid({
{allLegOrders.map((legOrder, i) => {
const leg = legMap.get(legOrder)
const inRange = legOrder >= minLeg && legOrder <= maxLeg
const showLeftLine = inRange && i > 0 && allLegOrders[i - 1] >= minLeg
const showLeftLine = inRange && i > 0 && (allLegOrders[i - 1] ?? 0) >= minLeg
const showRightLine =
inRange &&
i < allLegOrders.length - 1 &&
allLegOrders[i + 1] <= maxLeg
inRange && i < allLegOrders.length - 1 && (allLegOrders[i + 1] ?? 0) <= maxLeg
return (
<div
key={legOrder}
className="flex justify-center relative"
style={{ height: '20px' }}
>
<div key={legOrder} className="flex justify-center relative" style={{ height: '20px' }}>
{/* Left half connector */}
{showLeftLine && (
<div className="absolute top-[9px] left-0 right-1/2 h-0.5 bg-gray-300 dark:bg-gray-600" />
@@ -132,14 +118,8 @@ function TimelineGrid({
)
}
function LineageCard({
lineage,
allLegOrders,
}: {
lineage: LineageEntry
allLegOrders: number[]
}) {
const firstLeg = lineage.legs[0]
function LineageCard({ lineage, allLegOrders }: { lineage: LineageEntry; allLegOrders: number[] }) {
const firstLeg = lineage.legs[0]!
const displayPokemon = firstLeg.currentPokemon ?? firstLeg.pokemon
return (
@@ -155,7 +135,7 @@ function LineageCard({
/>
) : (
<div className="w-16 h-16 rounded-full bg-gray-200 dark:bg-gray-600 flex items-center justify-center text-xl font-bold text-gray-600 dark:text-gray-300">
{displayPokemon.name[0].toUpperCase()}
{displayPokemon.name[0]?.toUpperCase()}
</div>
)}
<span className="text-sm font-semibold text-gray-900 dark:text-gray-100 mt-1 text-center">
@@ -194,11 +174,9 @@ export function GenlockeLineage({ genlockeId }: GenlockeLineageProps) {
const allLegOrders = useMemo(() => {
if (!data) return []
return [
...new Set(
data.lineages.flatMap((l) => l.legs.map((leg) => leg.legOrder))
),
].sort((a, b) => a - b)
return [...new Set(data.lineages.flatMap((l) => l.legs.map((leg) => leg.legOrder)))].sort(
(a, b) => a - b
)
}, [data])
const legGameNames = useMemo(() => {
@@ -241,8 +219,8 @@ export function GenlockeLineage({ genlockeId }: GenlockeLineageProps) {
{/* Summary bar */}
<div className="flex flex-wrap items-center gap-4 text-sm">
<span className="font-semibold text-gray-900 dark:text-gray-100">
{data.totalLineages} lineage{data.totalLineages !== 1 ? 's' : ''}{' '}
across {allLegOrders.length} leg{allLegOrders.length !== 1 ? 's' : ''}
{data.totalLineages} lineage{data.totalLineages !== 1 ? 's' : ''} across{' '}
{allLegOrders.length} leg{allLegOrders.length !== 1 ? 's' : ''}
</span>
</div>
@@ -276,7 +254,7 @@ export function GenlockeLineage({ genlockeId }: GenlockeLineageProps) {
<div className="space-y-3">
{data.lineages.map((lineage) => (
<LineageCard
key={lineage.legs[0].encounterId}
key={lineage.legs[0]!.encounterId}
lineage={lineage}
allLegOrders={allLegOrders}
/>

View File

@@ -8,12 +8,7 @@ interface HofTeamModalProps {
isPending: boolean
}
export function HofTeamModal({
alive,
onSubmit,
onSkip,
isPending,
}: HofTeamModalProps) {
export function HofTeamModal({ alive, onSubmit, onSkip, isPending }: HofTeamModalProps) {
const [selected, setSelected] = useState<Set<number>>(() => {
// Pre-select all if 6 or fewer
if (alive.length <= 6) return new Set(alive.map((e) => e.id))
@@ -74,16 +69,14 @@ export function HofTeamModal({
/>
) : (
<div className="w-14 h-14 rounded-full bg-gray-200 dark:bg-gray-600 flex items-center justify-center text-lg font-bold">
{displayPokemon.name[0].toUpperCase()}
{displayPokemon.name[0]?.toUpperCase()}
</div>
)}
<span className="text-xs font-medium text-gray-700 dark:text-gray-300 mt-1 capitalize">
{enc.nickname || displayPokemon.name}
</span>
{enc.nickname && (
<span className="text-[10px] text-gray-400">
{displayPokemon.name}
</span>
<span className="text-[10px] text-gray-400">{displayPokemon.name}</span>
)}
</button>
)

View File

@@ -55,12 +55,7 @@ export function Layout() {
className="p-2 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700"
aria-label="Toggle menu"
>
<svg
className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
{menuOpen ? (
<path
strokeLinecap="round"

View File

@@ -1,26 +1,14 @@
import type { EncounterDetail } from '../types'
import { TypeBadge } from './TypeBadge'
interface PokemonCardProps {
export interface PokemonCardProps {
encounter: EncounterDetail
showFaintLevel?: boolean
onClick?: () => void
showFaintLevel?: boolean | undefined
onClick?: (() => void) | undefined
}
export function PokemonCard({
encounter,
showFaintLevel,
onClick,
}: PokemonCardProps) {
const {
pokemon,
currentPokemon,
route,
nickname,
catchLevel,
faintLevel,
deathCause,
} = encounter
export function PokemonCard({ encounter, showFaintLevel, onClick }: PokemonCardProps) {
const { pokemon, currentPokemon, route, nickname, catchLevel, faintLevel, deathCause } = encounter
const isDead = faintLevel !== null
const displayPokemon = currentPokemon ?? pokemon
const isEvolved = currentPokemon !== null
@@ -33,14 +21,10 @@ export function PokemonCard({
} ${onClick ? 'cursor-pointer hover:ring-2 hover:ring-blue-400 transition-shadow' : ''}`}
>
{displayPokemon.spriteUrl ? (
<img
src={displayPokemon.spriteUrl}
alt={displayPokemon.name}
className="w-25 h-25"
/>
<img src={displayPokemon.spriteUrl} alt={displayPokemon.name} className="w-25 h-25" />
) : (
<div className="w-25 h-25 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center text-xl font-bold text-gray-600 dark:text-gray-300">
{displayPokemon.name[0].toUpperCase()}
{displayPokemon.name[0]?.toUpperCase()}
</div>
)}
@@ -53,9 +37,7 @@ export function PokemonCard({
</span>
</div>
{nickname && (
<div className="text-xs text-gray-500 dark:text-gray-400">
{displayPokemon.name}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400">{displayPokemon.name}</div>
)}
<div className="flex flex-col items-center gap-0.5 mt-1">
@@ -70,9 +52,7 @@ export function PokemonCard({
: `Lv. ${catchLevel ?? '?'}`}
</div>
<div className="text-xs text-gray-400 dark:text-gray-500 mt-0.5">
{route.name}
</div>
<div className="text-xs text-gray-400 dark:text-gray-500 mt-0.5">{route.name}</div>
{isEvolved && (
<div className="text-[10px] text-gray-400 dark:text-gray-500 mt-0.5">

View File

@@ -9,11 +9,7 @@ export function RuleBadges({ rules }: RuleBadgesProps) {
const enabledRules = RULE_DEFINITIONS.filter((def) => rules[def.key])
if (enabledRules.length === 0) {
return (
<span className="text-sm text-gray-500 dark:text-gray-400">
No rules enabled
</span>
)
return <span className="text-sm text-gray-500 dark:text-gray-400">No rules enabled</span>
}
return (

View File

@@ -7,21 +7,14 @@ interface RuleToggleProps {
onChange: (enabled: boolean) => void
}
export function RuleToggle({
name,
description,
enabled,
onChange,
}: RuleToggleProps) {
export function RuleToggle({ name, description, enabled, onChange }: RuleToggleProps) {
const [showTooltip, setShowTooltip] = useState(false)
return (
<div className="flex items-center justify-between py-3 border-b border-gray-200 dark:border-gray-700 last:border-0">
<div className="flex-1 pr-4">
<div className="flex items-center gap-2">
<span className="font-medium text-gray-900 dark:text-gray-100">
{name}
</span>
<span className="font-medium text-gray-900 dark:text-gray-100">{name}</span>
<button
type="button"
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
@@ -30,12 +23,7 @@ export function RuleToggle({
onClick={() => setShowTooltip(!showTooltip)}
aria-label={`Info about ${name}`}
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
@@ -46,9 +34,7 @@ export function RuleToggle({
</button>
</div>
{showTooltip && (
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
{description}
</p>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">{description}</p>
)}
</div>
<button

View File

@@ -5,8 +5,8 @@ import { RuleToggle } from './RuleToggle'
interface RulesConfigurationProps {
rules: NuzlockeRules
onChange: (rules: NuzlockeRules) => void
onReset?: () => void
hiddenRules?: Set<keyof NuzlockeRules>
onReset?: (() => void) | undefined
hiddenRules?: Set<keyof NuzlockeRules> | undefined
}
export function RulesConfiguration({
@@ -19,12 +19,8 @@ export function RulesConfiguration({
? RULE_DEFINITIONS.filter((r) => !hiddenRules.has(r.key))
: RULE_DEFINITIONS
const coreRules = visibleRules.filter((r) => r.category === 'core')
const difficultyRules = visibleRules.filter(
(r) => r.category === 'difficulty'
)
const completionRules = visibleRules.filter(
(r) => r.category === 'completion'
)
const difficultyRules = visibleRules.filter((r) => r.category === 'difficulty')
const completionRules = visibleRules.filter((r) => r.category === 'completion')
const handleRuleChange = (key: keyof NuzlockeRules, value: boolean) => {
onChange({ ...rules, [key]: value })
@@ -60,9 +56,7 @@ export function RulesConfiguration({
<div className="bg-white dark:bg-gray-800 rounded-lg shadow">
<div className="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100">
Core Rules
</h3>
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100">Core Rules</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">
The fundamental rules of a Nuzlocke challenge
</p>
@@ -105,9 +99,7 @@ export function RulesConfiguration({
{completionRules.length > 0 && (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow">
<div className="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100">
Completion
</h3>
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100">Completion</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">
When is the run considered complete
</p>

View File

@@ -3,7 +3,7 @@ import type { EncounterDetail } from '../types'
interface ShinyBoxProps {
encounters: EncounterDetail[]
onEncounterClick?: (encounter: EncounterDetail) => void
onEncounterClick?: ((encounter: EncounterDetail) => void) | undefined
}
export function ShinyBox({ encounters, onEncounterClick }: ShinyBoxProps) {
@@ -22,9 +22,7 @@ export function ShinyBox({ encounters, onEncounterClick }: ShinyBoxProps) {
<PokemonCard
key={enc.id}
encounter={enc}
onClick={
onEncounterClick ? () => onEncounterClick(enc) : undefined
}
onClick={onEncounterClick ? () => onEncounterClick(enc) : undefined}
/>
))}
</div>

View File

@@ -1,10 +1,6 @@
import { useState, useMemo } from 'react'
import { useRoutePokemon } from '../hooks/useGames'
import {
EncounterMethodBadge,
getMethodLabel,
METHOD_ORDER,
} from './EncounterMethodBadge'
import { EncounterMethodBadge, getMethodLabel, METHOD_ORDER } from './EncounterMethodBadge'
import type { Route, RouteEncounterDetail } from '../types'
interface ShinyEncounterModalProps {
@@ -13,9 +9,9 @@ interface ShinyEncounterModalProps {
onSubmit: (data: {
routeId: number
pokemonId: number
nickname?: string
nickname?: string | undefined
status: 'caught'
catchLevel?: number
catchLevel?: number | undefined
isShiny: true
}) => void
onClose: () => void
@@ -50,13 +46,9 @@ export function ShinyEncounterModal({
isPending,
}: ShinyEncounterModalProps) {
const [selectedRouteId, setSelectedRouteId] = useState<number | null>(null)
const { data: routePokemon, isLoading: loadingPokemon } = useRoutePokemon(
selectedRouteId,
gameId
)
const { data: routePokemon, isLoading: loadingPokemon } = useRoutePokemon(selectedRouteId, gameId)
const [selectedPokemon, setSelectedPokemon] =
useState<RouteEncounterDetail | null>(null)
const [selectedPokemon, setSelectedPokemon] = useState<RouteEncounterDetail | null>(null)
const [nickname, setNickname] = useState('')
const [catchLevel, setCatchLevel] = useState<string>('')
const [search, setSearch] = useState('')
@@ -111,12 +103,7 @@ export function ShinyEncounterModal({
onClick={onClose}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
@@ -203,21 +190,18 @@ export function ShinyEncounterModal({
/>
) : (
<div className="w-10 h-10 rounded-full bg-gray-200 dark:bg-gray-600 flex items-center justify-center text-xs font-bold">
{rp.pokemon.name[0].toUpperCase()}
{rp.pokemon.name[0]?.toUpperCase()}
</div>
)}
<span className="text-xs text-gray-700 dark:text-gray-300 mt-1 capitalize">
{rp.pokemon.name}
</span>
{SPECIAL_METHODS.includes(rp.encounterMethod) && (
<EncounterMethodBadge
method={rp.encounterMethod}
/>
<EncounterMethodBadge method={rp.encounterMethod} />
)}
<span className="text-[10px] text-gray-400">
Lv. {rp.minLevel}
{rp.maxLevel !== rp.minLevel &&
`${rp.maxLevel}`}
{rp.maxLevel !== rp.minLevel && `${rp.maxLevel}`}
</span>
</button>
))}

View File

@@ -1,7 +1,7 @@
interface StatCardProps {
label: string
value: number
total?: number
total?: number | undefined
color: string
}
@@ -22,10 +22,7 @@ export function StatCard({ label, value, total, color }: StatCardProps) {
<div className="text-2xl font-bold text-gray-900 dark:text-gray-100">
{value}
{total !== undefined && (
<span className="text-sm font-normal text-gray-500 dark:text-gray-400">
{' '}
/ {total}
</span>
<span className="text-sm font-normal text-gray-500 dark:text-gray-400"> / {total}</span>
)}
</div>
<div className="text-sm text-gray-600 dark:text-gray-400">{label}</div>

View File

@@ -1,9 +1,5 @@
import { useState, useMemo } from 'react'
import type {
EncounterDetail,
UpdateEncounterInput,
CreateEncounterInput,
} from '../types'
import type { EncounterDetail, UpdateEncounterInput, CreateEncounterInput } from '../types'
import { useEvolutions, useForms } from '../hooks/useEncounters'
import { TypeBadge } from './TypeBadge'
import { formatEvolutionMethod } from '../utils/formatEvolution'
@@ -25,24 +21,14 @@ export function StatusChangeModal({
region,
onCreateEncounter,
}: StatusChangeModalProps) {
const {
pokemon,
currentPokemon,
route,
nickname,
catchLevel,
faintLevel,
deathCause,
} = encounter
const { pokemon, currentPokemon, route, nickname, catchLevel, faintLevel, deathCause } = encounter
const isDead = faintLevel !== null
const displayPokemon = currentPokemon ?? pokemon
const [showConfirm, setShowConfirm] = useState(false)
const [showEvolve, setShowEvolve] = useState(false)
const [showFormChange, setShowFormChange] = useState(false)
const [showShedConfirm, setShowShedConfirm] = useState(false)
const [pendingEvolutionId, setPendingEvolutionId] = useState<number | null>(
null
)
const [pendingEvolutionId, setPendingEvolutionId] = useState<number | null>(null)
const [shedNickname, setShedNickname] = useState('')
const [deathLevel, setDeathLevel] = useState('')
const [cause, setCause] = useState('')
@@ -115,12 +101,7 @@ export function StatusChangeModal({
onClick={onClose}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
@@ -142,7 +123,7 @@ export function StatusChangeModal({
/>
) : (
<div className="w-16 h-16 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center text-xl font-bold text-gray-600 dark:text-gray-300">
{displayPokemon.name[0].toUpperCase()}
{displayPokemon.name[0]?.toUpperCase()}
</div>
)}
<div>
@@ -179,55 +160,46 @@ export function StatusChangeModal({
</div>
{faintLevel !== null && (
<div className="text-sm text-gray-700 dark:text-gray-300">
<span className="text-gray-500 dark:text-gray-400">
Level at death:
</span>{' '}
<span className="text-gray-500 dark:text-gray-400">Level at death:</span>{' '}
{faintLevel}
</div>
)}
{deathCause && (
<div className="text-sm text-gray-700 dark:text-gray-300">
<span className="text-gray-500 dark:text-gray-400">
Cause:
</span>{' '}
{deathCause}
<span className="text-gray-500 dark:text-gray-400">Cause:</span> {deathCause}
</div>
)}
</div>
)}
{/* Alive pokemon: actions */}
{!isDead &&
!showConfirm &&
!showEvolve &&
!showFormChange &&
!showShedConfirm && (
<div className="flex gap-3">
{!isDead && !showConfirm && !showEvolve && !showFormChange && !showShedConfirm && (
<div className="flex gap-3">
<button
type="button"
onClick={() => setShowEvolve(true)}
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors"
>
Evolve
</button>
{forms && forms.length > 0 && (
<button
type="button"
onClick={() => setShowEvolve(true)}
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors"
onClick={() => setShowFormChange(true)}
className="flex-1 px-4 py-2 bg-purple-600 text-white rounded-lg font-medium hover:bg-purple-700 transition-colors"
>
Evolve
Change Form
</button>
{forms && forms.length > 0 && (
<button
type="button"
onClick={() => setShowFormChange(true)}
className="flex-1 px-4 py-2 bg-purple-600 text-white rounded-lg font-medium hover:bg-purple-700 transition-colors"
>
Change Form
</button>
)}
<button
type="button"
onClick={() => setShowConfirm(true)}
className="flex-1 px-4 py-2 bg-red-600 text-white rounded-lg font-medium hover:bg-red-700 transition-colors"
>
Mark as Dead
</button>
</div>
)}
)}
<button
type="button"
onClick={() => setShowConfirm(true)}
className="flex-1 px-4 py-2 bg-red-600 text-white rounded-lg font-medium hover:bg-red-700 transition-colors"
>
Mark as Dead
</button>
</div>
)}
{/* Evolution selection */}
{!isDead && showEvolve && (
@@ -245,14 +217,10 @@ export function StatusChangeModal({
</button>
</div>
{evolutionsLoading && (
<p className="text-sm text-gray-500 dark:text-gray-400">
Loading evolutions...
</p>
<p className="text-sm text-gray-500 dark:text-gray-400">Loading evolutions...</p>
)}
{!evolutionsLoading && normalEvolutions.length === 0 && (
<p className="text-sm text-gray-500 dark:text-gray-400">
No evolutions available
</p>
<p className="text-sm text-gray-500 dark:text-gray-400">No evolutions available</p>
)}
{!evolutionsLoading && normalEvolutions.length > 0 && (
<div className="space-y-2">
@@ -272,7 +240,7 @@ export function StatusChangeModal({
/>
) : (
<div className="w-10 h-10 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center text-sm font-bold text-gray-600 dark:text-gray-300">
{evo.toPokemon.name[0].toUpperCase()}
{evo.toPokemon.name[0]?.toUpperCase()}
</div>
)}
<div className="text-left">
@@ -320,16 +288,12 @@ export function StatusChangeModal({
/>
) : (
<div className="w-12 h-12 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center text-sm font-bold text-gray-600 dark:text-gray-300">
{shedCompanion.toPokemon.name[0].toUpperCase()}
{shedCompanion.toPokemon.name[0]?.toUpperCase()}
</div>
)}
<p className="text-sm text-amber-800 dark:text-amber-300">
{displayPokemon.name} shed its shell! Would you also like to
add{' '}
<span className="font-semibold">
{shedCompanion.toPokemon.name}
</span>
?
{displayPokemon.name} shed its shell! Would you also like to add{' '}
<span className="font-semibold">{shedCompanion.toPokemon.name}</span>?
</p>
</div>
</div>
@@ -338,8 +302,7 @@ export function StatusChangeModal({
htmlFor="shed-nickname"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
Nickname{' '}
<span className="font-normal text-gray-400">(optional)</span>
Nickname <span className="font-normal text-gray-400">(optional)</span>
</label>
<input
id="shed-nickname"
@@ -366,9 +329,7 @@ export function StatusChangeModal({
onClick={() => applyEvolution(true)}
className="flex-1 px-4 py-2 bg-amber-600 text-white rounded-lg font-medium hover:bg-amber-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isPending
? 'Saving...'
: `Add ${shedCompanion.toPokemon.name}`}
{isPending ? 'Saving...' : `Add ${shedCompanion.toPokemon.name}`}
</button>
</div>
</div>
@@ -400,14 +361,10 @@ export function StatusChangeModal({
className="w-full flex items-center gap-3 p-3 rounded-lg border border-gray-200 dark:border-gray-600 hover:bg-purple-50 dark:hover:bg-purple-900/20 hover:border-purple-300 dark:hover:border-purple-600 transition-colors disabled:opacity-50"
>
{form.spriteUrl ? (
<img
src={form.spriteUrl}
alt={form.name}
className="w-10 h-10"
/>
<img src={form.spriteUrl} alt={form.name} className="w-10 h-10" />
) : (
<div className="w-10 h-10 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center text-sm font-bold text-gray-600 dark:text-gray-300">
{form.name[0].toUpperCase()}
{form.name[0]?.toUpperCase()}
</div>
)}
<div className="text-left">
@@ -441,8 +398,7 @@ export function StatusChangeModal({
htmlFor="death-level"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
Level at Death{' '}
<span className="font-normal text-gray-400">(optional)</span>
Level at Death <span className="font-normal text-gray-400">(optional)</span>
</label>
<input
id="death-level"
@@ -461,8 +417,7 @@ export function StatusChangeModal({
htmlFor="death-cause"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
Cause of Death{' '}
<span className="font-normal text-gray-400">(optional)</span>
Cause of Death <span className="font-normal text-gray-400">(optional)</span>
</label>
<input
id="death-cause"
@@ -498,11 +453,7 @@ export function StatusChangeModal({
{/* Footer for dead/no-confirm/no-evolve views */}
{(isDead ||
(!isDead &&
!showConfirm &&
!showEvolve &&
!showFormChange &&
!showShedConfirm)) && (
(!isDead && !showConfirm && !showEvolve && !showFormChange && !showShedConfirm)) && (
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end">
<button
type="button"

View File

@@ -20,10 +20,7 @@ export function StepIndicator({
const isCurrent = step === currentStep
return (
<li
key={label}
className={`flex items-center ${i < steps.length - 1 ? 'flex-1' : ''}`}
>
<li key={label} className={`flex items-center ${i < steps.length - 1 ? 'flex-1' : ''}`}>
<button
type="button"
onClick={() => isCompleted && onStepClick(step)}
@@ -53,11 +50,7 @@ export function StepIndicator({
stroke="currentColor"
strokeWidth={3}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M5 13l4 4L19 7"
/>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
) : (
step
@@ -68,9 +61,7 @@ export function StepIndicator({
{i < steps.length - 1 && (
<div
className={`flex-1 h-0.5 mx-3 ${
step < currentStep
? 'bg-blue-600'
: 'bg-gray-300 dark:bg-gray-600'
step < currentStep ? 'bg-blue-600' : 'bg-gray-300 dark:bg-gray-600'
}`}
/>
)}

View File

@@ -8,15 +8,8 @@ interface TransferModalProps {
isPending: boolean
}
export function TransferModal({
hofTeam,
onSubmit,
onSkip,
isPending,
}: TransferModalProps) {
const [selected, setSelected] = useState<Set<number>>(
() => new Set(hofTeam.map((e) => e.id))
)
export function TransferModal({ hofTeam, onSubmit, onSkip, isPending }: TransferModalProps) {
const [selected, setSelected] = useState<Set<number>>(() => new Set(hofTeam.map((e) => e.id)))
const toggle = (id: number) => {
setSelected((prev) => {
@@ -39,8 +32,8 @@ export function TransferModal({
Transfer Pokemon to Next Leg
</h2>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Selected Pokemon will be bred down to their base form and appear as
level 1 encounters in the next leg.
Selected Pokemon will be bred down to their base form and appear as level 1 encounters
in the next leg.
</p>
</div>
@@ -69,20 +62,16 @@ export function TransferModal({
/>
) : (
<div className="w-14 h-14 rounded-full bg-gray-200 dark:bg-gray-600 flex items-center justify-center text-lg font-bold">
{displayPokemon.name[0].toUpperCase()}
{displayPokemon.name[0]?.toUpperCase()}
</div>
)}
<span className="text-xs font-medium text-gray-700 dark:text-gray-300 mt-1 capitalize">
{enc.nickname || displayPokemon.name}
</span>
{enc.nickname && (
<span className="text-[10px] text-gray-400">
{displayPokemon.name}
</span>
<span className="text-[10px] text-gray-400">{displayPokemon.name}</span>
)}
<span className="text-[10px] text-gray-400 mt-0.5">
{enc.route.name}
</span>
<span className="text-[10px] text-gray-400 mt-0.5">{enc.route.name}</span>
</button>
)
})}

View File

@@ -5,7 +5,5 @@ interface TypeBadgeProps {
export function TypeBadge({ type, size = 'sm' }: TypeBadgeProps) {
const height = size === 'md' ? 'h-5' : 'h-4'
return (
<img src={`/types/${type}.png`} alt={type} className={`${height} w-auto`} />
)
return <img src={`/types/${type}.png`} alt={type} className={`${height} w-auto`} />
}

View File

@@ -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) => (

View File

@@ -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}

View File

@@ -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}

View File

@@ -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">

View File

@@ -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

View File

@@ -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 ?? '')

View File

@@ -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" />

View File

@@ -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

View File

@@ -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

View File

@@ -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>

View File

@@ -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

View File

@@ -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>