Add boss battles, level caps, and badge tracking
Introduces full boss battle system: data models (BossBattle, BossPokemon, BossResult), API endpoints for CRUD and per-run defeat tracking, and frontend UI including a sticky level cap bar with badge display on the run page, interleaved boss battle cards in the encounter list, and an admin panel section for managing boss battles and their pokemon teams. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
120
frontend/src/components/BossDefeatModal.tsx
Normal file
120
frontend/src/components/BossDefeatModal.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import { type FormEvent, useState } from 'react'
|
||||
import type { BossBattle, CreateBossResultInput } from '../types/game'
|
||||
|
||||
interface BossDefeatModalProps {
|
||||
boss: BossBattle
|
||||
onSubmit: (data: CreateBossResultInput) => void
|
||||
onClose: () => void
|
||||
isPending?: boolean
|
||||
}
|
||||
|
||||
export function BossDefeatModal({ boss, onSubmit, onClose, isPending }: BossDefeatModalProps) {
|
||||
const [result, setResult] = useState<'won' | 'lost'>('won')
|
||||
const [attempts, setAttempts] = useState('1')
|
||||
|
||||
const handleSubmit = (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
onSubmit({
|
||||
bossBattleId: boss.id,
|
||||
result,
|
||||
attempts: Number(attempts) || 1,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="fixed inset-0 bg-black/50" onClick={onClose} />
|
||||
<div className="relative bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-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>
|
||||
</div>
|
||||
|
||||
{/* Boss team preview */}
|
||||
{boss.pokemon.length > 0 && (
|
||||
<div className="px-6 py-3 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{boss.pokemon
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.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" />
|
||||
) : (
|
||||
<div className="w-10 h-10 bg-gray-200 dark:bg-gray-700 rounded-full" />
|
||||
)}
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 capitalize">
|
||||
{bp.pokemon.name}
|
||||
</span>
|
||||
<span className="text-xs font-medium text-gray-700 dark:text-gray-300">
|
||||
Lv.{bp.level}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="px-6 py-4 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">Result</label>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setResult('won')}
|
||||
className={`flex-1 px-3 py-2 text-sm font-medium rounded-md border transition-colors ${
|
||||
result === 'won'
|
||||
? 'bg-green-600 text-white border-green-600'
|
||||
: 'border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
Won
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setResult('lost')}
|
||||
className={`flex-1 px-3 py-2 text-sm font-medium rounded-md border transition-colors ${
|
||||
result === 'lost'
|
||||
? 'bg-red-600 text-white border-red-600'
|
||||
: 'border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
Lost
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Attempts</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={attempts}
|
||||
onChange={(e) => setAttempts(e.target.value)}
|
||||
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm font-medium rounded-md border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isPending}
|
||||
className="px-4 py-2 text-sm font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{isPending ? 'Saving...' : 'Save Result'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
183
frontend/src/components/admin/BossBattleFormModal.tsx
Normal file
183
frontend/src/components/admin/BossBattleFormModal.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
import { type FormEvent, useState } from 'react'
|
||||
import { FormModal } from './FormModal'
|
||||
import type { BossBattle, Route } from '../../types/game'
|
||||
import type { CreateBossBattleInput, UpdateBossBattleInput } from '../../types/admin'
|
||||
|
||||
interface BossBattleFormModalProps {
|
||||
boss?: BossBattle
|
||||
routes: Route[]
|
||||
nextOrder: number
|
||||
onSubmit: (data: CreateBossBattleInput | UpdateBossBattleInput) => void
|
||||
onClose: () => void
|
||||
isSubmitting?: boolean
|
||||
}
|
||||
|
||||
const BOSS_TYPES = [
|
||||
{ value: 'gym_leader', label: 'Gym Leader' },
|
||||
{ value: 'elite_four', label: 'Elite Four' },
|
||||
{ value: 'champion', label: 'Champion' },
|
||||
{ value: 'rival', label: 'Rival' },
|
||||
{ value: 'evil_team', label: 'Evil Team' },
|
||||
{ value: 'other', label: 'Other' },
|
||||
]
|
||||
|
||||
export function BossBattleFormModal({
|
||||
boss,
|
||||
routes,
|
||||
nextOrder,
|
||||
onSubmit,
|
||||
onClose,
|
||||
isSubmitting,
|
||||
}: BossBattleFormModalProps) {
|
||||
const [name, setName] = useState(boss?.name ?? '')
|
||||
const [bossType, setBossType] = useState(boss?.bossType ?? 'gym_leader')
|
||||
const [badgeName, setBadgeName] = useState(boss?.badgeName ?? '')
|
||||
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 [location, setLocation] = useState(boss?.location ?? '')
|
||||
const [spriteUrl, setSpriteUrl] = useState(boss?.spriteUrl ?? '')
|
||||
|
||||
const handleSubmit = (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
onSubmit({
|
||||
name,
|
||||
bossType,
|
||||
badgeName: badgeName || null,
|
||||
badgeImageUrl: badgeImageUrl || null,
|
||||
levelCap: Number(levelCap),
|
||||
order: Number(order),
|
||||
afterRouteId: afterRouteId ? Number(afterRouteId) : null,
|
||||
location,
|
||||
spriteUrl: spriteUrl || null,
|
||||
})
|
||||
}
|
||||
|
||||
// Sort routes by order for the dropdown
|
||||
const sortedRoutes = [...routes].sort((a, b) => a.order - b.order)
|
||||
|
||||
return (
|
||||
<FormModal
|
||||
title={boss ? 'Edit Boss Battle' : 'Add Boss Battle'}
|
||||
onClose={onClose}
|
||||
onSubmit={handleSubmit}
|
||||
isSubmitting={isSubmitting}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g. Brock"
|
||||
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Type</label>
|
||||
<select
|
||||
value={bossType}
|
||||
onChange={(e) => setBossType(e.target.value)}
|
||||
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
|
||||
>
|
||||
{BOSS_TYPES.map((t) => (
|
||||
<option key={t.value} value={t.value}>
|
||||
{t.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Location</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={location}
|
||||
onChange={(e) => setLocation(e.target.value)}
|
||||
placeholder="e.g. Pewter City Gym"
|
||||
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Level Cap</label>
|
||||
<input
|
||||
type="number"
|
||||
required
|
||||
min={1}
|
||||
value={levelCap}
|
||||
onChange={(e) => setLevelCap(e.target.value)}
|
||||
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Order</label>
|
||||
<input
|
||||
type="number"
|
||||
required
|
||||
min={1}
|
||||
value={order}
|
||||
onChange={(e) => setOrder(e.target.value)}
|
||||
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Position After Route</label>
|
||||
<select
|
||||
value={afterRouteId}
|
||||
onChange={(e) => setAfterRouteId(e.target.value)}
|
||||
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
|
||||
>
|
||||
<option value="">None</option>
|
||||
{sortedRoutes.map((r) => (
|
||||
<option key={r.id} value={r.id}>
|
||||
{r.order}. {r.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Badge Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={badgeName}
|
||||
onChange={(e) => setBadgeName(e.target.value)}
|
||||
placeholder="Optional"
|
||||
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Badge Image URL</label>
|
||||
<input
|
||||
type="text"
|
||||
value={badgeImageUrl}
|
||||
onChange={(e) => setBadgeImageUrl(e.target.value)}
|
||||
placeholder="Optional"
|
||||
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Sprite URL</label>
|
||||
<input
|
||||
type="text"
|
||||
value={spriteUrl}
|
||||
onChange={(e) => setSpriteUrl(e.target.value)}
|
||||
placeholder="Optional"
|
||||
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
|
||||
/>
|
||||
</div>
|
||||
</FormModal>
|
||||
)
|
||||
}
|
||||
129
frontend/src/components/admin/BossTeamEditor.tsx
Normal file
129
frontend/src/components/admin/BossTeamEditor.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import { type FormEvent, useState } from 'react'
|
||||
import { PokemonSelector } from './PokemonSelector'
|
||||
import type { BossBattle } from '../../types/game'
|
||||
import type { BossPokemonInput } from '../../types/admin'
|
||||
|
||||
interface BossTeamEditorProps {
|
||||
boss: BossBattle
|
||||
onSave: (team: BossPokemonInput[]) => void
|
||||
onClose: () => void
|
||||
isSaving?: boolean
|
||||
}
|
||||
|
||||
export function BossTeamEditor({ boss, onSave, onClose, isSaving }: BossTeamEditorProps) {
|
||||
const [team, setTeam] = useState<Array<{ pokemonId: number | null; pokemonName: string; level: string; order: number }>>(
|
||||
boss.pokemon.length > 0
|
||||
? boss.pokemon
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((bp) => ({
|
||||
pokemonId: bp.pokemonId,
|
||||
pokemonName: bp.pokemon.name,
|
||||
level: String(bp.level),
|
||||
order: bp.order,
|
||||
}))
|
||||
: [{ pokemonId: null, pokemonName: '', level: '', order: 1 }],
|
||||
)
|
||||
|
||||
const addSlot = () => {
|
||||
setTeam((prev) => [
|
||||
...prev,
|
||||
{ pokemonId: null, pokemonName: '', level: '', order: prev.length + 1 },
|
||||
])
|
||||
}
|
||||
|
||||
const removeSlot = (index: number) => {
|
||||
setTeam((prev) => prev.filter((_, i) => i !== index).map((item, i) => ({ ...item, order: i + 1 })))
|
||||
}
|
||||
|
||||
const updateSlot = (index: number, field: string, value: number | string | null) => {
|
||||
setTeam((prev) =>
|
||||
prev.map((item, i) => (i === index ? { ...item, [field]: value } : item)),
|
||||
)
|
||||
}
|
||||
|
||||
const handleSubmit = (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
const validTeam: BossPokemonInput[] = team
|
||||
.filter((t) => t.pokemonId != null && t.level)
|
||||
.map((t, i) => ({
|
||||
pokemonId: t.pokemonId!,
|
||||
level: Number(t.level),
|
||||
order: i + 1,
|
||||
}))
|
||||
onSave(validTeam)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="fixed inset-0 bg-black/50" onClick={onClose} />
|
||||
<div className="relative bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-lg w-full mx-4 max-h-[90vh] overflow-y-auto">
|
||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-lg font-semibold">{boss.name}'s Team</h2>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="px-6 py-4 space-y-3">
|
||||
{team.map((slot, index) => (
|
||||
<div key={index} className="flex items-end gap-2">
|
||||
<div className="flex-1">
|
||||
<PokemonSelector
|
||||
label={`Pokemon ${index + 1}`}
|
||||
selectedId={slot.pokemonId}
|
||||
initialName={slot.pokemonName}
|
||||
onChange={(id) => updateSlot(index, 'pokemonId', id)}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-20">
|
||||
<label className="block text-sm font-medium mb-1">Level</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
value={slot.level}
|
||||
onChange={(e) => updateSlot(index, 'level', e.target.value)}
|
||||
className="w-full px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeSlot(index)}
|
||||
className="px-2 py-2 text-red-500 hover:text-red-700 text-sm"
|
||||
title="Remove"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{team.length < 6 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={addSlot}
|
||||
className="w-full py-2 text-sm text-blue-600 dark:text-blue-400 border border-dashed border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||
>
|
||||
+ Add Pokemon
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm font-medium rounded-md border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSaving}
|
||||
className="px-4 py-2 text-sm font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{isSaving ? 'Saving...' : 'Save Team'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user