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

@@ -246,19 +246,33 @@ function BossTeamPreview({
<div className="flex gap-2 flex-wrap">
{[...displayed]
.sort((a, b) => a.order - b.order)
.map((bp) => (
<div key={bp.id} className="flex items-center gap-1">
{bp.pokemon.spriteUrl ? (
<img src={bp.pokemon.spriteUrl} alt={bp.pokemon.name} className="w-20 h-20" />
) : (
<div className="w-20 h-20 bg-surface-3 rounded-full" />
)}
<div className="flex flex-col items-start gap-0.5">
<span className="text-xs text-text-tertiary">Lvl {bp.level}</span>
<ConditionBadge condition={bp.conditionLabel} size="xs" />
.map((bp) => {
const moves = [bp.move1, bp.move2, bp.move3, bp.move4].filter(Boolean)
return (
<div key={bp.id} className="flex items-center gap-1">
{bp.pokemon.spriteUrl ? (
<img src={bp.pokemon.spriteUrl} alt={bp.pokemon.name} className="w-20 h-20" />
) : (
<div className="w-20 h-20 bg-surface-3 rounded-full" />
)}
<div className="flex flex-col items-start gap-0.5">
<span className="text-xs text-text-tertiary">Lvl {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 leading-tight">
{moves.map((m) => m!.name).join(', ')}
</div>
)}
</div>
</div>
</div>
))}
)
})}
</div>
</div>
)
@@ -663,6 +677,28 @@ export function RunEncounters() {
return set
}, [bossResults])
// Map encounter ID to encounter detail for team display
const encounterById = useMemo(() => {
const map = new Map<number, EncounterDetail>()
if (run) {
for (const enc of run.encounters) {
map.set(enc.id, enc)
}
}
return map
}, [run])
// Map boss battle ID to result for team snapshot
const bossResultByBattleId = useMemo(() => {
const map = new Map<number, (typeof bossResults)[number]>()
if (bossResults) {
for (const r of bossResults) {
map.set(r.bossBattleId, r)
}
}
return map
}, [bossResults])
const sortedBosses = useMemo(() => {
if (!bosses) return []
return [...bosses].sort((a, b) => a.order - b.order)
@@ -1174,238 +1210,258 @@ export function RunEncounters() {
{activeTab === 'encounters' && (
<>
{/* Team Section */}
{(alive.length > 0 || dead.length > 0) && (
<div className="mb-6">
<div className="flex items-center justify-between mb-3">
<button
type="button"
onClick={() => setShowTeam(!showTeam)}
className="flex items-center gap-2 group"
>
<h2 className="text-lg font-semibold text-text-primary">
{isActive ? 'Team' : 'Final Team'}
</h2>
<span className="text-xs text-text-muted">
{alive.length} alive
{dead.length > 0 ? `, ${dead.length} dead` : ''}
</span>
<svg
className={`w-4 h-4 text-text-tertiary transition-transform ${showTeam ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
{showTeam && alive.length > 1 && (
<select
value={teamSort}
onChange={(e) => setTeamSort(e.target.value as TeamSortKey)}
className="text-sm border border-border-default rounded-lg px-3 py-1.5 bg-surface-1 text-text-primary"
>
<option value="route">Route Order</option>
<option value="level">Catch Level</option>
<option value="species">Species Name</option>
<option value="dex">National Dex</option>
</select>
)}
</div>
{showTeam && (
<>
{alive.length > 0 && (
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 gap-2 mb-3">
{alive.map((enc) => (
<PokemonCard
key={enc.id}
encounter={enc}
onClick={isActive ? () => setSelectedTeamEncounter(enc) : undefined}
{(alive.length > 0 || dead.length > 0) && (
<div className="mb-6">
<div className="flex items-center justify-between mb-3">
<button
type="button"
onClick={() => setShowTeam(!showTeam)}
className="flex items-center gap-2 group"
>
<h2 className="text-lg font-semibold text-text-primary">
{isActive ? 'Team' : 'Final Team'}
</h2>
<span className="text-xs text-text-muted">
{alive.length} alive
{dead.length > 0 ? `, ${dead.length} dead` : ''}
</span>
<svg
className={`w-4 h-4 text-text-tertiary transition-transform ${showTeam ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
))}
</div>
)}
{dead.length > 0 && (
</svg>
</button>
{showTeam && alive.length > 1 && (
<select
value={teamSort}
onChange={(e) => setTeamSort(e.target.value as TeamSortKey)}
className="text-sm border border-border-default rounded-lg px-3 py-1.5 bg-surface-1 text-text-primary"
>
<option value="route">Route Order</option>
<option value="level">Catch Level</option>
<option value="species">Species Name</option>
<option value="dex">National Dex</option>
</select>
)}
</div>
{showTeam && (
<>
<h3 className="text-sm font-medium text-text-tertiary mb-2">Graveyard</h3>
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 gap-2">
{dead.map((enc) => (
<PokemonCard
key={enc.id}
encounter={enc}
showFaintLevel
onClick={isActive ? () => setSelectedTeamEncounter(enc) : undefined}
/>
))}
</div>
{alive.length > 0 && (
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 gap-2 mb-3">
{alive.map((enc) => (
<PokemonCard
key={enc.id}
encounter={enc}
onClick={isActive ? () => setSelectedTeamEncounter(enc) : undefined}
/>
))}
</div>
)}
{dead.length > 0 && (
<>
<h3 className="text-sm font-medium text-text-tertiary mb-2">Graveyard</h3>
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 gap-2">
{dead.map((enc) => (
<PokemonCard
key={enc.id}
encounter={enc}
showFaintLevel
onClick={isActive ? () => setSelectedTeamEncounter(enc) : undefined}
/>
))}
</div>
</>
)}
</>
)}
</>
</div>
)}
</div>
)}
{/* Shiny Box */}
{run.rules?.shinyClause && shinyEncounters.length > 0 && (
<div className="mb-6">
<ShinyBox
encounters={shinyEncounters}
onEncounterClick={isActive ? (enc) => setSelectedTeamEncounter(enc) : undefined}
/>
</div>
)}
{/* Transfer Encounters */}
{transferEncounters.length > 0 && (
<div className="mb-6">
<h2 className="text-sm font-medium text-indigo-400 mb-2">Transferred Pokemon</h2>
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 gap-2">
{transferEncounters.map((enc) => (
<PokemonCard
key={enc.id}
encounter={enc}
onClick={isActive ? () => setSelectedTeamEncounter(enc) : undefined}
{/* Shiny Box */}
{run.rules?.shinyClause && shinyEncounters.length > 0 && (
<div className="mb-6">
<ShinyBox
encounters={shinyEncounters}
onEncounterClick={isActive ? (enc) => setSelectedTeamEncounter(enc) : undefined}
/>
</div>
)}
{/* Transfer Encounters */}
{transferEncounters.length > 0 && (
<div className="mb-6">
<h2 className="text-sm font-medium text-indigo-400 mb-2">Transferred Pokemon</h2>
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 gap-2">
{transferEncounters.map((enc) => (
<PokemonCard
key={enc.id}
encounter={enc}
onClick={isActive ? () => setSelectedTeamEncounter(enc) : undefined}
/>
))}
</div>
</div>
)}
{/* Progress bar */}
<div className="mb-4">
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-3">
<h2 className="text-lg font-semibold text-text-primary">Encounters</h2>
{isActive && completedCount < totalLocations && (
<button
type="button"
disabled={bulkRandomize.isPending}
onClick={() => {
const remaining = totalLocations - completedCount
if (
window.confirm(
`Randomize encounters for all ${remaining} remaining locations?`
)
) {
bulkRandomize.mutate()
}
}}
className="px-2.5 py-1 text-xs font-medium rounded-lg border border-purple-600 text-purple-400 light:text-purple-700 light:border-purple-500 hover:bg-purple-900/20 light:hover:bg-purple-50 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
{bulkRandomize.isPending ? 'Randomizing...' : 'Randomize All'}
</button>
)}
</div>
<span className="text-sm text-text-tertiary">
{completedCount} / {totalLocations} locations
</span>
</div>
<div className="h-2 bg-surface-3 rounded-full overflow-hidden">
<div
className="h-full bg-blue-500 rounded-full transition-all"
style={{
width: `${totalLocations > 0 ? (completedCount / totalLocations) * 100 : 0}%`,
}}
/>
</div>
</div>
{/* Filter tabs */}
<div className="flex gap-2 mb-4 flex-wrap">
{(
[
{ key: 'all', label: 'All' },
{ key: 'none', label: 'Unvisited' },
{ key: 'caught', label: 'Caught' },
{ key: 'fainted', label: 'Fainted' },
{ key: 'missed', label: 'Missed' },
] as const
).map(({ key, label }) => (
<button
key={key}
onClick={() => setFilter(key)}
className={`px-3 py-1 rounded-full text-sm font-medium transition-colors ${
filter === key
? 'bg-blue-600 text-white'
: 'bg-surface-2 text-text-secondary hover:bg-surface-3'
}`}
>
{label}
</button>
))}
</div>
</div>
)}
{/* Progress bar */}
<div className="mb-4">
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-3">
<h2 className="text-lg font-semibold text-text-primary">Encounters</h2>
{isActive && completedCount < totalLocations && (
<button
type="button"
disabled={bulkRandomize.isPending}
onClick={() => {
const remaining = totalLocations - completedCount
if (
window.confirm(`Randomize encounters for all ${remaining} remaining locations?`)
) {
bulkRandomize.mutate()
}
}}
className="px-2.5 py-1 text-xs font-medium rounded-lg border border-purple-600 text-purple-400 light:text-purple-700 light:border-purple-500 hover:bg-purple-900/20 light:hover:bg-purple-50 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
{bulkRandomize.isPending ? 'Randomizing...' : 'Randomize All'}
</button>
{/* Route list */}
<div className="space-y-1">
{filteredRoutes.length === 0 && (
<p className="text-text-tertiary text-sm py-4 text-center">
{filter === 'all'
? 'Click a route to log your first encounter'
: 'No routes match this filter — try a different one'}
</p>
)}
</div>
<span className="text-sm text-text-tertiary">
{completedCount} / {totalLocations} locations
</span>
</div>
<div className="h-2 bg-surface-3 rounded-full overflow-hidden">
<div
className="h-full bg-blue-500 rounded-full transition-all"
style={{
width: `${totalLocations > 0 ? (completedCount / totalLocations) * 100 : 0}%`,
}}
/>
</div>
</div>
{filteredRoutes.map((route) => {
// Collect all route IDs to check for boss cards after
const routeIds: number[] =
route.children.length > 0
? [route.id, ...route.children.map((c) => c.id)]
: [route.id]
{/* Filter tabs */}
<div className="flex gap-2 mb-4 flex-wrap">
{(
[
{ key: 'all', label: 'All' },
{ key: 'none', label: 'Unvisited' },
{ key: 'caught', label: 'Caught' },
{ key: 'fainted', label: 'Fainted' },
{ key: 'missed', label: 'Missed' },
] as const
).map(({ key, label }) => (
<button
key={key}
onClick={() => setFilter(key)}
className={`px-3 py-1 rounded-full text-sm font-medium transition-colors ${
filter === key
? 'bg-blue-600 text-white'
: 'bg-surface-2 text-text-secondary hover:bg-surface-3'
}`}
>
{label}
</button>
))}
</div>
// Find boss battles positioned after this route (or any of its children)
const bossesHere: BossBattle[] = []
for (const rid of routeIds) {
const b = bossesAfterRoute.get(rid)
if (b) bossesHere.push(...b)
}
{/* Route list */}
<div className="space-y-1">
{filteredRoutes.length === 0 && (
<p className="text-text-tertiary text-sm py-4 text-center">
{filter === 'all'
? 'Click a route to log your first encounter'
: 'No routes match this filter — try a different one'}
</p>
)}
{filteredRoutes.map((route) => {
// Collect all route IDs to check for boss cards after
const routeIds: number[] =
route.children.length > 0 ? [route.id, ...route.children.map((c) => c.id)] : [route.id]
// Find boss battles positioned after this route (or any of its children)
const bossesHere: BossBattle[] = []
for (const rid of routeIds) {
const b = bossesAfterRoute.get(rid)
if (b) bossesHere.push(...b)
}
const routeElement =
route.children.length > 0 ? (
<RouteGroup
key={route.id}
group={route}
encounterByRoute={encounterByRoute}
giftEncounterByRoute={giftEncounterByRoute}
isExpanded={expandedGroups.has(route.id)}
onToggleExpand={() => toggleGroup(route.id)}
onRouteClick={handleRouteClick}
filter={filter}
pinwheelClause={pinwheelClause}
/>
) : (
(() => {
const encounter = encounterByRoute.get(route.id)
const giftEncounter = giftEncounterByRoute.get(route.id)
const displayEncounter = encounter ?? giftEncounter
const rs = getRouteStatus(displayEncounter)
const si = statusIndicator[rs]
return (
<button
const routeElement =
route.children.length > 0 ? (
<RouteGroup
key={route.id}
type="button"
onClick={() => handleRouteClick(route)}
className={`w-full flex items-center gap-3 px-4 py-3 rounded-lg text-left transition-colors hover:bg-surface-2/50 ${si.bg}`}
>
<span className={`w-2.5 h-2.5 rounded-full shrink-0 ${si.dot}`} />
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-text-primary">{route.name}</div>
{encounter ? (
<div className="flex items-center gap-2 mt-0.5">
{encounter.pokemon.spriteUrl && (
<img
src={encounter.pokemon.spriteUrl}
alt={encounter.pokemon.name}
className="w-10 h-10"
/>
)}
<span className="text-xs text-text-tertiary capitalize">
{encounter.nickname ?? encounter.pokemon.name}
{encounter.status === 'caught' &&
encounter.faintLevel !== null &&
(encounter.deathCause ? `${encounter.deathCause}` : ' (dead)')}
</span>
{giftEncounter && (
<>
group={route}
encounterByRoute={encounterByRoute}
giftEncounterByRoute={giftEncounterByRoute}
isExpanded={expandedGroups.has(route.id)}
onToggleExpand={() => toggleGroup(route.id)}
onRouteClick={handleRouteClick}
filter={filter}
pinwheelClause={pinwheelClause}
/>
) : (
(() => {
const encounter = encounterByRoute.get(route.id)
const giftEncounter = giftEncounterByRoute.get(route.id)
const displayEncounter = encounter ?? giftEncounter
const rs = getRouteStatus(displayEncounter)
const si = statusIndicator[rs]
return (
<button
key={route.id}
type="button"
onClick={() => handleRouteClick(route)}
className={`w-full flex items-center gap-3 px-4 py-3 rounded-lg text-left transition-colors hover:bg-surface-2/50 ${si.bg}`}
>
<span className={`w-2.5 h-2.5 rounded-full shrink-0 ${si.dot}`} />
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-text-primary">{route.name}</div>
{encounter ? (
<div className="flex items-center gap-2 mt-0.5">
{encounter.pokemon.spriteUrl && (
<img
src={encounter.pokemon.spriteUrl}
alt={encounter.pokemon.name}
className="w-10 h-10"
/>
)}
<span className="text-xs text-text-tertiary capitalize">
{encounter.nickname ?? encounter.pokemon.name}
{encounter.status === 'caught' &&
encounter.faintLevel !== null &&
(encounter.deathCause ? `${encounter.deathCause}` : ' (dead)')}
</span>
{giftEncounter && (
<>
{giftEncounter.pokemon.spriteUrl && (
<img
src={giftEncounter.pokemon.spriteUrl}
alt={giftEncounter.pokemon.name}
className="w-8 h-8 opacity-60"
/>
)}
<span className="text-xs text-text-tertiary capitalize">
{giftEncounter.nickname ?? giftEncounter.pokemon.name}
<span className="text-text-muted ml-1">(gift)</span>
</span>
</>
)}
</div>
) : giftEncounter ? (
<div className="flex items-center gap-2 mt-0.5">
{giftEncounter.pokemon.spriteUrl && (
<img
src={giftEncounter.pokemon.spriteUrl}
@@ -1417,176 +1473,194 @@ export function RunEncounters() {
{giftEncounter.nickname ?? giftEncounter.pokemon.name}
<span className="text-text-muted ml-1">(gift)</span>
</span>
</>
)}
</div>
) : giftEncounter ? (
<div className="flex items-center gap-2 mt-0.5">
{giftEncounter.pokemon.spriteUrl && (
<img
src={giftEncounter.pokemon.spriteUrl}
alt={giftEncounter.pokemon.name}
className="w-8 h-8 opacity-60"
/>
)}
<span className="text-xs text-text-tertiary capitalize">
{giftEncounter.nickname ?? giftEncounter.pokemon.name}
<span className="text-text-muted ml-1">(gift)</span>
</span>
</div>
) : (
route.encounterMethods.length > 0 && (
<div className="flex flex-wrap gap-1 mt-0.5">
{route.encounterMethods.map((m) => (
<EncounterMethodBadge key={m} method={m} size="xs" />
))}
</div>
)
)}
</div>
<span className="text-xs text-text-muted shrink-0">{si.label}</span>
</button>
)
})()
)
return (
<div key={route.id}>
{routeElement}
{/* Boss battle cards after this route */}
{bossesHere.map((boss) => {
const isDefeated = defeatedBossIds.has(boss.id)
const sectionAfter = sectionDividerAfterBoss.get(boss.id)
const bossTypeLabel: Record<string, string> = {
gym_leader: 'Gym Leader',
elite_four: 'Elite Four',
champion: 'Champion',
rival: 'Rival',
evil_team: 'Evil Team',
kahuna: 'Kahuna',
totem: 'Totem',
other: 'Boss',
}
const bossTypeColors: Record<string, string> = {
gym_leader: 'border-yellow-600',
elite_four: 'border-purple-600',
champion: 'border-red-600',
rival: 'border-blue-600',
evil_team: 'border-gray-400',
kahuna: 'border-orange-600',
totem: 'border-teal-600',
other: 'border-gray-500',
}
const isBossExpanded = expandedBosses.has(boss.id)
const toggleBoss = () => {
setExpandedBosses((prev) => {
const next = new Set(prev)
if (next.has(boss.id)) next.delete(boss.id)
else next.add(boss.id)
return next
})
}
return (
<div key={`boss-${boss.id}`}>
<div
className={`my-2 rounded-lg border-2 ${bossTypeColors[boss.bossType] ?? bossTypeColors['other']} ${
isDefeated ? 'bg-green-900/10' : 'bg-surface-1'
} px-4 py-3`}
>
<div
className="flex items-start justify-between cursor-pointer select-none"
onClick={toggleBoss}
>
<div className="flex items-center gap-3">
<svg
className={`w-4 h-4 text-text-tertiary transition-transform ${isBossExpanded ? 'rotate-90' : ''}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
{boss.spriteUrl && (
<img src={boss.spriteUrl} alt={boss.name} className="h-10 w-auto" />
)}
<div>
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-text-primary">
{boss.name}
</span>
<span className="px-2 py-0.5 text-xs font-medium rounded-full bg-surface-2 text-text-secondary">
{bossTypeLabel[boss.bossType] ?? boss.bossType}
</span>
{boss.specialtyType && <TypeBadge type={boss.specialtyType} />}
</div>
<p className="text-xs text-text-tertiary">
{boss.location} &middot; Level Cap: {boss.levelCap}
</p>
</div>
) : (
route.encounterMethods.length > 0 && (
<div className="flex flex-wrap gap-1 mt-0.5">
{route.encounterMethods.map((m) => (
<EncounterMethodBadge key={m} method={m} size="xs" />
))}
</div>
)
)}
</div>
<div onClick={(e) => e.stopPropagation()}>
{isDefeated ? (
<span className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium rounded-full bg-green-900/40 text-green-300 light:bg-green-100 light:text-green-800">
Defeated &#10003;
</span>
) : isActive ? (
<button
onClick={() => setSelectedBoss(boss)}
className="px-3 py-1 text-xs font-medium rounded-full bg-accent-600 text-white hover:bg-accent-500 transition-colors"
>
Battle
</button>
) : null}
</div>
</div>
{/* Boss pokemon team */}
{isBossExpanded && boss.pokemon.length > 0 && (
<BossTeamPreview pokemon={boss.pokemon} starterName={starterName} />
)}
</div>
{sectionAfter && (
<div className="flex items-center gap-3 my-4">
<div className="flex-1 h-px bg-surface-3" />
<span className="text-sm font-semibold text-text-tertiary uppercase tracking-wider">
{sectionAfter}
</span>
<div className="flex-1 h-px bg-surface-3" />
</div>
)}
</div>
<span className="text-xs text-text-muted shrink-0">{si.label}</span>
</button>
)
})()
)
})}
</div>
)
})}
</div>
{/* Encounter Modal */}
{selectedRoute && (
<EncounterModal
route={selectedRoute}
gameId={run!.gameId}
runId={runIdNum}
namingScheme={run!.namingScheme}
isGenlocke={!!run!.genlocke}
existing={editingEncounter ?? undefined}
dupedPokemonIds={dupedPokemonIds}
retiredPokemonIds={retiredPokemonIds}
onSubmit={handleCreate}
onUpdate={handleUpdate}
onClose={() => {
setSelectedRoute(null)
setEditingEncounter(null)
}}
isPending={createEncounter.isPending || updateEncounter.isPending}
useAllPokemon={useAllPokemon}
staticClause={run?.rules?.staticClause ?? true}
allowedTypes={rulesAllowedTypes.length ? rulesAllowedTypes : undefined}
/>
)}
return (
<div key={route.id}>
{routeElement}
{/* Boss battle cards after this route */}
{bossesHere.map((boss) => {
const isDefeated = defeatedBossIds.has(boss.id)
const sectionAfter = sectionDividerAfterBoss.get(boss.id)
const bossTypeLabel: Record<string, string> = {
gym_leader: 'Gym Leader',
elite_four: 'Elite Four',
champion: 'Champion',
rival: 'Rival',
evil_team: 'Evil Team',
kahuna: 'Kahuna',
totem: 'Totem',
other: 'Boss',
}
const bossTypeColors: Record<string, string> = {
gym_leader: 'border-yellow-600',
elite_four: 'border-purple-600',
champion: 'border-red-600',
rival: 'border-blue-600',
evil_team: 'border-gray-400',
kahuna: 'border-orange-600',
totem: 'border-teal-600',
other: 'border-gray-500',
}
const isBossExpanded = expandedBosses.has(boss.id)
const toggleBoss = () => {
setExpandedBosses((prev) => {
const next = new Set(prev)
if (next.has(boss.id)) next.delete(boss.id)
else next.add(boss.id)
return next
})
}
return (
<div key={`boss-${boss.id}`}>
<div
className={`my-2 rounded-lg border-2 ${bossTypeColors[boss.bossType] ?? bossTypeColors['other']} ${
isDefeated ? 'bg-green-900/10' : 'bg-surface-1'
} px-4 py-3`}
>
<div
className="flex items-start justify-between cursor-pointer select-none"
onClick={toggleBoss}
>
<div className="flex items-center gap-3">
<svg
className={`w-4 h-4 text-text-tertiary transition-transform ${isBossExpanded ? 'rotate-90' : ''}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9 5l7 7-7 7"
/>
</svg>
{boss.spriteUrl && (
<img src={boss.spriteUrl} alt={boss.name} className="h-10 w-auto" />
)}
<div>
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-text-primary">
{boss.name}
</span>
<span className="px-2 py-0.5 text-xs font-medium rounded-full bg-surface-2 text-text-secondary">
{bossTypeLabel[boss.bossType] ?? boss.bossType}
</span>
{boss.specialtyType && <TypeBadge type={boss.specialtyType} />}
</div>
<p className="text-xs text-text-tertiary">
{boss.location} &middot; Level Cap: {boss.levelCap}
</p>
</div>
</div>
<div onClick={(e) => e.stopPropagation()}>
{isDefeated ? (
<span className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium rounded-full bg-green-900/40 text-green-300 light:bg-green-100 light:text-green-800">
Defeated &#10003;
</span>
) : isActive ? (
<button
onClick={() => setSelectedBoss(boss)}
className="px-3 py-1 text-xs font-medium rounded-full bg-accent-600 text-white hover:bg-accent-500 transition-colors"
>
Battle
</button>
) : null}
</div>
</div>
{/* Boss pokemon team */}
{isBossExpanded && boss.pokemon.length > 0 && (
<BossTeamPreview pokemon={boss.pokemon} starterName={starterName} />
)}
{/* Player team snapshot */}
{isDefeated && (() => {
const result = bossResultByBattleId.get(boss.id)
if (!result || result.team.length === 0) return null
return (
<div className="mt-3 pt-3 border-t border-border-default">
<p className="text-xs font-medium text-text-secondary mb-2">Your Team</p>
<div className="flex gap-2 flex-wrap">
{result.team.map((tm) => {
const enc = encounterById.get(tm.encounterId)
if (!enc) return null
const dp = enc.currentPokemon ?? enc.pokemon
return (
<div key={tm.id} className="flex flex-col items-center">
{dp.spriteUrl ? (
<img src={dp.spriteUrl} alt={dp.name} className="w-10 h-10" />
) : (
<div className="w-10 h-10 bg-surface-3 rounded-full" />
)}
<span className="text-[10px] text-text-tertiary capitalize">
{enc.nickname ?? dp.name}
</span>
<span className="text-[10px] text-text-muted">Lv.{tm.level}</span>
</div>
)
})}
</div>
</div>
)
})()}
</div>
{sectionAfter && (
<div className="flex items-center gap-3 my-4">
<div className="flex-1 h-px bg-surface-3" />
<span className="text-sm font-semibold text-text-tertiary uppercase tracking-wider">
{sectionAfter}
</span>
<div className="flex-1 h-px bg-surface-3" />
</div>
)}
</div>
)
})}
</div>
)
})}
</div>
{/* Encounter Modal */}
{selectedRoute && (
<EncounterModal
route={selectedRoute}
gameId={run!.gameId}
runId={runIdNum}
namingScheme={run!.namingScheme}
isGenlocke={!!run!.genlocke}
existing={editingEncounter ?? undefined}
dupedPokemonIds={dupedPokemonIds}
retiredPokemonIds={retiredPokemonIds}
onSubmit={handleCreate}
onUpdate={handleUpdate}
onClose={() => {
setSelectedRoute(null)
setEditingEncounter(null)
}}
isPending={createEncounter.isPending || updateEncounter.isPending}
useAllPokemon={useAllPokemon}
staticClause={run?.rules?.staticClause ?? true}
allowedTypes={rulesAllowedTypes.length ? rulesAllowedTypes : undefined}
/>
)}
</>
)}
@@ -1633,6 +1707,7 @@ export function RunEncounters() {
{selectedBoss && (
<BossDefeatModal
boss={selectedBoss}
aliveEncounters={alive}
onSubmit={(data) => {
createBossResult.mutate(data, {
onSuccess: () => setSelectedBoss(null),