feat: add auth system, boss pokemon details, moves/abilities API, and run ownership
Add user authentication with login/signup/protected routes, boss pokemon detail fields and result team tracking, moves and abilities selector components and API, run ownership and visibility controls, and various UI improvements across encounters, run list, and journal pages. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,9 +1,15 @@
|
||||
import { type FormEvent, useMemo, useState } from 'react'
|
||||
import type { BossBattle, CreateBossResultInput } from '../types/game'
|
||||
import type {
|
||||
BossBattle,
|
||||
BossResultTeamMemberInput,
|
||||
CreateBossResultInput,
|
||||
EncounterDetail,
|
||||
} from '../types/game'
|
||||
import { ConditionBadge } from './ConditionBadge'
|
||||
|
||||
interface BossDefeatModalProps {
|
||||
boss: BossBattle
|
||||
aliveEncounters: EncounterDetail[]
|
||||
onSubmit: (data: CreateBossResultInput) => void
|
||||
onClose: () => void
|
||||
isPending?: boolean
|
||||
@@ -17,14 +23,43 @@ function matchVariant(labels: string[], starterName?: string | null): string | n
|
||||
return matches.length === 1 ? (matches[0] ?? null) : null
|
||||
}
|
||||
|
||||
interface TeamSelection {
|
||||
encounterId: number
|
||||
level: number
|
||||
}
|
||||
|
||||
export function BossDefeatModal({
|
||||
boss,
|
||||
aliveEncounters,
|
||||
onSubmit,
|
||||
onClose,
|
||||
isPending,
|
||||
starterName,
|
||||
}: BossDefeatModalProps) {
|
||||
const [selectedTeam, setSelectedTeam] = useState<Map<number, TeamSelection>>(new Map())
|
||||
|
||||
const toggleTeamMember = (enc: EncounterDetail) => {
|
||||
setSelectedTeam((prev) => {
|
||||
const next = new Map(prev)
|
||||
if (next.has(enc.id)) {
|
||||
next.delete(enc.id)
|
||||
} else {
|
||||
next.set(enc.id, { encounterId: enc.id, level: enc.catchLevel ?? 1 })
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const updateLevel = (encounterId: number, level: number) => {
|
||||
setSelectedTeam((prev) => {
|
||||
const next = new Map(prev)
|
||||
const existing = next.get(encounterId)
|
||||
if (existing) {
|
||||
next.set(encounterId, { ...existing, level })
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
const variantLabels = useMemo(() => {
|
||||
const labels = new Set<string>()
|
||||
for (const bp of boss.pokemon) {
|
||||
@@ -52,10 +87,12 @@ export function BossDefeatModal({
|
||||
|
||||
const handleSubmit = (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
const team: BossResultTeamMemberInput[] = Array.from(selectedTeam.values())
|
||||
onSubmit({
|
||||
bossBattleId: boss.id,
|
||||
result: 'won',
|
||||
attempts: 1,
|
||||
team,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -92,18 +129,93 @@ export function BossDefeatModal({
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{[...displayedPokemon]
|
||||
.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" />
|
||||
.map((bp) => {
|
||||
const moves = [bp.move1, bp.move2, bp.move3, bp.move4].filter(Boolean)
|
||||
return (
|
||||
<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-surface-3 rounded-full" />
|
||||
)}
|
||||
<span className="text-xs text-text-tertiary capitalize">{bp.pokemon.name}</span>
|
||||
<span className="text-xs font-medium text-text-secondary">Lv.{bp.level}</span>
|
||||
<ConditionBadge condition={bp.conditionLabel} size="xs" />
|
||||
{bp.ability && (
|
||||
<span className="text-[10px] text-text-muted">{bp.ability.name}</span>
|
||||
)}
|
||||
{bp.heldItem && (
|
||||
<span className="text-[10px] text-yellow-500/80">{bp.heldItem}</span>
|
||||
)}
|
||||
{moves.length > 0 && (
|
||||
<div className="text-[9px] text-text-muted text-center leading-tight max-w-[80px]">
|
||||
{moves.map((m) => m!.name).join(', ')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Team selection */}
|
||||
{aliveEncounters.length > 0 && (
|
||||
<div className="px-6 py-3 border-b border-border-default">
|
||||
<p className="text-sm font-medium text-text-secondary mb-2">Your team (optional)</p>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2 max-h-48 overflow-y-auto">
|
||||
{aliveEncounters.map((enc) => {
|
||||
const isSelected = selectedTeam.has(enc.id)
|
||||
const selection = selectedTeam.get(enc.id)
|
||||
const displayPokemon = enc.currentPokemon ?? enc.pokemon
|
||||
return (
|
||||
<div
|
||||
key={enc.id}
|
||||
className={`flex items-center gap-2 p-2 rounded border cursor-pointer transition-colors ${
|
||||
isSelected
|
||||
? 'border-accent-500 bg-accent-500/10'
|
||||
: 'border-border-default hover:bg-surface-2'
|
||||
}`}
|
||||
onClick={() => toggleTeamMember(enc)}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => toggleTeamMember(enc)}
|
||||
className="sr-only"
|
||||
/>
|
||||
{displayPokemon.spriteUrl ? (
|
||||
<img
|
||||
src={displayPokemon.spriteUrl}
|
||||
alt={displayPokemon.name}
|
||||
className="w-8 h-8"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-10 h-10 bg-surface-3 rounded-full" />
|
||||
<div className="w-8 h-8 bg-surface-3 rounded-full" />
|
||||
)}
|
||||
<span className="text-xs text-text-tertiary capitalize">{bp.pokemon.name}</span>
|
||||
<span className="text-xs font-medium text-text-secondary">Lv.{bp.level}</span>
|
||||
<ConditionBadge condition={bp.conditionLabel} size="xs" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs font-medium truncate">
|
||||
{enc.nickname ?? displayPokemon.name}
|
||||
</p>
|
||||
{isSelected && (
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
value={selection?.level ?? enc.catchLevel ?? 1}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation()
|
||||
updateLevel(enc.id, Number.parseInt(e.target.value, 10) || 1)
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="w-14 text-xs px-1 py-0.5 mt-1 rounded border border-border-default bg-surface-1"
|
||||
placeholder="Lv"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user