Add pre-commit hooks for linting and formatting
Set up pre-commit framework with ruff (backend) and ESLint/Prettier/tsc (frontend) hooks to catch issues locally before CI. Auto-format all frontend files with Prettier to comply with the new check. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -10,14 +10,24 @@ 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
|
||||
}
|
||||
|
||||
export function BossDefeatModal({ boss, onSubmit, onClose, isPending, hardcoreMode, starterName }: BossDefeatModalProps) {
|
||||
export function BossDefeatModal({
|
||||
boss,
|
||||
onSubmit,
|
||||
onClose,
|
||||
isPending,
|
||||
hardcoreMode,
|
||||
starterName,
|
||||
}: BossDefeatModalProps) {
|
||||
const [result, setResult] = useState<'won' | 'lost'>('won')
|
||||
const [attempts, setAttempts] = useState('1')
|
||||
|
||||
@@ -30,16 +40,20 @@ export function BossDefeatModal({ boss, onSubmit, onClose, isPending, hardcoreMo
|
||||
}, [boss.pokemon])
|
||||
|
||||
const hasVariants = variantLabels.length > 0
|
||||
const autoMatch = useMemo(() => matchVariant(variantLabels, starterName), [variantLabels, starterName])
|
||||
const autoMatch = useMemo(
|
||||
() => matchVariant(variantLabels, starterName),
|
||||
[variantLabels, starterName]
|
||||
)
|
||||
const showPills = hasVariants && autoMatch === null
|
||||
const [selectedVariant, setSelectedVariant] = useState<string | null>(
|
||||
autoMatch ?? (hasVariants ? variantLabels[0] : null),
|
||||
autoMatch ?? (hasVariants ? variantLabels[0] : 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])
|
||||
|
||||
@@ -58,7 +72,9 @@ export function BossDefeatModal({ boss, onSubmit, onClose, isPending, hardcoreMo
|
||||
<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 */}
|
||||
@@ -88,7 +104,11 @@ export function BossDefeatModal({ boss, onSubmit, onClose, isPending, hardcoreMo
|
||||
.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" />
|
||||
)}
|
||||
@@ -138,7 +158,9 @@ export function BossDefeatModal({ boss, onSubmit, onClose, isPending, hardcoreMo
|
||||
|
||||
{!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}
|
||||
|
||||
@@ -31,8 +31,10 @@ export function EggEncounterModal({
|
||||
const [isSearching, setIsSearching] = useState(false)
|
||||
|
||||
// Only show leaf routes (no children)
|
||||
const parentIds = new Set(routes.filter(r => r.parentRouteId !== null).map(r => r.parentRouteId))
|
||||
const leafRoutes = routes.filter(r => !parentIds.has(r.id))
|
||||
const parentIds = new Set(
|
||||
routes.filter((r) => r.parentRouteId !== null).map((r) => r.parentRouteId)
|
||||
)
|
||||
const leafRoutes = routes.filter((r) => !parentIds.has(r.id))
|
||||
|
||||
// Debounced pokemon search
|
||||
useEffect(() => {
|
||||
@@ -44,7 +46,9 @@ export function EggEncounterModal({
|
||||
const timer = setTimeout(async () => {
|
||||
setIsSearching(true)
|
||||
try {
|
||||
const data = await api.get<{ items: Pokemon[] }>(`/pokemon?search=${encodeURIComponent(search)}&limit=20`)
|
||||
const data = await api.get<{ items: Pokemon[] }>(
|
||||
`/pokemon?search=${encodeURIComponent(search)}&limit=20`
|
||||
)
|
||||
setSearchResults(data.items)
|
||||
} catch {
|
||||
setSearchResults([])
|
||||
@@ -196,11 +200,13 @@ 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>
|
||||
|
||||
@@ -69,14 +69,15 @@ export const METHOD_ORDER = [
|
||||
export function getMethodLabel(method: string): string {
|
||||
return (
|
||||
METHOD_CONFIG[method]?.label ??
|
||||
method
|
||||
.replace(/-/g, ' ')
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase())
|
||||
method.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
|
||||
)
|
||||
}
|
||||
|
||||
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'
|
||||
return (
|
||||
METHOD_CONFIG[method]?.color ??
|
||||
'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300'
|
||||
)
|
||||
}
|
||||
|
||||
export function EncounterMethodBadge({
|
||||
@@ -88,7 +89,8 @@ 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}`}
|
||||
|
||||
@@ -42,31 +42,36 @@ interface EncounterModalProps {
|
||||
isPending: boolean
|
||||
}
|
||||
|
||||
const statusOptions: { value: EncounterStatus; label: string; color: string }[] =
|
||||
[
|
||||
{
|
||||
value: 'caught',
|
||||
label: 'Caught',
|
||||
color:
|
||||
'bg-green-100 text-green-800 border-green-300 dark:bg-green-900/40 dark:text-green-300 dark:border-green-700',
|
||||
},
|
||||
{
|
||||
value: 'fainted',
|
||||
label: 'Fainted',
|
||||
color:
|
||||
'bg-red-100 text-red-800 border-red-300 dark:bg-red-900/40 dark:text-red-300 dark:border-red-700',
|
||||
},
|
||||
{
|
||||
value: 'missed',
|
||||
label: 'Missed / Ran',
|
||||
color:
|
||||
'bg-gray-100 text-gray-800 border-gray-300 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-600',
|
||||
},
|
||||
]
|
||||
const statusOptions: {
|
||||
value: EncounterStatus
|
||||
label: string
|
||||
color: string
|
||||
}[] = [
|
||||
{
|
||||
value: 'caught',
|
||||
label: 'Caught',
|
||||
color:
|
||||
'bg-green-100 text-green-800 border-green-300 dark:bg-green-900/40 dark:text-green-300 dark:border-green-700',
|
||||
},
|
||||
{
|
||||
value: 'fainted',
|
||||
label: 'Fainted',
|
||||
color:
|
||||
'bg-red-100 text-red-800 border-red-300 dark:bg-red-900/40 dark:text-red-300 dark:border-red-700',
|
||||
},
|
||||
{
|
||||
value: 'missed',
|
||||
label: 'Missed / Ran',
|
||||
color:
|
||||
'bg-gray-100 text-gray-800 border-gray-300 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-600',
|
||||
},
|
||||
]
|
||||
|
||||
const SPECIAL_METHODS = ['starter', 'gift', 'fossil', 'trade']
|
||||
|
||||
function groupByMethod(pokemon: RouteEncounterDetail[]): { method: string; pokemon: RouteEncounterDetail[] }[] {
|
||||
function groupByMethod(
|
||||
pokemon: RouteEncounterDetail[]
|
||||
): { method: string; pokemon: RouteEncounterDetail[] }[] {
|
||||
const groups = new Map<string, RouteEncounterDetail[]>()
|
||||
for (const rp of pokemon) {
|
||||
const list = groups.get(rp.encounterMethod) ?? []
|
||||
@@ -84,7 +89,7 @@ function groupByMethod(pokemon: RouteEncounterDetail[]): { method: string; pokem
|
||||
|
||||
function pickRandomPokemon(
|
||||
pokemon: RouteEncounterDetail[],
|
||||
dupedIds?: Set<number>,
|
||||
dupedIds?: Set<number>
|
||||
): RouteEncounterDetail | null {
|
||||
const eligible = dupedIds
|
||||
? pokemon.filter((rp) => !dupedIds.has(rp.pokemonId))
|
||||
@@ -109,17 +114,17 @@ export function EncounterModal({
|
||||
}: EncounterModalProps) {
|
||||
const { data: routePokemon, isLoading: loadingPokemon } = useRoutePokemon(
|
||||
route.id,
|
||||
gameId,
|
||||
gameId
|
||||
)
|
||||
|
||||
const [selectedPokemon, setSelectedPokemon] =
|
||||
useState<RouteEncounterDetail | null>(null)
|
||||
const [status, setStatus] = useState<EncounterStatus>(
|
||||
existing?.status ?? 'caught',
|
||||
existing?.status ?? 'caught'
|
||||
)
|
||||
const [nickname, setNickname] = useState(existing?.nickname ?? '')
|
||||
const [catchLevel, setCatchLevel] = useState<string>(
|
||||
existing?.catchLevel?.toString() ?? '',
|
||||
existing?.catchLevel?.toString() ?? ''
|
||||
)
|
||||
const [faintLevel, setFaintLevel] = useState<string>('')
|
||||
const [deathCause, setDeathCause] = useState('')
|
||||
@@ -128,27 +133,31 @@ export function EncounterModal({
|
||||
const isEditing = !!existing
|
||||
|
||||
const showSuggestions = !!namingScheme && status === 'caught' && !isEditing
|
||||
const lineagePokemonId = isGenlocke && selectedPokemon ? selectedPokemon.pokemonId : null
|
||||
const { data: suggestions, refetch: regenerate, isFetching: loadingSuggestions } =
|
||||
useNameSuggestions(showSuggestions ? runId : null, lineagePokemonId)
|
||||
const lineagePokemonId =
|
||||
isGenlocke && selectedPokemon ? selectedPokemon.pokemonId : null
|
||||
const {
|
||||
data: suggestions,
|
||||
refetch: regenerate,
|
||||
isFetching: loadingSuggestions,
|
||||
} = useNameSuggestions(showSuggestions ? runId : null, lineagePokemonId)
|
||||
|
||||
// Pre-select pokemon when editing
|
||||
useEffect(() => {
|
||||
if (existing && routePokemon) {
|
||||
const match = routePokemon.find(
|
||||
(rp) => rp.pokemonId === existing.pokemonId,
|
||||
(rp) => rp.pokemonId === existing.pokemonId
|
||||
)
|
||||
if (match) setSelectedPokemon(match)
|
||||
}
|
||||
}, [existing, routePokemon])
|
||||
|
||||
const filteredPokemon = routePokemon?.filter((rp) =>
|
||||
rp.pokemon.name.toLowerCase().includes(search.toLowerCase()),
|
||||
rp.pokemon.name.toLowerCase().includes(search.toLowerCase())
|
||||
)
|
||||
|
||||
const groupedPokemon = useMemo(
|
||||
() => (filteredPokemon ? groupByMethod(filteredPokemon) : []),
|
||||
[filteredPokemon],
|
||||
[filteredPokemon]
|
||||
)
|
||||
const hasMultipleGroups = groupedPokemon.length > 1
|
||||
|
||||
@@ -224,13 +233,15 @@ 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),
|
||||
pickRandomPokemon(routePokemon, dupedPokemonIds)
|
||||
)
|
||||
}
|
||||
}}
|
||||
@@ -268,12 +279,15 @@ 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
|
||||
@@ -299,16 +313,24 @@ export function EncounterModal({
|
||||
</span>
|
||||
{isDuped && (
|
||||
<span className="text-[10px] text-gray-400 italic">
|
||||
{retiredPokemonIds?.has(rp.pokemonId) ? 'retired (HoF)' : 'already caught'}
|
||||
{retiredPokemonIds?.has(rp.pokemonId)
|
||||
? 'retired (HoF)'
|
||||
: '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>
|
||||
@@ -518,11 +540,7 @@ export function EncounterModal({
|
||||
onClick={handleSubmit}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{isPending
|
||||
? 'Saving...'
|
||||
: isEditing
|
||||
? 'Update'
|
||||
: 'Log Encounter'}
|
||||
{isPending ? 'Saving...' : isEditing ? 'Update' : 'Log Encounter'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,12 @@ 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!'
|
||||
|
||||
@@ -29,32 +29,46 @@ export function GameGrid({ games, selectedId, onSelect, runs }: GameGridProps) {
|
||||
|
||||
const generations = useMemo(
|
||||
() => [...new Set(games.map((g) => g.generation))].sort(),
|
||||
[games],
|
||||
[games]
|
||||
)
|
||||
|
||||
const regions = useMemo(
|
||||
() => [...new Set(games.map((g) => g.region))].sort(),
|
||||
[games],
|
||||
[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, filter, regionFilter, hideWithActiveRun, hideCompleted, activeRunGameIds, completedRunGameIds])
|
||||
}, [
|
||||
games,
|
||||
filter,
|
||||
regionFilter,
|
||||
hideWithActiveRun,
|
||||
hideCompleted,
|
||||
activeRunGameIds,
|
||||
completedRunGameIds,
|
||||
])
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
const groups: Record<number, Game[]> = {}
|
||||
@@ -77,7 +91,9 @@ 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)}
|
||||
@@ -98,7 +114,9 @@ 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)}
|
||||
|
||||
@@ -134,7 +134,8 @@ export function GenlockeGraveyard({ genlockeId }: GenlockeGraveyardProps) {
|
||||
</span>
|
||||
{data.deadliestLeg && (
|
||||
<span className="text-gray-500 dark:text-gray-400">
|
||||
Deadliest: Leg {data.deadliestLeg.legOrder} — {data.deadliestLeg.gameName} ({data.deadliestLeg.deathCount})
|
||||
Deadliest: Leg {data.deadliestLeg.legOrder} —{' '}
|
||||
{data.deadliestLeg.gameName} ({data.deadliestLeg.deathCount})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -143,7 +144,9 @@ 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>
|
||||
|
||||
@@ -28,7 +28,9 @@ function LegDot({ leg }: { leg: LineageLegEntry }) {
|
||||
|
||||
return (
|
||||
<div className="group relative flex flex-col items-center">
|
||||
<div className={`w-4 h-4 rounded-full ${color} ring-2 ring-offset-1 ring-offset-white dark:ring-offset-gray-800 ring-gray-200 dark:ring-gray-600`} />
|
||||
<div
|
||||
className={`w-4 h-4 rounded-full ${color} ring-2 ring-offset-1 ring-offset-white dark:ring-offset-gray-800 ring-gray-200 dark:ring-gray-600`}
|
||||
/>
|
||||
|
||||
{/* Tooltip */}
|
||||
<div className="absolute bottom-full mb-2 hidden group-hover:flex flex-col items-center z-10">
|
||||
@@ -36,25 +38,32 @@ 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.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>
|
||||
)}
|
||||
<div className={`font-medium ${
|
||||
leg.faintLevel !== null ? 'text-red-300' :
|
||||
leg.wasTransferred ? 'text-blue-300' :
|
||||
leg.enteredHof ? 'text-yellow-300' :
|
||||
'text-green-300'
|
||||
}`}>
|
||||
<div
|
||||
className={`font-medium ${
|
||||
leg.faintLevel !== null
|
||||
? 'text-red-300'
|
||||
: leg.wasTransferred
|
||||
? 'text-blue-300'
|
||||
: leg.enteredHof
|
||||
? 'text-yellow-300'
|
||||
: 'text-green-300'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
{leg.enteredHof && leg.faintLevel === null && (
|
||||
@@ -185,9 +194,11 @@ 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(() => {
|
||||
@@ -230,8 +241,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>
|
||||
|
||||
|
||||
@@ -8,7 +8,12 @@ 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))
|
||||
|
||||
@@ -7,8 +7,20 @@ interface PokemonCardProps {
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -22,7 +22,9 @@ export function ShinyBox({ encounters, onEncounterClick }: ShinyBoxProps) {
|
||||
<PokemonCard
|
||||
key={enc.id}
|
||||
encounter={enc}
|
||||
onClick={onEncounterClick ? () => onEncounterClick(enc) : undefined}
|
||||
onClick={
|
||||
onEncounterClick ? () => onEncounterClick(enc) : undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -24,7 +24,9 @@ interface ShinyEncounterModalProps {
|
||||
|
||||
const SPECIAL_METHODS = ['starter', 'gift', 'fossil', 'trade']
|
||||
|
||||
function groupByMethod(pokemon: RouteEncounterDetail[]): { method: string; pokemon: RouteEncounterDetail[] }[] {
|
||||
function groupByMethod(
|
||||
pokemon: RouteEncounterDetail[]
|
||||
): { method: string; pokemon: RouteEncounterDetail[] }[] {
|
||||
const groups = new Map<string, RouteEncounterDetail[]>()
|
||||
for (const rp of pokemon) {
|
||||
const list = groups.get(rp.encounterMethod) ?? []
|
||||
@@ -50,7 +52,7 @@ export function ShinyEncounterModal({
|
||||
const [selectedRouteId, setSelectedRouteId] = useState<number | null>(null)
|
||||
const { data: routePokemon, isLoading: loadingPokemon } = useRoutePokemon(
|
||||
selectedRouteId,
|
||||
gameId,
|
||||
gameId
|
||||
)
|
||||
|
||||
const [selectedPokemon, setSelectedPokemon] =
|
||||
@@ -60,12 +62,12 @@ export function ShinyEncounterModal({
|
||||
const [search, setSearch] = useState('')
|
||||
|
||||
const filteredPokemon = routePokemon?.filter((rp) =>
|
||||
rp.pokemon.name.toLowerCase().includes(search.toLowerCase()),
|
||||
rp.pokemon.name.toLowerCase().includes(search.toLowerCase())
|
||||
)
|
||||
|
||||
const groupedPokemon = useMemo(
|
||||
() => (filteredPokemon ? groupByMethod(filteredPokemon) : []),
|
||||
[filteredPokemon],
|
||||
[filteredPokemon]
|
||||
)
|
||||
const hasMultipleGroups = groupedPokemon.length > 1
|
||||
|
||||
@@ -90,8 +92,10 @@ export function ShinyEncounterModal({
|
||||
}
|
||||
|
||||
// Only show leaf routes (no children, i.e. routes that aren't parents)
|
||||
const parentIds = new Set(routes.filter(r => r.parentRouteId !== null).map(r => r.parentRouteId))
|
||||
const leafRoutes = routes.filter(r => !parentIds.has(r.id))
|
||||
const parentIds = new Set(
|
||||
routes.filter((r) => r.parentRouteId !== null).map((r) => r.parentRouteId)
|
||||
)
|
||||
const leafRoutes = routes.filter((r) => !parentIds.has(r.id))
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
@@ -206,11 +210,14 @@ export function ShinyEncounterModal({
|
||||
{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>
|
||||
))}
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
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'
|
||||
|
||||
interface StatusChangeModalProps {
|
||||
encounter: EncounterDetail
|
||||
onUpdate: (data: {
|
||||
id: number
|
||||
data: UpdateEncounterInput
|
||||
}) => void
|
||||
onUpdate: (data: { id: number; data: UpdateEncounterInput }) => void
|
||||
onClose: () => void
|
||||
isPending: boolean
|
||||
region?: string
|
||||
@@ -24,15 +25,24 @@ 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('')
|
||||
@@ -40,15 +50,15 @@ export function StatusChangeModal({
|
||||
const activePokemonId = currentPokemon?.id ?? pokemon.id
|
||||
const { data: evolutions, isLoading: evolutionsLoading } = useEvolutions(
|
||||
showEvolve || showShedConfirm ? activePokemonId : null,
|
||||
region,
|
||||
region
|
||||
)
|
||||
const { data: forms } = useForms(isDead ? null : activePokemonId)
|
||||
|
||||
const { normalEvolutions, shedCompanion } = useMemo(() => {
|
||||
if (!evolutions) return { normalEvolutions: [], shedCompanion: null }
|
||||
return {
|
||||
normalEvolutions: evolutions.filter(e => e.trigger !== 'shed'),
|
||||
shedCompanion: evolutions.find(e => e.trigger === 'shed') ?? null,
|
||||
normalEvolutions: evolutions.filter((e) => e.trigger !== 'shed'),
|
||||
shedCompanion: evolutions.find((e) => e.trigger === 'shed') ?? null,
|
||||
}
|
||||
}, [evolutions])
|
||||
|
||||
@@ -187,33 +197,37 @@ export function StatusChangeModal({
|
||||
)}
|
||||
|
||||
{/* Alive pokemon: actions */}
|
||||
{!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 && (
|
||||
{!isDead &&
|
||||
!showConfirm &&
|
||||
!showEvolve &&
|
||||
!showFormChange &&
|
||||
!showShedConfirm && (
|
||||
<div className="flex gap-3">
|
||||
<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"
|
||||
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"
|
||||
>
|
||||
Change Form
|
||||
Evolve
|
||||
</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>
|
||||
)}
|
||||
{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>
|
||||
)}
|
||||
|
||||
{/* Evolution selection */}
|
||||
{!isDead && showEvolve && (
|
||||
@@ -231,10 +245,14 @@ 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">
|
||||
@@ -247,7 +265,11 @@ 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-blue-50 dark:hover:bg-blue-900/20 hover:border-blue-300 dark:hover:border-blue-600 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{evo.toPokemon.spriteUrl ? (
|
||||
<img src={evo.toPokemon.spriteUrl} alt={evo.toPokemon.name} className="w-10 h-10" />
|
||||
<img
|
||||
src={evo.toPokemon.spriteUrl}
|
||||
alt={evo.toPokemon.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">
|
||||
{evo.toPokemon.name[0].toUpperCase()}
|
||||
@@ -302,8 +324,12 @@ export function StatusChangeModal({
|
||||
</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>
|
||||
@@ -340,7 +366,9 @@ 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>
|
||||
@@ -372,7 +400,11 @@ 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()}
|
||||
@@ -465,7 +497,12 @@ export function StatusChangeModal({
|
||||
</div>
|
||||
|
||||
{/* Footer for dead/no-confirm/no-evolve views */}
|
||||
{(isDead || (!isDead && !showConfirm && !showEvolve && !showFormChange && !showShedConfirm)) && (
|
||||
{(isDead ||
|
||||
(!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"
|
||||
|
||||
@@ -6,7 +6,11 @@ interface StepIndicatorProps {
|
||||
steps?: string[]
|
||||
}
|
||||
|
||||
export function StepIndicator({ currentStep, onStepClick, steps = DEFAULT_STEPS }: StepIndicatorProps) {
|
||||
export function StepIndicator({
|
||||
currentStep,
|
||||
onStepClick,
|
||||
steps = DEFAULT_STEPS,
|
||||
}: StepIndicatorProps) {
|
||||
return (
|
||||
<nav aria-label="Progress" className="mb-8">
|
||||
<ol className="flex items-center">
|
||||
|
||||
@@ -8,9 +8,14 @@ interface TransferModalProps {
|
||||
isPending: boolean
|
||||
}
|
||||
|
||||
export function TransferModal({ hofTeam, onSubmit, onSkip, isPending }: TransferModalProps) {
|
||||
export function TransferModal({
|
||||
hofTeam,
|
||||
onSubmit,
|
||||
onSkip,
|
||||
isPending,
|
||||
}: TransferModalProps) {
|
||||
const [selected, setSelected] = useState<Set<number>>(
|
||||
() => new Set(hofTeam.map((e) => e.id)),
|
||||
() => new Set(hofTeam.map((e) => e.id))
|
||||
)
|
||||
|
||||
const toggle = (id: number) => {
|
||||
@@ -34,7 +39,8 @@ export function TransferModal({ hofTeam, onSubmit, onSkip, isPending }: Transfer
|
||||
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>
|
||||
|
||||
|
||||
@@ -6,10 +6,6 @@ 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`}
|
||||
/>
|
||||
<img src={`/types/${type}.png`} alt={type} className={`${height} w-auto`} />
|
||||
)
|
||||
}
|
||||
|
||||
@@ -79,7 +79,10 @@ export function AdminTable<T>({
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<tr key={i}>
|
||||
{columns.map((col) => (
|
||||
<td key={col.header} className={`px-4 py-3 ${col.className ?? ''}`}>
|
||||
<td
|
||||
key={col.header}
|
||||
className={`px-4 py-3 ${col.className ?? ''}`}
|
||||
>
|
||||
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
|
||||
</td>
|
||||
))}
|
||||
@@ -111,7 +114,9 @@ export function AdminTable<T>({
|
||||
return (
|
||||
<th
|
||||
key={col.header}
|
||||
onClick={sortable ? () => handleSort(col.header) : undefined}
|
||||
onClick={
|
||||
sortable ? () => handleSort(col.header) : undefined
|
||||
}
|
||||
className={`px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider ${col.className ?? ''} ${sortable ? 'cursor-pointer select-none hover:text-gray-700 dark:hover:text-gray-200' : ''}`}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
@@ -132,7 +137,11 @@ export function AdminTable<T>({
|
||||
<tr
|
||||
key={keyFn(row)}
|
||||
onClick={onRowClick ? () => onRowClick(row) : undefined}
|
||||
className={onRowClick ? 'cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800' : ''}
|
||||
className={
|
||||
onRowClick
|
||||
? 'cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800'
|
||||
: ''
|
||||
}
|
||||
>
|
||||
{columns.map((col) => (
|
||||
<td
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { type FormEvent, useState } from 'react'
|
||||
import { FormModal } from './FormModal'
|
||||
import type { BossBattle, Game, Route } from '../../types/game'
|
||||
import type { CreateBossBattleInput, UpdateBossBattleInput } from '../../types/admin'
|
||||
import type {
|
||||
CreateBossBattleInput,
|
||||
UpdateBossBattleInput,
|
||||
} from '../../types/admin'
|
||||
|
||||
interface BossBattleFormModalProps {
|
||||
boss?: BossBattle
|
||||
@@ -17,9 +20,24 @@ interface BossBattleFormModalProps {
|
||||
}
|
||||
|
||||
const POKEMON_TYPES = [
|
||||
'normal', 'fire', 'water', 'electric', 'grass', 'ice',
|
||||
'fighting', 'poison', 'ground', 'flying', 'psychic', 'bug',
|
||||
'rock', 'ghost', 'dragon', 'dark', 'steel', 'fairy',
|
||||
'normal',
|
||||
'fire',
|
||||
'water',
|
||||
'electric',
|
||||
'grass',
|
||||
'ice',
|
||||
'fighting',
|
||||
'poison',
|
||||
'ground',
|
||||
'flying',
|
||||
'psychic',
|
||||
'bug',
|
||||
'rock',
|
||||
'ghost',
|
||||
'dragon',
|
||||
'dark',
|
||||
'steel',
|
||||
'fairy',
|
||||
]
|
||||
|
||||
const BOSS_TYPES = [
|
||||
@@ -52,7 +70,9 @@ export function BossBattleFormModal({
|
||||
const [badgeImageUrl, setBadgeImageUrl] = useState(boss?.badgeImageUrl ?? '')
|
||||
const [levelCap, setLevelCap] = useState(String(boss?.levelCap ?? ''))
|
||||
const [order, setOrder] = useState(String(boss?.order ?? nextOrder))
|
||||
const [afterRouteId, setAfterRouteId] = useState(String(boss?.afterRouteId ?? ''))
|
||||
const [afterRouteId, setAfterRouteId] = useState(
|
||||
String(boss?.afterRouteId ?? '')
|
||||
)
|
||||
const [location, setLocation] = useState(boss?.location ?? '')
|
||||
const [section, setSection] = useState(boss?.section ?? '')
|
||||
const [spriteUrl, setSpriteUrl] = useState(boss?.spriteUrl ?? '')
|
||||
@@ -87,15 +107,17 @@ export function BossBattleFormModal({
|
||||
isSubmitting={isSubmitting}
|
||||
onDelete={onDelete}
|
||||
isDeleting={isDeleting}
|
||||
headerExtra={onEditTeam ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEditTeam}
|
||||
className="text-sm text-blue-600 dark:text-blue-400 hover:underline"
|
||||
>
|
||||
Edit Team ({boss?.pokemon.length ?? 0})
|
||||
</button>
|
||||
) : undefined}
|
||||
headerExtra={
|
||||
onEditTeam ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEditTeam}
|
||||
className="text-sm text-blue-600 dark:text-blue-400 hover:underline"
|
||||
>
|
||||
Edit Team ({boss?.pokemon.length ?? 0})
|
||||
</button>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
@@ -190,7 +212,9 @@ export function BossBattleFormModal({
|
||||
</div>
|
||||
{games && games.length > 1 && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Game (version exclusive)</label>
|
||||
<label className="block text-sm font-medium mb-1">
|
||||
Game (version exclusive)
|
||||
</label>
|
||||
<select
|
||||
value={gameId}
|
||||
onChange={(e) => setGameId(e.target.value)}
|
||||
@@ -208,7 +232,9 @@ export function BossBattleFormModal({
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Position After Route</label>
|
||||
<label className="block text-sm font-medium mb-1">
|
||||
Position After Route
|
||||
</label>
|
||||
<select
|
||||
value={afterRouteId}
|
||||
onChange={(e) => setAfterRouteId(e.target.value)}
|
||||
@@ -235,7 +261,9 @@ export function BossBattleFormModal({
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Badge Image URL</label>
|
||||
<label className="block text-sm font-medium mb-1">
|
||||
Badge Image URL
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={badgeImageUrl}
|
||||
|
||||
@@ -38,7 +38,12 @@ function groupByVariant(boss: BossBattle): Variant[] {
|
||||
}
|
||||
|
||||
if (map.size === 0) {
|
||||
return [{ label: null, pokemon: [{ pokemonId: null, pokemonName: '', level: '', order: 1 }] }]
|
||||
return [
|
||||
{
|
||||
label: null,
|
||||
pokemon: [{ pokemonId: null, pokemonName: '', level: '', order: 1 }],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
const variants: Variant[] = []
|
||||
@@ -48,43 +53,71 @@ function groupByVariant(boss: BossBattle): Variant[] {
|
||||
map.delete(null)
|
||||
}
|
||||
// Then alphabetical
|
||||
const remaining = [...map.entries()].sort((a, b) => (a[0] ?? '').localeCompare(b[0] ?? ''))
|
||||
const remaining = [...map.entries()].sort((a, b) =>
|
||||
(a[0] ?? '').localeCompare(b[0] ?? '')
|
||||
)
|
||||
for (const [label, pokemon] of remaining) {
|
||||
variants.push({ label, pokemon })
|
||||
}
|
||||
return variants
|
||||
}
|
||||
|
||||
export function BossTeamEditor({ boss, onSave, onClose, isSaving }: BossTeamEditorProps) {
|
||||
const [variants, setVariants] = useState<Variant[]>(() => groupByVariant(boss))
|
||||
export function BossTeamEditor({
|
||||
boss,
|
||||
onSave,
|
||||
onClose,
|
||||
isSaving,
|
||||
}: BossTeamEditorProps) {
|
||||
const [variants, setVariants] = useState<Variant[]>(() =>
|
||||
groupByVariant(boss)
|
||||
)
|
||||
const [activeTab, setActiveTab] = useState(0)
|
||||
const [newVariantName, setNewVariantName] = useState('')
|
||||
const [showAddVariant, setShowAddVariant] = useState(false)
|
||||
|
||||
const activeVariant = variants[activeTab] ?? variants[0]
|
||||
|
||||
const updateVariant = (tabIndex: number, updater: (v: Variant) => Variant) => {
|
||||
const updateVariant = (
|
||||
tabIndex: number,
|
||||
updater: (v: Variant) => Variant
|
||||
) => {
|
||||
setVariants((prev) => prev.map((v, i) => (i === tabIndex ? updater(v) : v)))
|
||||
}
|
||||
|
||||
const addSlot = () => {
|
||||
updateVariant(activeTab, (v) => ({
|
||||
...v,
|
||||
pokemon: [...v.pokemon, { pokemonId: null, pokemonName: '', level: '', order: v.pokemon.length + 1 }],
|
||||
pokemon: [
|
||||
...v.pokemon,
|
||||
{
|
||||
pokemonId: null,
|
||||
pokemonName: '',
|
||||
level: '',
|
||||
order: v.pokemon.length + 1,
|
||||
},
|
||||
],
|
||||
}))
|
||||
}
|
||||
|
||||
const removeSlot = (index: number) => {
|
||||
updateVariant(activeTab, (v) => ({
|
||||
...v,
|
||||
pokemon: v.pokemon.filter((_, i) => i !== index).map((item, i) => ({ ...item, order: i + 1 })),
|
||||
pokemon: v.pokemon
|
||||
.filter((_, i) => i !== index)
|
||||
.map((item, i) => ({ ...item, order: i + 1 })),
|
||||
}))
|
||||
}
|
||||
|
||||
const updateSlot = (index: number, field: string, value: number | string | null) => {
|
||||
const updateSlot = (
|
||||
index: number,
|
||||
field: string,
|
||||
value: number | string | null
|
||||
) => {
|
||||
updateVariant(activeTab, (v) => ({
|
||||
...v,
|
||||
pokemon: v.pokemon.map((item, i) => (i === index ? { ...item, [field]: value } : item)),
|
||||
pokemon: v.pokemon.map((item, i) =>
|
||||
i === index ? { ...item, [field]: value } : item
|
||||
),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -92,7 +125,13 @@ export function BossTeamEditor({ boss, onSave, onClose, isSaving }: BossTeamEdit
|
||||
const name = newVariantName.trim()
|
||||
if (!name) return
|
||||
if (variants.some((v) => v.label === name)) return
|
||||
setVariants((prev) => [...prev, { label: name, pokemon: [{ pokemonId: null, pokemonName: '', level: '', order: 1 }] }])
|
||||
setVariants((prev) => [
|
||||
...prev,
|
||||
{
|
||||
label: name,
|
||||
pokemon: [{ pokemonId: null, pokemonName: '', level: '', order: 1 }],
|
||||
},
|
||||
])
|
||||
setActiveTab(variants.length)
|
||||
setNewVariantName('')
|
||||
setShowAddVariant(false)
|
||||
@@ -109,8 +148,11 @@ export function BossTeamEditor({ boss, onSave, onClose, isSaving }: BossTeamEdit
|
||||
e.preventDefault()
|
||||
const allPokemon: BossPokemonInput[] = []
|
||||
for (const variant of variants) {
|
||||
const conditionLabel = variants.length === 1 && variant.label === null ? null : variant.label
|
||||
const validPokemon = variant.pokemon.filter((t) => t.pokemonId != null && t.level)
|
||||
const conditionLabel =
|
||||
variants.length === 1 && variant.label === null ? null : variant.label
|
||||
const validPokemon = variant.pokemon.filter(
|
||||
(t) => t.pokemonId != null && t.level
|
||||
)
|
||||
for (let i = 0; i < validPokemon.length; i++) {
|
||||
allPokemon.push({
|
||||
pokemonId: validPokemon[i].pokemonId!,
|
||||
@@ -147,7 +189,10 @@ export function BossTeamEditor({ boss, onSave, onClose, isSaving }: BossTeamEdit
|
||||
{v.label ?? 'Default'}
|
||||
{v.label !== null && (
|
||||
<span
|
||||
onClick={(e) => { e.stopPropagation(); removeVariant(i) }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
removeVariant(i)
|
||||
}}
|
||||
className="ml-1.5 text-gray-400 hover:text-red-500 cursor-pointer"
|
||||
title="Remove variant"
|
||||
>
|
||||
@@ -171,13 +216,31 @@ export function BossTeamEditor({ boss, onSave, onClose, isSaving }: BossTeamEdit
|
||||
type="text"
|
||||
value={newVariantName}
|
||||
onChange={(e) => setNewVariantName(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addVariant() } if (e.key === 'Escape') setShowAddVariant(false) }}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
addVariant()
|
||||
}
|
||||
if (e.key === 'Escape') setShowAddVariant(false)
|
||||
}}
|
||||
placeholder="Variant name..."
|
||||
className="px-2 py-1 text-sm border rounded dark:bg-gray-700 dark:border-gray-600 w-40"
|
||||
autoFocus
|
||||
/>
|
||||
<button type="button" onClick={addVariant} className="px-2 py-1 text-sm text-blue-600 dark:text-blue-400">Add</button>
|
||||
<button type="button" onClick={() => setShowAddVariant(false)} className="px-1 py-1 text-sm text-gray-400">✕</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addVariant}
|
||||
className="px-2 py-1 text-sm text-blue-600 dark:text-blue-400"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAddVariant(false)}
|
||||
className="px-1 py-1 text-sm text-gray-400"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -185,7 +248,10 @@ export function BossTeamEditor({ boss, onSave, onClose, isSaving }: BossTeamEdit
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="px-6 py-4 space-y-3">
|
||||
{activeVariant.pokemon.map((slot, index) => (
|
||||
<div key={`${activeTab}-${index}`} className="flex items-end gap-2">
|
||||
<div
|
||||
key={`${activeTab}-${index}`}
|
||||
className="flex items-end gap-2"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<PokemonSelector
|
||||
label={`Pokemon ${index + 1}`}
|
||||
@@ -195,7 +261,9 @@ export function BossTeamEditor({ boss, onSave, onClose, isSaving }: BossTeamEdit
|
||||
/>
|
||||
</div>
|
||||
<div className="w-20">
|
||||
<label className="block text-sm font-medium mb-1">Level</label>
|
||||
<label className="block text-sm font-medium mb-1">
|
||||
Level
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
|
||||
@@ -12,7 +12,14 @@ interface BulkImportModalProps {
|
||||
updatedLabel?: string
|
||||
}
|
||||
|
||||
export function BulkImportModal({ title, example, onSubmit, onClose, createdLabel = 'Created', updatedLabel = 'Updated' }: BulkImportModalProps) {
|
||||
export function BulkImportModal({
|
||||
title,
|
||||
example,
|
||||
onSubmit,
|
||||
onClose,
|
||||
createdLabel = 'Created',
|
||||
updatedLabel = 'Updated',
|
||||
}: BulkImportModalProps) {
|
||||
const [json, setJson] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [result, setResult] = useState<BulkImportResult | null>(null)
|
||||
@@ -73,7 +80,10 @@ export function BulkImportModal({ title, example, onSubmit, onClose, createdLabe
|
||||
|
||||
{result && (
|
||||
<div className="p-3 bg-green-50 dark:bg-green-900/30 text-green-700 dark:text-green-300 rounded-md text-sm">
|
||||
<p>{createdLabel}: {result.created}, {updatedLabel}: {result.updated}</p>
|
||||
<p>
|
||||
{createdLabel}: {result.created}, {updatedLabel}:{' '}
|
||||
{result.updated}
|
||||
</p>
|
||||
{result.errors.length > 0 && (
|
||||
<ul className="mt-2 list-disc list-inside text-red-600 dark:text-red-400">
|
||||
{result.errors.map((err, i) => (
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { type FormEvent, useState } from 'react'
|
||||
import { FormModal } from './FormModal'
|
||||
import { PokemonSelector } from './PokemonSelector'
|
||||
import type { EvolutionAdmin, CreateEvolutionInput, UpdateEvolutionInput } from '../../types'
|
||||
import type {
|
||||
EvolutionAdmin,
|
||||
CreateEvolutionInput,
|
||||
UpdateEvolutionInput,
|
||||
} from '../../types'
|
||||
|
||||
interface EvolutionFormModalProps {
|
||||
evolution?: EvolutionAdmin
|
||||
@@ -23,10 +27,10 @@ export function EvolutionFormModal({
|
||||
isDeleting,
|
||||
}: EvolutionFormModalProps) {
|
||||
const [fromPokemonId, setFromPokemonId] = useState<number | null>(
|
||||
evolution?.fromPokemonId ?? null,
|
||||
evolution?.fromPokemonId ?? null
|
||||
)
|
||||
const [toPokemonId, setToPokemonId] = useState<number | null>(
|
||||
evolution?.toPokemonId ?? null,
|
||||
evolution?.toPokemonId ?? null
|
||||
)
|
||||
const [trigger, setTrigger] = useState(evolution?.trigger ?? 'level-up')
|
||||
const [minLevel, setMinLevel] = useState(String(evolution?.minLevel ?? ''))
|
||||
|
||||
@@ -55,7 +55,11 @@ export function FormModal({
|
||||
onBlur={() => setConfirmingDelete(false)}
|
||||
className="px-4 py-2 text-sm font-medium rounded-md text-red-600 dark:text-red-400 border border-red-300 dark:border-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 disabled:opacity-50"
|
||||
>
|
||||
{isDeleting ? 'Deleting...' : confirmingDelete ? 'Confirm?' : 'Delete'}
|
||||
{isDeleting
|
||||
? 'Deleting...'
|
||||
: confirmingDelete
|
||||
? 'Confirm?'
|
||||
: 'Delete'}
|
||||
</button>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
|
||||
@@ -20,13 +20,23 @@ function slugify(name: string) {
|
||||
.replace(/^-|-$/g, '')
|
||||
}
|
||||
|
||||
export function GameFormModal({ game, onSubmit, onClose, isSubmitting, onDelete, isDeleting, detailUrl }: GameFormModalProps) {
|
||||
export function GameFormModal({
|
||||
game,
|
||||
onSubmit,
|
||||
onClose,
|
||||
isSubmitting,
|
||||
onDelete,
|
||||
isDeleting,
|
||||
detailUrl,
|
||||
}: GameFormModalProps) {
|
||||
const [name, setName] = useState(game?.name ?? '')
|
||||
const [slug, setSlug] = useState(game?.slug ?? '')
|
||||
const [generation, setGeneration] = useState(String(game?.generation ?? ''))
|
||||
const [region, setRegion] = useState(game?.region ?? '')
|
||||
const [boxArtUrl, setBoxArtUrl] = useState(game?.boxArtUrl ?? '')
|
||||
const [releaseYear, setReleaseYear] = useState(game?.releaseYear ? String(game.releaseYear) : '')
|
||||
const [releaseYear, setReleaseYear] = useState(
|
||||
game?.releaseYear ? String(game.releaseYear) : ''
|
||||
)
|
||||
const [autoSlug, setAutoSlug] = useState(!game)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -53,14 +63,16 @@ export function GameFormModal({ game, onSubmit, onClose, isSubmitting, onDelete,
|
||||
isSubmitting={isSubmitting}
|
||||
onDelete={onDelete}
|
||||
isDeleting={isDeleting}
|
||||
headerExtra={detailUrl ? (
|
||||
<Link
|
||||
to={detailUrl}
|
||||
className="text-sm text-blue-600 dark:text-blue-400 hover:underline"
|
||||
>
|
||||
View Routes & Bosses
|
||||
</Link>
|
||||
) : undefined}
|
||||
headerExtra={
|
||||
detailUrl ? (
|
||||
<Link
|
||||
to={detailUrl}
|
||||
className="text-sm text-blue-600 dark:text-blue-400 hover:underline"
|
||||
>
|
||||
View Routes & Bosses
|
||||
</Link>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Name</label>
|
||||
|
||||
@@ -2,8 +2,17 @@ import { type FormEvent, useState, useEffect } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { EvolutionFormModal } from './EvolutionFormModal'
|
||||
import type { Pokemon, CreatePokemonInput, UpdatePokemonInput, EvolutionAdmin, UpdateEvolutionInput } from '../../types'
|
||||
import { usePokemonEncounterLocations, usePokemonEvolutionChain } from '../../hooks/usePokemon'
|
||||
import type {
|
||||
Pokemon,
|
||||
CreatePokemonInput,
|
||||
UpdatePokemonInput,
|
||||
EvolutionAdmin,
|
||||
UpdateEvolutionInput,
|
||||
} from '../../types'
|
||||
import {
|
||||
usePokemonEncounterLocations,
|
||||
usePokemonEvolutionChain,
|
||||
} from '../../hooks/usePokemon'
|
||||
import { useUpdateEvolution, useDeleteEvolution } from '../../hooks/useAdmin'
|
||||
import { formatEvolutionMethod } from '../../utils/formatEvolution'
|
||||
|
||||
@@ -18,20 +27,32 @@ interface PokemonFormModalProps {
|
||||
|
||||
type Tab = 'details' | 'evolutions' | 'encounters'
|
||||
|
||||
export function PokemonFormModal({ pokemon, onSubmit, onClose, isSubmitting, onDelete, isDeleting }: PokemonFormModalProps) {
|
||||
export function PokemonFormModal({
|
||||
pokemon,
|
||||
onSubmit,
|
||||
onClose,
|
||||
isSubmitting,
|
||||
onDelete,
|
||||
isDeleting,
|
||||
}: PokemonFormModalProps) {
|
||||
const [pokeapiId, setPokeapiId] = useState(String(pokemon?.pokeapiId ?? ''))
|
||||
const [nationalDex, setNationalDex] = useState(String(pokemon?.nationalDex ?? ''))
|
||||
const [nationalDex, setNationalDex] = useState(
|
||||
String(pokemon?.nationalDex ?? '')
|
||||
)
|
||||
const [name, setName] = useState(pokemon?.name ?? '')
|
||||
const [types, setTypes] = useState(pokemon?.types.join(', ') ?? '')
|
||||
const [spriteUrl, setSpriteUrl] = useState(pokemon?.spriteUrl ?? '')
|
||||
const [activeTab, setActiveTab] = useState<Tab>('details')
|
||||
const [editingEvolution, setEditingEvolution] = useState<EvolutionAdmin | null>(null)
|
||||
const [editingEvolution, setEditingEvolution] =
|
||||
useState<EvolutionAdmin | null>(null)
|
||||
const [confirmingDelete, setConfirmingDelete] = useState(false)
|
||||
|
||||
const isEdit = !!pokemon
|
||||
const pokemonId = pokemon?.id ?? null
|
||||
const { data: encounterLocations, isLoading: encountersLoading } = usePokemonEncounterLocations(pokemonId)
|
||||
const { data: evolutionChain, isLoading: evolutionsLoading } = usePokemonEvolutionChain(pokemonId)
|
||||
const { data: encounterLocations, isLoading: encountersLoading } =
|
||||
usePokemonEncounterLocations(pokemonId)
|
||||
const { data: evolutionChain, isLoading: evolutionsLoading } =
|
||||
usePokemonEvolutionChain(pokemonId)
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
const updateEvolution = useUpdateEvolution()
|
||||
@@ -42,7 +63,9 @@ export function PokemonFormModal({ pokemon, onSubmit, onClose, isSubmitting, onD
|
||||
}, [onDelete])
|
||||
|
||||
const invalidateChain = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['pokemon', pokemonId, 'evolution-chain'] })
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['pokemon', pokemonId, 'evolution-chain'],
|
||||
})
|
||||
}
|
||||
|
||||
const handleSubmit = (e: FormEvent) => {
|
||||
@@ -80,7 +103,9 @@ export function PokemonFormModal({ pokemon, onSubmit, onClose, isSubmitting, onD
|
||||
<div className="relative bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-lg w-full mx-4 max-h-[90vh] flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 shrink-0">
|
||||
<h2 className="text-lg font-semibold">{pokemon ? 'Edit Pokemon' : 'Add Pokemon'}</h2>
|
||||
<h2 className="text-lg font-semibold">
|
||||
{pokemon ? 'Edit Pokemon' : 'Add Pokemon'}
|
||||
</h2>
|
||||
{isEdit && (
|
||||
<div className="flex gap-1 mt-2">
|
||||
{tabs.map((tab) => (
|
||||
@@ -99,10 +124,15 @@ export function PokemonFormModal({ pokemon, onSubmit, onClose, isSubmitting, onD
|
||||
|
||||
{/* Details tab (form) */}
|
||||
{activeTab === 'details' && (
|
||||
<form onSubmit={handleSubmit} className="flex flex-col min-h-0 flex-1">
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="flex flex-col min-h-0 flex-1"
|
||||
>
|
||||
<div className="px-6 py-4 space-y-4 overflow-y-auto">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">PokeAPI ID</label>
|
||||
<label className="block text-sm font-medium mb-1">
|
||||
PokeAPI ID
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
required
|
||||
@@ -113,7 +143,9 @@ export function PokemonFormModal({ pokemon, onSubmit, onClose, isSubmitting, onD
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">National Dex #</label>
|
||||
<label className="block text-sm font-medium mb-1">
|
||||
National Dex #
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
required
|
||||
@@ -134,7 +166,9 @@ export function PokemonFormModal({ pokemon, onSubmit, onClose, isSubmitting, onD
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Types (comma-separated)</label>
|
||||
<label className="block text-sm font-medium mb-1">
|
||||
Types (comma-separated)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
@@ -145,7 +179,9 @@ export function PokemonFormModal({ pokemon, onSubmit, onClose, isSubmitting, onD
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Sprite URL</label>
|
||||
<label className="block text-sm font-medium mb-1">
|
||||
Sprite URL
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={spriteUrl}
|
||||
@@ -170,7 +206,11 @@ export function PokemonFormModal({ pokemon, onSubmit, onClose, isSubmitting, onD
|
||||
onBlur={() => setConfirmingDelete(false)}
|
||||
className="px-4 py-2 text-sm font-medium rounded-md text-red-600 dark:text-red-400 border border-red-300 dark:border-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 disabled:opacity-50"
|
||||
>
|
||||
{isDeleting ? 'Deleting...' : confirmingDelete ? 'Confirm?' : 'Delete'}
|
||||
{isDeleting
|
||||
? 'Deleting...'
|
||||
: confirmingDelete
|
||||
? 'Confirm?'
|
||||
: 'Delete'}
|
||||
</button>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
@@ -197,28 +237,35 @@ export function PokemonFormModal({ pokemon, onSubmit, onClose, isSubmitting, onD
|
||||
<div className="flex flex-col min-h-0 flex-1">
|
||||
<div className="px-6 py-4 overflow-y-auto">
|
||||
{evolutionsLoading && (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Loading...</p>
|
||||
)}
|
||||
{!evolutionsLoading && (!evolutionChain || evolutionChain.length === 0) && (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">No evolutions</p>
|
||||
)}
|
||||
{!evolutionsLoading && evolutionChain && evolutionChain.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{evolutionChain.map((evo) => (
|
||||
<button
|
||||
key={evo.id}
|
||||
type="button"
|
||||
onClick={() => setEditingEvolution(evo)}
|
||||
className="w-full text-left text-sm text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded px-2 py-1.5 -mx-2 transition-colors"
|
||||
>
|
||||
{evo.fromPokemon.name} → {evo.toPokemon.name}{' '}
|
||||
<span className="text-gray-400 dark:text-gray-500">
|
||||
({formatEvolutionMethod(evo)})
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Loading...
|
||||
</p>
|
||||
)}
|
||||
{!evolutionsLoading &&
|
||||
(!evolutionChain || evolutionChain.length === 0) && (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
No evolutions
|
||||
</p>
|
||||
)}
|
||||
{!evolutionsLoading &&
|
||||
evolutionChain &&
|
||||
evolutionChain.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{evolutionChain.map((evo) => (
|
||||
<button
|
||||
key={evo.id}
|
||||
type="button"
|
||||
onClick={() => setEditingEvolution(evo)}
|
||||
className="w-full text-left text-sm text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded px-2 py-1.5 -mx-2 transition-colors"
|
||||
>
|
||||
{evo.fromPokemon.name} → {evo.toPokemon.name}{' '}
|
||||
<span className="text-gray-400 dark:text-gray-500">
|
||||
({formatEvolutionMethod(evo)})
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end shrink-0">
|
||||
<button
|
||||
@@ -237,37 +284,48 @@ export function PokemonFormModal({ pokemon, onSubmit, onClose, isSubmitting, onD
|
||||
<div className="flex flex-col min-h-0 flex-1">
|
||||
<div className="px-6 py-4 overflow-y-auto">
|
||||
{encountersLoading && (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Loading...</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Loading...
|
||||
</p>
|
||||
)}
|
||||
{!encountersLoading && (!encounterLocations || encounterLocations.length === 0) && (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">No encounters</p>
|
||||
)}
|
||||
{!encountersLoading && encounterLocations && encounterLocations.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{encounterLocations.map((game) => (
|
||||
<div key={game.gameId}>
|
||||
<div className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{game.gameName}
|
||||
</div>
|
||||
<div className="space-y-0.5 pl-2">
|
||||
{game.encounters.map((enc, i) => (
|
||||
<div key={i} className="text-sm text-gray-600 dark:text-gray-400 flex items-center gap-1">
|
||||
<Link
|
||||
to={`/admin/games/${game.gameId}/routes/${enc.routeId}`}
|
||||
className="text-blue-600 dark:text-blue-400 hover:underline"
|
||||
{!encountersLoading &&
|
||||
(!encounterLocations || encounterLocations.length === 0) && (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
No encounters
|
||||
</p>
|
||||
)}
|
||||
{!encountersLoading &&
|
||||
encounterLocations &&
|
||||
encounterLocations.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{encounterLocations.map((game) => (
|
||||
<div key={game.gameId}>
|
||||
<div className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{game.gameName}
|
||||
</div>
|
||||
<div className="space-y-0.5 pl-2">
|
||||
{game.encounters.map((enc, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="text-sm text-gray-600 dark:text-gray-400 flex items-center gap-1"
|
||||
>
|
||||
{enc.routeName}
|
||||
</Link>
|
||||
<span className="text-gray-400 dark:text-gray-500">
|
||||
— {enc.encounterMethod}, Lv. {enc.minLevel}–{enc.maxLevel}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
<Link
|
||||
to={`/admin/games/${game.gameId}/routes/${enc.routeId}`}
|
||||
className="text-blue-600 dark:text-blue-400 hover:underline"
|
||||
>
|
||||
{enc.routeName}
|
||||
</Link>
|
||||
<span className="text-gray-400 dark:text-gray-500">
|
||||
— {enc.encounterMethod}, Lv. {enc.minLevel}–
|
||||
{enc.maxLevel}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end shrink-0">
|
||||
<button
|
||||
@@ -294,7 +352,7 @@ export function PokemonFormModal({ pokemon, onSubmit, onClose, isSubmitting, onD
|
||||
setEditingEvolution(null)
|
||||
invalidateChain()
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
onClose={() => setEditingEvolution(null)}
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
import { type FormEvent, useState } from 'react'
|
||||
import { FormModal } from './FormModal'
|
||||
import { PokemonSelector } from './PokemonSelector'
|
||||
import { METHOD_ORDER, METHOD_CONFIG, getMethodLabel } from '../EncounterMethodBadge'
|
||||
import type { RouteEncounterDetail, CreateRouteEncounterInput, UpdateRouteEncounterInput } from '../../types'
|
||||
import {
|
||||
METHOD_ORDER,
|
||||
METHOD_CONFIG,
|
||||
getMethodLabel,
|
||||
} from '../EncounterMethodBadge'
|
||||
import type {
|
||||
RouteEncounterDetail,
|
||||
CreateRouteEncounterInput,
|
||||
UpdateRouteEncounterInput,
|
||||
} from '../../types'
|
||||
|
||||
interface RouteEncounterFormModalProps {
|
||||
encounter?: RouteEncounterDetail
|
||||
onSubmit: (data: CreateRouteEncounterInput | UpdateRouteEncounterInput) => void
|
||||
onSubmit: (
|
||||
data: CreateRouteEncounterInput | UpdateRouteEncounterInput
|
||||
) => void
|
||||
onClose: () => void
|
||||
isSubmitting?: boolean
|
||||
onDelete?: () => void
|
||||
@@ -25,11 +35,18 @@ export function RouteEncounterFormModal({
|
||||
|
||||
const initialMethod = encounter?.encounterMethod ?? ''
|
||||
const isKnownMethod = METHOD_ORDER.includes(initialMethod)
|
||||
const [selectedMethod, setSelectedMethod] = useState(isKnownMethod ? initialMethod : initialMethod ? 'other' : '')
|
||||
const [customMethod, setCustomMethod] = useState(isKnownMethod ? '' : initialMethod)
|
||||
const encounterMethod = selectedMethod === 'other' ? customMethod : selectedMethod
|
||||
const [selectedMethod, setSelectedMethod] = useState(
|
||||
isKnownMethod ? initialMethod : initialMethod ? 'other' : ''
|
||||
)
|
||||
const [customMethod, setCustomMethod] = useState(
|
||||
isKnownMethod ? '' : initialMethod
|
||||
)
|
||||
const encounterMethod =
|
||||
selectedMethod === 'other' ? customMethod : selectedMethod
|
||||
|
||||
const [encounterRate, setEncounterRate] = useState(String(encounter?.encounterRate ?? ''))
|
||||
const [encounterRate, setEncounterRate] = useState(
|
||||
String(encounter?.encounterRate ?? '')
|
||||
)
|
||||
const [minLevel, setMinLevel] = useState(String(encounter?.minLevel ?? ''))
|
||||
const [maxLevel, setMaxLevel] = useState(String(encounter?.maxLevel ?? ''))
|
||||
|
||||
@@ -70,7 +87,9 @@ export function RouteEncounterFormModal({
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Encounter Method</label>
|
||||
<label className="block text-sm font-medium mb-1">
|
||||
Encounter Method
|
||||
</label>
|
||||
<select
|
||||
required
|
||||
value={selectedMethod}
|
||||
@@ -107,7 +126,9 @@ export function RouteEncounterFormModal({
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Encounter Rate (%)</label>
|
||||
<label className="block text-sm font-medium mb-1">
|
||||
Encounter Rate (%)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
required
|
||||
|
||||
@@ -14,7 +14,16 @@ interface RouteFormModalProps {
|
||||
detailUrl?: string
|
||||
}
|
||||
|
||||
export function RouteFormModal({ route, nextOrder, onSubmit, onClose, isSubmitting, onDelete, isDeleting, detailUrl }: RouteFormModalProps) {
|
||||
export function RouteFormModal({
|
||||
route,
|
||||
nextOrder,
|
||||
onSubmit,
|
||||
onClose,
|
||||
isSubmitting,
|
||||
onDelete,
|
||||
isDeleting,
|
||||
detailUrl,
|
||||
}: RouteFormModalProps) {
|
||||
const [name, setName] = useState(route?.name ?? '')
|
||||
const [order, setOrder] = useState(String(route?.order ?? nextOrder ?? 0))
|
||||
const [pinwheelZone, setPinwheelZone] = useState(
|
||||
@@ -38,14 +47,16 @@ export function RouteFormModal({ route, nextOrder, onSubmit, onClose, isSubmitti
|
||||
isSubmitting={isSubmitting}
|
||||
onDelete={onDelete}
|
||||
isDeleting={isDeleting}
|
||||
headerExtra={detailUrl ? (
|
||||
<Link
|
||||
to={detailUrl}
|
||||
className="text-sm text-blue-600 dark:text-blue-400 hover:underline"
|
||||
>
|
||||
View Encounters
|
||||
</Link>
|
||||
) : undefined}
|
||||
headerExtra={
|
||||
detailUrl ? (
|
||||
<Link
|
||||
to={detailUrl}
|
||||
className="text-sm text-blue-600 dark:text-blue-400 hover:underline"
|
||||
>
|
||||
View Encounters
|
||||
</Link>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Name</label>
|
||||
@@ -79,7 +90,8 @@ export function RouteFormModal({ route, nextOrder, onSubmit, onClose, isSubmitti
|
||||
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Routes in the same zone share an encounter when the Pinwheel Clause is active
|
||||
Routes in the same zone share an encounter when the Pinwheel Clause is
|
||||
active
|
||||
</p>
|
||||
</div>
|
||||
</FormModal>
|
||||
|
||||
Reference in New Issue
Block a user