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:
69
frontend/src/components/admin/AbilitySelector.tsx
Normal file
69
frontend/src/components/admin/AbilitySelector.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { useSearchAbilities } from '../../hooks/useMoves'
|
||||
|
||||
interface AbilitySelectorProps {
|
||||
label: string
|
||||
selectedId: number | null
|
||||
initialName?: string
|
||||
onChange: (id: number | null, name: string) => void
|
||||
}
|
||||
|
||||
export function AbilitySelector({
|
||||
label,
|
||||
selectedId,
|
||||
initialName,
|
||||
onChange,
|
||||
}: AbilitySelectorProps) {
|
||||
const [search, setSearch] = useState(initialName ?? '')
|
||||
const [open, setOpen] = useState(false)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const { data } = useSearchAbilities(search)
|
||||
const abilities = data?.items ?? []
|
||||
|
||||
useEffect(() => {
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClick)
|
||||
return () => document.removeEventListener('mousedown', handleClick)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<label className="block text-xs font-medium mb-0.5 text-text-secondary">{label}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value)
|
||||
setOpen(true)
|
||||
if (!e.target.value) onChange(null, '')
|
||||
}}
|
||||
onFocus={() => search && setOpen(true)}
|
||||
placeholder="Search ability..."
|
||||
className="w-full px-2 py-1.5 text-sm border rounded bg-surface-2 border-border-default"
|
||||
/>
|
||||
{open && abilities.length > 0 && (
|
||||
<ul className="absolute z-20 mt-1 w-full bg-surface-1 border border-border-default rounded shadow-lg max-h-40 overflow-y-auto">
|
||||
{abilities.map((a) => (
|
||||
<li
|
||||
key={a.id}
|
||||
onClick={() => {
|
||||
onChange(a.id, a.name)
|
||||
setSearch(a.name)
|
||||
setOpen(false)
|
||||
}}
|
||||
className={`px-2 py-1.5 cursor-pointer hover:bg-surface-2 text-sm ${
|
||||
a.id === selectedId ? 'bg-accent-900/30' : ''
|
||||
}`}
|
||||
>
|
||||
{a.name}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,38 @@
|
||||
import { type FormEvent, useState } from 'react'
|
||||
import { PokemonSelector } from './PokemonSelector'
|
||||
import { MoveSelector } from './MoveSelector'
|
||||
import { AbilitySelector } from './AbilitySelector'
|
||||
import type { BossBattle } from '../../types/game'
|
||||
import type { BossPokemonInput } from '../../types/admin'
|
||||
|
||||
const NATURES = [
|
||||
'Hardy',
|
||||
'Lonely',
|
||||
'Brave',
|
||||
'Adamant',
|
||||
'Naughty',
|
||||
'Bold',
|
||||
'Docile',
|
||||
'Relaxed',
|
||||
'Impish',
|
||||
'Lax',
|
||||
'Timid',
|
||||
'Hasty',
|
||||
'Serious',
|
||||
'Jolly',
|
||||
'Naive',
|
||||
'Modest',
|
||||
'Mild',
|
||||
'Quiet',
|
||||
'Bashful',
|
||||
'Rash',
|
||||
'Calm',
|
||||
'Gentle',
|
||||
'Sassy',
|
||||
'Careful',
|
||||
'Quirky',
|
||||
]
|
||||
|
||||
interface BossTeamEditorProps {
|
||||
boss: BossBattle
|
||||
onSave: (team: BossPokemonInput[]) => void
|
||||
@@ -15,6 +45,19 @@ interface PokemonSlot {
|
||||
pokemonName: string
|
||||
level: string
|
||||
order: number
|
||||
// Detail fields
|
||||
abilityId: number | null
|
||||
abilityName: string
|
||||
heldItem: string
|
||||
nature: string
|
||||
move1Id: number | null
|
||||
move1Name: string
|
||||
move2Id: number | null
|
||||
move2Name: string
|
||||
move3Id: number | null
|
||||
move3Name: string
|
||||
move4Id: number | null
|
||||
move4Name: string
|
||||
}
|
||||
|
||||
interface Variant {
|
||||
@@ -22,6 +65,27 @@ interface Variant {
|
||||
pokemon: PokemonSlot[]
|
||||
}
|
||||
|
||||
function createEmptySlot(order: number): PokemonSlot {
|
||||
return {
|
||||
pokemonId: null,
|
||||
pokemonName: '',
|
||||
level: '',
|
||||
order,
|
||||
abilityId: null,
|
||||
abilityName: '',
|
||||
heldItem: '',
|
||||
nature: '',
|
||||
move1Id: null,
|
||||
move1Name: '',
|
||||
move2Id: null,
|
||||
move2Name: '',
|
||||
move3Id: null,
|
||||
move3Name: '',
|
||||
move4Id: null,
|
||||
move4Name: '',
|
||||
}
|
||||
}
|
||||
|
||||
function groupByVariant(boss: BossBattle): Variant[] {
|
||||
const sorted = [...boss.pokemon].sort((a, b) => a.order - b.order)
|
||||
const map = new Map<string | null, PokemonSlot[]>()
|
||||
@@ -34,25 +98,30 @@ function groupByVariant(boss: BossBattle): Variant[] {
|
||||
pokemonName: bp.pokemon.name,
|
||||
level: String(bp.level),
|
||||
order: bp.order,
|
||||
abilityId: bp.abilityId,
|
||||
abilityName: bp.ability?.name ?? '',
|
||||
heldItem: bp.heldItem ?? '',
|
||||
nature: bp.nature ?? '',
|
||||
move1Id: bp.move1Id,
|
||||
move1Name: bp.move1?.name ?? '',
|
||||
move2Id: bp.move2Id,
|
||||
move2Name: bp.move2?.name ?? '',
|
||||
move3Id: bp.move3Id,
|
||||
move3Name: bp.move3?.name ?? '',
|
||||
move4Id: bp.move4Id,
|
||||
move4Name: bp.move4?.name ?? '',
|
||||
})
|
||||
}
|
||||
|
||||
if (map.size === 0) {
|
||||
return [
|
||||
{
|
||||
label: null,
|
||||
pokemon: [{ pokemonId: null, pokemonName: '', level: '', order: 1 }],
|
||||
},
|
||||
]
|
||||
return [{ label: null, pokemon: [createEmptySlot(1)] }]
|
||||
}
|
||||
|
||||
const variants: Variant[] = []
|
||||
// null (default) first
|
||||
if (map.has(null)) {
|
||||
variants.push({ label: null, pokemon: map.get(null)! })
|
||||
map.delete(null)
|
||||
}
|
||||
// Then alphabetical
|
||||
const remaining = [...map.entries()].sort((a, b) => (a[0] ?? '').localeCompare(b[0] ?? ''))
|
||||
for (const [label, pokemon] of remaining) {
|
||||
variants.push({ label, pokemon })
|
||||
@@ -65,9 +134,19 @@ export function BossTeamEditor({ boss, onSave, onClose, isSaving }: BossTeamEdit
|
||||
const [activeTab, setActiveTab] = useState(0)
|
||||
const [newVariantName, setNewVariantName] = useState('')
|
||||
const [showAddVariant, setShowAddVariant] = useState(false)
|
||||
const [expandedSlots, setExpandedSlots] = useState<Set<string>>(new Set())
|
||||
|
||||
const activeVariant = variants[activeTab] ?? variants[0]
|
||||
|
||||
const toggleExpanded = (key: string) => {
|
||||
setExpandedSlots((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(key)) next.delete(key)
|
||||
else next.add(key)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const updateVariant = (tabIndex: number, updater: (v: Variant) => Variant) => {
|
||||
setVariants((prev) => prev.map((v, i) => (i === tabIndex ? updater(v) : v)))
|
||||
}
|
||||
@@ -75,15 +154,7 @@ export function BossTeamEditor({ boss, onSave, onClose, isSaving }: BossTeamEdit
|
||||
const addSlot = () => {
|
||||
updateVariant(activeTab, (v) => ({
|
||||
...v,
|
||||
pokemon: [
|
||||
...v.pokemon,
|
||||
{
|
||||
pokemonId: null,
|
||||
pokemonName: '',
|
||||
level: '',
|
||||
order: v.pokemon.length + 1,
|
||||
},
|
||||
],
|
||||
pokemon: [...v.pokemon, createEmptySlot(v.pokemon.length + 1)],
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -96,10 +167,10 @@ export function BossTeamEditor({ boss, onSave, onClose, isSaving }: BossTeamEdit
|
||||
}))
|
||||
}
|
||||
|
||||
const updateSlot = (index: number, field: string, value: number | string | null) => {
|
||||
const updateSlot = (index: number, updates: Partial<PokemonSlot>) => {
|
||||
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, ...updates } : item)),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -107,13 +178,7 @@ 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: [createEmptySlot(1)] }])
|
||||
setActiveTab(variants.length)
|
||||
setNewVariantName('')
|
||||
setShowAddVariant(false)
|
||||
@@ -141,6 +206,13 @@ export function BossTeamEditor({ boss, onSave, onClose, isSaving }: BossTeamEdit
|
||||
level: Number(p.level),
|
||||
order: i + 1,
|
||||
conditionLabel,
|
||||
abilityId: p.abilityId,
|
||||
heldItem: p.heldItem || null,
|
||||
nature: p.nature || null,
|
||||
move1Id: p.move1Id,
|
||||
move2Id: p.move2Id,
|
||||
move3Id: p.move3Id,
|
||||
move4Id: p.move4Id,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -150,7 +222,7 @@ export function BossTeamEditor({ boss, onSave, onClose, isSaving }: BossTeamEdit
|
||||
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-surface-1 rounded-lg shadow-xl max-w-lg w-full mx-4 max-h-[90vh] overflow-y-auto">
|
||||
<div className="relative bg-surface-1 rounded-lg shadow-xl max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
|
||||
<div className="px-6 py-4 border-b border-border-default">
|
||||
<h2 className="text-lg font-semibold">{boss.name}'s Team</h2>
|
||||
</div>
|
||||
@@ -209,11 +281,7 @@ export function BossTeamEditor({ boss, onSave, onClose, isSaving }: BossTeamEdit
|
||||
className="px-2 py-1 text-sm border rounded bg-surface-2 border-border-default w-40"
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addVariant}
|
||||
className="px-2 py-1 text-sm text-text-link"
|
||||
>
|
||||
<button type="button" onClick={addVariant} className="px-2 py-1 text-sm text-text-link">
|
||||
Add
|
||||
</button>
|
||||
<button
|
||||
@@ -228,38 +296,149 @@ export function BossTeamEditor({ boss, onSave, onClose, isSaving }: BossTeamEdit
|
||||
</div>
|
||||
|
||||
<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 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 bg-surface-2 border-border-default"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeSlot(index)}
|
||||
className="px-2 py-2 text-red-500 hover:text-red-700 text-sm"
|
||||
title="Remove"
|
||||
<div className="px-6 py-4 space-y-4">
|
||||
{activeVariant?.pokemon.map((slot, index) => {
|
||||
const slotKey = `${activeTab}-${index}`
|
||||
const isExpanded = expandedSlots.has(slotKey)
|
||||
const hasDetails =
|
||||
slot.abilityId ||
|
||||
slot.heldItem ||
|
||||
slot.nature ||
|
||||
slot.move1Id ||
|
||||
slot.move2Id ||
|
||||
slot.move3Id ||
|
||||
slot.move4Id
|
||||
|
||||
return (
|
||||
<div
|
||||
key={slotKey}
|
||||
className="border border-border-default rounded-lg p-3 bg-surface-0"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{/* Main row: Pokemon + Level */}
|
||||
<div 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 bg-surface-2 border-border-default"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleExpanded(slotKey)}
|
||||
className={`px-2 py-2 text-sm transition-colors ${
|
||||
hasDetails ? 'text-accent-500' : 'text-text-tertiary hover:text-text-secondary'
|
||||
}`}
|
||||
title={isExpanded ? 'Hide details' : 'Show details'}
|
||||
>
|
||||
{isExpanded ? '▼' : '▶'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeSlot(index)}
|
||||
className="px-2 py-2 text-red-500 hover:text-red-700 text-sm"
|
||||
title="Remove"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Expandable details */}
|
||||
{isExpanded && (
|
||||
<div className="mt-3 pt-3 border-t border-border-default space-y-3">
|
||||
{/* Row 1: Ability, Held Item, Nature */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<AbilitySelector
|
||||
label="Ability"
|
||||
selectedId={slot.abilityId}
|
||||
initialName={slot.abilityName}
|
||||
onChange={(id, name) =>
|
||||
updateSlot(index, { abilityId: id, abilityName: name })
|
||||
}
|
||||
/>
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-0.5 text-text-secondary">
|
||||
Held Item
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={slot.heldItem}
|
||||
onChange={(e) => updateSlot(index, { heldItem: e.target.value })}
|
||||
placeholder="e.g. Leftovers"
|
||||
className="w-full px-2 py-1.5 text-sm border rounded bg-surface-2 border-border-default"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-0.5 text-text-secondary">
|
||||
Nature
|
||||
</label>
|
||||
<select
|
||||
value={slot.nature}
|
||||
onChange={(e) => updateSlot(index, { nature: e.target.value })}
|
||||
className="w-full px-2 py-1.5 text-sm border rounded bg-surface-2 border-border-default"
|
||||
>
|
||||
<option value="">—</option>
|
||||
{NATURES.map((n) => (
|
||||
<option key={n} value={n}>
|
||||
{n}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 2: Moves */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<MoveSelector
|
||||
label="Move 1"
|
||||
selectedId={slot.move1Id}
|
||||
initialName={slot.move1Name}
|
||||
onChange={(id, name) =>
|
||||
updateSlot(index, { move1Id: id, move1Name: name })
|
||||
}
|
||||
/>
|
||||
<MoveSelector
|
||||
label="Move 2"
|
||||
selectedId={slot.move2Id}
|
||||
initialName={slot.move2Name}
|
||||
onChange={(id, name) =>
|
||||
updateSlot(index, { move2Id: id, move2Name: name })
|
||||
}
|
||||
/>
|
||||
<MoveSelector
|
||||
label="Move 3"
|
||||
selectedId={slot.move3Id}
|
||||
initialName={slot.move3Name}
|
||||
onChange={(id, name) =>
|
||||
updateSlot(index, { move3Id: id, move3Name: name })
|
||||
}
|
||||
/>
|
||||
<MoveSelector
|
||||
label="Move 4"
|
||||
selectedId={slot.move4Id}
|
||||
initialName={slot.move4Name}
|
||||
onChange={(id, name) =>
|
||||
updateSlot(index, { move4Id: id, move4Name: name })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{activeVariant && activeVariant.pokemon.length < 6 && (
|
||||
<button
|
||||
|
||||
64
frontend/src/components/admin/MoveSelector.tsx
Normal file
64
frontend/src/components/admin/MoveSelector.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { useSearchMoves } from '../../hooks/useMoves'
|
||||
|
||||
interface MoveSelectorProps {
|
||||
label: string
|
||||
selectedId: number | null
|
||||
initialName?: string
|
||||
onChange: (id: number | null, name: string) => void
|
||||
}
|
||||
|
||||
export function MoveSelector({ label, selectedId, initialName, onChange }: MoveSelectorProps) {
|
||||
const [search, setSearch] = useState(initialName ?? '')
|
||||
const [open, setOpen] = useState(false)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const { data } = useSearchMoves(search)
|
||||
const moves = data?.items ?? []
|
||||
|
||||
useEffect(() => {
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClick)
|
||||
return () => document.removeEventListener('mousedown', handleClick)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<label className="block text-xs font-medium mb-0.5 text-text-secondary">{label}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value)
|
||||
setOpen(true)
|
||||
if (!e.target.value) onChange(null, '')
|
||||
}}
|
||||
onFocus={() => search && setOpen(true)}
|
||||
placeholder="Search move..."
|
||||
className="w-full px-2 py-1.5 text-sm border rounded bg-surface-2 border-border-default"
|
||||
/>
|
||||
{open && moves.length > 0 && (
|
||||
<ul className="absolute z-20 mt-1 w-full bg-surface-1 border border-border-default rounded shadow-lg max-h-40 overflow-y-auto">
|
||||
{moves.map((m) => (
|
||||
<li
|
||||
key={m.id}
|
||||
onClick={() => {
|
||||
onChange(m.id, m.name)
|
||||
setSearch(m.name)
|
||||
setOpen(false)
|
||||
}}
|
||||
className={`px-2 py-1.5 cursor-pointer hover:bg-surface-2 text-sm ${
|
||||
m.id === selectedId ? 'bg-accent-900/30' : ''
|
||||
}`}
|
||||
>
|
||||
{m.name}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user