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:
@@ -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} · 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 ✓
|
||||
</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} · 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 ✓
|
||||
</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),
|
||||
|
||||
Reference in New Issue
Block a user