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>
|
||||
)}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import { Layout } from './Layout'
|
||||
import { AuthProvider } from '../contexts/AuthContext'
|
||||
|
||||
vi.mock('../hooks/useTheme', () => ({
|
||||
useTheme: () => ({ theme: 'dark' as const, toggle: vi.fn() }),
|
||||
@@ -10,7 +11,9 @@ vi.mock('../hooks/useTheme', () => ({
|
||||
function renderLayout(initialPath = '/') {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={[initialPath]}>
|
||||
<Layout />
|
||||
<AuthProvider>
|
||||
<Layout />
|
||||
</AuthProvider>
|
||||
</MemoryRouter>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from 'react'
|
||||
import { Link, Outlet, useLocation } from 'react-router-dom'
|
||||
import { useTheme } from '../hooks/useTheme'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
|
||||
const navLinks = [
|
||||
{ to: '/runs/new', label: 'New Run' },
|
||||
@@ -71,6 +72,67 @@ function ThemeToggle() {
|
||||
)
|
||||
}
|
||||
|
||||
function UserMenu({ onAction }: { onAction?: () => void }) {
|
||||
const { user, loading, signOut } = useAuth()
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
if (loading) {
|
||||
return <div className="w-8 h-8 rounded-full bg-surface-3 animate-pulse" />
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<Link
|
||||
to="/login"
|
||||
onClick={onAction}
|
||||
className="px-3 py-2 rounded-md text-sm font-medium text-text-secondary hover:text-text-primary hover:bg-surface-3 transition-colors"
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
const email = user.email ?? ''
|
||||
const initials = email.charAt(0).toUpperCase()
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(!open)}
|
||||
className="flex items-center gap-2 p-1 rounded-full hover:bg-surface-3 transition-colors"
|
||||
>
|
||||
<div className="w-8 h-8 rounded-full bg-accent-600 flex items-center justify-center text-white text-sm font-medium">
|
||||
{initials}
|
||||
</div>
|
||||
</button>
|
||||
{open && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={() => setOpen(false)} />
|
||||
<div className="absolute right-0 mt-2 w-48 bg-surface-2 border border-border-default rounded-lg shadow-lg z-50">
|
||||
<div className="px-4 py-3 border-b border-border-default">
|
||||
<p className="text-sm text-text-primary truncate">{email}</p>
|
||||
</div>
|
||||
<div className="py-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
setOpen(false)
|
||||
onAction?.()
|
||||
await signOut()
|
||||
}}
|
||||
className="w-full text-left px-4 py-2 text-sm text-text-secondary hover:text-text-primary hover:bg-surface-3 transition-colors"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Layout() {
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const location = useLocation()
|
||||
@@ -103,6 +165,7 @@ export function Layout() {
|
||||
</NavLink>
|
||||
))}
|
||||
<ThemeToggle />
|
||||
<UserMenu />
|
||||
</div>
|
||||
{/* Mobile hamburger */}
|
||||
<div className="flex items-center gap-1 sm:hidden">
|
||||
@@ -149,6 +212,9 @@ export function Layout() {
|
||||
{link.label}
|
||||
</NavLink>
|
||||
))}
|
||||
<div className="pt-2 border-t border-border-default mt-2">
|
||||
<UserMenu onAction={() => setMenuOpen(false)} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
21
frontend/src/components/ProtectedRoute.tsx
Normal file
21
frontend/src/components/ProtectedRoute.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Navigate, useLocation } from 'react-router-dom'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
|
||||
export function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
const { user, loading } = useAuth()
|
||||
const location = useLocation()
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-accent-500" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return <Navigate to="/login" state={{ from: location }} replace />
|
||||
}
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
export { CustomRulesDisplay } from './CustomRulesDisplay'
|
||||
export { ProtectedRoute } from './ProtectedRoute'
|
||||
export { EggEncounterModal } from './EggEncounterModal'
|
||||
export { EncounterMethodBadge } from './EncounterMethodBadge'
|
||||
export { EncounterModal } from './EncounterModal'
|
||||
|
||||
@@ -6,8 +6,8 @@ import type { BossResult, BossBattle } from '../../types/game'
|
||||
|
||||
interface JournalEditorProps {
|
||||
entry?: JournalEntry | null
|
||||
bossResults?: BossResult[]
|
||||
bosses?: BossBattle[]
|
||||
bossResults?: BossResult[] | undefined
|
||||
bosses?: BossBattle[] | undefined
|
||||
onSave: (data: { title: string; body: string; bossResultId: number | null }) => void
|
||||
onDelete?: () => void
|
||||
onCancel: () => void
|
||||
@@ -67,7 +67,10 @@ export function JournalEditor({
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="journal-title" className="block text-sm font-medium text-text-secondary mb-1">
|
||||
<label
|
||||
htmlFor="journal-title"
|
||||
className="block text-sm font-medium text-text-secondary mb-1"
|
||||
>
|
||||
Title
|
||||
</label>
|
||||
<input
|
||||
@@ -82,7 +85,10 @@ export function JournalEditor({
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="journal-boss" className="block text-sm font-medium text-text-secondary mb-1">
|
||||
<label
|
||||
htmlFor="journal-boss"
|
||||
className="block text-sm font-medium text-text-secondary mb-1"
|
||||
>
|
||||
Linked Boss Battle (optional)
|
||||
</label>
|
||||
<select
|
||||
|
||||
@@ -5,8 +5,8 @@ import type { BossResult, BossBattle } from '../../types/game'
|
||||
|
||||
interface JournalEntryViewProps {
|
||||
entry: JournalEntry
|
||||
bossResult?: BossResult | null
|
||||
boss?: BossBattle | null
|
||||
bossResult?: BossResult | null | undefined
|
||||
boss?: BossBattle | null | undefined
|
||||
onEdit?: () => void
|
||||
onBack?: () => void
|
||||
}
|
||||
@@ -38,7 +38,12 @@ export function JournalEntryView({
|
||||
className="text-text-secondary hover:text-text-primary transition-colors flex items-center gap-1"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 19l-7-7 7-7"
|
||||
/>
|
||||
</svg>
|
||||
Back to Journal
|
||||
</button>
|
||||
|
||||
@@ -19,7 +19,10 @@ function formatDate(dateString: string): string {
|
||||
}
|
||||
|
||||
function getPreviewSnippet(body: string, maxLength = 120): string {
|
||||
const stripped = body.replace(/[#*_`~[\]]/g, '').replace(/\n+/g, ' ').trim()
|
||||
const stripped = body
|
||||
.replace(/[#*_`~[\]]/g, '')
|
||||
.replace(/\n+/g, ' ')
|
||||
.trim()
|
||||
if (stripped.length <= maxLength) return stripped
|
||||
return stripped.slice(0, maxLength).trim() + '...'
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@ import type { BossResult, BossBattle } from '../../types/game'
|
||||
|
||||
interface JournalSectionProps {
|
||||
runId: number
|
||||
bossResults?: BossResult[]
|
||||
bosses?: BossBattle[]
|
||||
bossResults?: BossResult[] | undefined
|
||||
bosses?: BossBattle[] | undefined
|
||||
}
|
||||
|
||||
type Mode = 'list' | 'new'
|
||||
|
||||
Reference in New Issue
Block a user