feat: add auth system, boss pokemon details, moves/abilities API, and run ownership
Some checks failed
CI / backend-tests (push) Failing after 1m16s
CI / frontend-tests (push) Successful in 57s

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:
2026-03-20 21:41:38 +01:00
parent a6cb309b8b
commit 0a519e356e
69 changed files with 3574 additions and 693 deletions

View File

@@ -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>
)}

View File

@@ -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>
)
}

View File

@@ -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>
)}

View 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}</>
}

View 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>
)
}

View File

@@ -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"
>
&#10005;
</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"
>
&#10005;
</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

View 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>
)
}

View File

@@ -1,4 +1,5 @@
export { CustomRulesDisplay } from './CustomRulesDisplay'
export { ProtectedRoute } from './ProtectedRoute'
export { EggEncounterModal } from './EggEncounterModal'
export { EncounterMethodBadge } from './EncounterMethodBadge'
export { EncounterModal } from './EncounterModal'

View File

@@ -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

View File

@@ -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>

View File

@@ -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() + '...'
}

View File

@@ -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'