feat: make level field optional in boss defeat modal
Remove the level input from the boss defeat modal since the app doesn't track levels elsewhere. Team selection is now just checkboxes without requiring level entry. - Remove level input UI from BossDefeatModal.tsx - Add alembic migration to make boss_result_team.level nullable - Update model and schemas to make level optional (defaults to null) - Conditionally render level in boss result display Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,11 +1,11 @@
|
|||||||
---
|
---
|
||||||
# nuzlocke-tracker-532i
|
# nuzlocke-tracker-532i
|
||||||
title: 'UX: Make level field optional in boss defeat modal'
|
title: 'UX: Make level field optional in boss defeat modal'
|
||||||
status: todo
|
status: completed
|
||||||
type: feature
|
type: feature
|
||||||
priority: normal
|
priority: normal
|
||||||
created_at: 2026-03-21T21:50:48Z
|
created_at: 2026-03-21T21:50:48Z
|
||||||
updated_at: 2026-03-21T22:04:08Z
|
updated_at: 2026-03-22T09:16:12Z
|
||||||
---
|
---
|
||||||
|
|
||||||
## Problem
|
## Problem
|
||||||
@@ -22,8 +22,17 @@ When recording which team members beat a boss, users must manually enter a level
|
|||||||
|
|
||||||
Remove the level field entirely from the UI and make it optional in the backend:
|
Remove the level field entirely from the UI and make it optional in the backend:
|
||||||
|
|
||||||
- [ ] Remove level input from `BossDefeatModal.tsx`
|
- [x] Remove level input from `BossDefeatModal.tsx`
|
||||||
- [ ] Make `level` column nullable in the database (alembic migration)
|
- [x] Make `level` column nullable in the database (alembic migration)
|
||||||
- [ ] Update the API schema to make level optional (default to null)
|
- [x] Update the API schema to make level optional (default to null)
|
||||||
- [ ] Update any backend validation that requires level
|
- [x] Update any backend validation that requires level
|
||||||
- [ ] Verify boss result display still works without level data
|
- [x] Verify boss result display still works without level data
|
||||||
|
|
||||||
|
|
||||||
|
## Summary of Changes
|
||||||
|
|
||||||
|
- Removed level input field from BossDefeatModal.tsx, simplifying team selection to just checkboxes
|
||||||
|
- Created alembic migration to make boss_result_team.level column nullable
|
||||||
|
- Updated SQLAlchemy model and Pydantic schemas to make level optional (defaults to null)
|
||||||
|
- Updated RunEncounters.tsx to conditionally render level only when present
|
||||||
|
- Updated frontend TypeScript types for BossResultTeamMember and BossResultTeamMemberInput
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"""make_boss_result_team_level_nullable
|
||||||
|
|
||||||
|
Revision ID: 903e0cdbfe5a
|
||||||
|
Revises: p7e8f9a0b1c2
|
||||||
|
Create Date: 2026-03-22 10:13:41.828406
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "903e0cdbfe5a"
|
||||||
|
down_revision: str | Sequence[str] | None = "p7e8f9a0b1c2"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.alter_column(
|
||||||
|
"boss_result_team",
|
||||||
|
"level",
|
||||||
|
existing_type=sa.SmallInteger(),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.execute("UPDATE boss_result_team SET level = 1 WHERE level IS NULL")
|
||||||
|
op.alter_column(
|
||||||
|
"boss_result_team",
|
||||||
|
"level",
|
||||||
|
existing_type=sa.SmallInteger(),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
@@ -14,7 +14,7 @@ class BossResultTeam(Base):
|
|||||||
encounter_id: Mapped[int] = mapped_column(
|
encounter_id: Mapped[int] = mapped_column(
|
||||||
ForeignKey("encounters.id", ondelete="CASCADE"), index=True
|
ForeignKey("encounters.id", ondelete="CASCADE"), index=True
|
||||||
)
|
)
|
||||||
level: Mapped[int] = mapped_column(SmallInteger)
|
level: Mapped[int | None] = mapped_column(SmallInteger, nullable=True)
|
||||||
|
|
||||||
boss_result: Mapped[BossResult] = relationship(back_populates="team")
|
boss_result: Mapped[BossResult] = relationship(back_populates="team")
|
||||||
encounter: Mapped[Encounter] = relationship()
|
encounter: Mapped[Encounter] = relationship()
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ class BossBattleResponse(CamelModel):
|
|||||||
class BossResultTeamMemberResponse(CamelModel):
|
class BossResultTeamMemberResponse(CamelModel):
|
||||||
id: int
|
id: int
|
||||||
encounter_id: int
|
encounter_id: int
|
||||||
level: int
|
level: int | None
|
||||||
|
|
||||||
|
|
||||||
class BossResultResponse(CamelModel):
|
class BossResultResponse(CamelModel):
|
||||||
@@ -120,7 +120,7 @@ class BossPokemonInput(CamelModel):
|
|||||||
|
|
||||||
class BossResultTeamMemberInput(CamelModel):
|
class BossResultTeamMemberInput(CamelModel):
|
||||||
encounter_id: int
|
encounter_id: int
|
||||||
level: int
|
level: int | None = None
|
||||||
|
|
||||||
|
|
||||||
class BossResultCreate(CamelModel):
|
class BossResultCreate(CamelModel):
|
||||||
|
|||||||
@@ -23,10 +23,7 @@ function matchVariant(labels: string[], starterName?: string | null): string | n
|
|||||||
return matches.length === 1 ? (matches[0] ?? null) : null
|
return matches.length === 1 ? (matches[0] ?? null) : null
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TeamSelection {
|
type TeamSelection = number
|
||||||
encounterId: number
|
|
||||||
level: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export function BossDefeatModal({
|
export function BossDefeatModal({
|
||||||
boss,
|
boss,
|
||||||
@@ -36,26 +33,15 @@ export function BossDefeatModal({
|
|||||||
isPending,
|
isPending,
|
||||||
starterName,
|
starterName,
|
||||||
}: BossDefeatModalProps) {
|
}: BossDefeatModalProps) {
|
||||||
const [selectedTeam, setSelectedTeam] = useState<Map<number, TeamSelection>>(new Map())
|
const [selectedTeam, setSelectedTeam] = useState<Set<TeamSelection>>(new Set())
|
||||||
|
|
||||||
const toggleTeamMember = (enc: EncounterDetail) => {
|
const toggleTeamMember = (encounterId: number) => {
|
||||||
setSelectedTeam((prev) => {
|
setSelectedTeam((prev) => {
|
||||||
const next = new Map(prev)
|
const next = new Set(prev)
|
||||||
if (next.has(enc.id)) {
|
if (next.has(encounterId)) {
|
||||||
next.delete(enc.id)
|
next.delete(encounterId)
|
||||||
} else {
|
} else {
|
||||||
next.set(enc.id, { encounterId: enc.id, level: enc.catchLevel ?? 1 })
|
next.add(encounterId)
|
||||||
}
|
|
||||||
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
|
return next
|
||||||
})
|
})
|
||||||
@@ -87,7 +73,9 @@ export function BossDefeatModal({
|
|||||||
|
|
||||||
const handleSubmit = (e: FormEvent) => {
|
const handleSubmit = (e: FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const team: BossResultTeamMemberInput[] = Array.from(selectedTeam.values())
|
const team: BossResultTeamMemberInput[] = Array.from(selectedTeam).map((encounterId) => ({
|
||||||
|
encounterId,
|
||||||
|
}))
|
||||||
onSubmit({
|
onSubmit({
|
||||||
bossBattleId: boss.id,
|
bossBattleId: boss.id,
|
||||||
result: 'won',
|
result: 'won',
|
||||||
@@ -134,11 +122,17 @@ export function BossDefeatModal({
|
|||||||
return (
|
return (
|
||||||
<div key={bp.id} className="flex flex-col items-center">
|
<div key={bp.id} className="flex flex-col items-center">
|
||||||
{bp.pokemon.spriteUrl ? (
|
{bp.pokemon.spriteUrl ? (
|
||||||
<img src={bp.pokemon.spriteUrl} alt={bp.pokemon.name} className="w-10 h-10" />
|
<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" />
|
<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 text-text-tertiary capitalize">
|
||||||
|
{bp.pokemon.name}
|
||||||
|
</span>
|
||||||
<span className="text-xs font-medium text-text-secondary">Lv.{bp.level}</span>
|
<span className="text-xs font-medium text-text-secondary">Lv.{bp.level}</span>
|
||||||
<ConditionBadge condition={bp.conditionLabel} size="xs" />
|
<ConditionBadge condition={bp.conditionLabel} size="xs" />
|
||||||
{bp.ability && (
|
{bp.ability && (
|
||||||
@@ -166,7 +160,6 @@ export function BossDefeatModal({
|
|||||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2 max-h-48 overflow-y-auto">
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2 max-h-48 overflow-y-auto">
|
||||||
{aliveEncounters.map((enc) => {
|
{aliveEncounters.map((enc) => {
|
||||||
const isSelected = selectedTeam.has(enc.id)
|
const isSelected = selectedTeam.has(enc.id)
|
||||||
const selection = selectedTeam.get(enc.id)
|
|
||||||
const displayPokemon = enc.currentPokemon ?? enc.pokemon
|
const displayPokemon = enc.currentPokemon ?? enc.pokemon
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -176,12 +169,12 @@ export function BossDefeatModal({
|
|||||||
? 'border-accent-500 bg-accent-500/10'
|
? 'border-accent-500 bg-accent-500/10'
|
||||||
: 'border-border-default hover:bg-surface-2'
|
: 'border-border-default hover:bg-surface-2'
|
||||||
}`}
|
}`}
|
||||||
onClick={() => toggleTeamMember(enc)}
|
onClick={() => toggleTeamMember(enc.id)}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={isSelected}
|
checked={isSelected}
|
||||||
onChange={() => toggleTeamMember(enc)}
|
onChange={() => toggleTeamMember(enc.id)}
|
||||||
className="sr-only"
|
className="sr-only"
|
||||||
/>
|
/>
|
||||||
{displayPokemon.spriteUrl ? (
|
{displayPokemon.spriteUrl ? (
|
||||||
@@ -193,26 +186,9 @@ export function BossDefeatModal({
|
|||||||
) : (
|
) : (
|
||||||
<div className="w-8 h-8 bg-surface-3 rounded-full" />
|
<div className="w-8 h-8 bg-surface-3 rounded-full" />
|
||||||
)}
|
)}
|
||||||
<div className="flex-1 min-w-0">
|
<p className="flex-1 min-w-0 text-xs font-medium truncate">
|
||||||
<p className="text-xs font-medium truncate">
|
{enc.nickname ?? displayPokemon.name}
|
||||||
{enc.nickname ?? displayPokemon.name}
|
</p>
|
||||||
</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>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1246,253 +1246,279 @@ export function RunEncounters() {
|
|||||||
{/* Encounters Tab */}
|
{/* Encounters Tab */}
|
||||||
{activeTab === 'encounters' && (
|
{activeTab === 'encounters' && (
|
||||||
<>
|
<>
|
||||||
<div className="lg:flex lg:gap-6">
|
<div className="lg:flex lg:gap-6">
|
||||||
{/* Main content column */}
|
{/* Main content column */}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
{/* Team Section - Mobile/Tablet only */}
|
{/* Team Section - Mobile/Tablet only */}
|
||||||
{(alive.length > 0 || dead.length > 0) && (
|
{(alive.length > 0 || dead.length > 0) && (
|
||||||
<div className="mb-6 lg:hidden">
|
<div className="mb-6 lg:hidden">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowTeam(!showTeam)}
|
onClick={() => setShowTeam(!showTeam)}
|
||||||
className="flex items-center gap-2 group"
|
className="flex items-center gap-2 group"
|
||||||
>
|
>
|
||||||
<h2 className="text-lg font-semibold text-text-primary">
|
<h2 className="text-lg font-semibold text-text-primary">
|
||||||
{isActive ? 'Team' : 'Final Team'}
|
{isActive ? 'Team' : 'Final Team'}
|
||||||
</h2>
|
</h2>
|
||||||
<span className="text-xs text-text-muted">
|
<span className="text-xs text-text-muted">
|
||||||
{alive.length} alive
|
{alive.length} alive
|
||||||
{dead.length > 0 ? `, ${dead.length} dead` : ''}
|
{dead.length > 0 ? `, ${dead.length} dead` : ''}
|
||||||
</span>
|
</span>
|
||||||
<svg
|
<svg
|
||||||
className={`w-4 h-4 text-text-tertiary transition-transform ${showTeam ? 'rotate-180' : ''}`}
|
className={`w-4 h-4 text-text-tertiary transition-transform ${showTeam ? 'rotate-180' : ''}`}
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
d="M19 9l-7 7-7-7"
|
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 && canEdit ? () => setSelectedTeamEncounter(enc) : undefined
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
))}
|
</svg>
|
||||||
</div>
|
</button>
|
||||||
)}
|
{showTeam && alive.length > 1 && (
|
||||||
{dead.length > 0 && (
|
<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>
|
{alive.length > 0 && (
|
||||||
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 gap-2">
|
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 gap-2 mb-3">
|
||||||
{dead.map((enc) => (
|
{alive.map((enc) => (
|
||||||
<PokemonCard
|
<PokemonCard
|
||||||
key={enc.id}
|
key={enc.id}
|
||||||
encounter={enc}
|
encounter={enc}
|
||||||
showFaintLevel
|
onClick={
|
||||||
onClick={
|
isActive && canEdit
|
||||||
isActive && canEdit ? () => setSelectedTeamEncounter(enc) : undefined
|
? () => setSelectedTeamEncounter(enc)
|
||||||
}
|
: undefined
|
||||||
/>
|
}
|
||||||
))}
|
/>
|
||||||
</div>
|
))}
|
||||||
|
</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 && canEdit
|
||||||
|
? () => setSelectedTeamEncounter(enc)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Shiny Box */}
|
{/* Shiny Box */}
|
||||||
{run.rules?.shinyClause && shinyEncounters.length > 0 && (
|
{run.rules?.shinyClause && shinyEncounters.length > 0 && (
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<ShinyBox
|
<ShinyBox
|
||||||
encounters={shinyEncounters}
|
encounters={shinyEncounters}
|
||||||
onEncounterClick={
|
onEncounterClick={
|
||||||
isActive && canEdit ? (enc) => setSelectedTeamEncounter(enc) : undefined
|
isActive && canEdit ? (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 && canEdit ? () => 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 && canEdit ? () => 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 && canEdit && 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>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Progress bar */}
|
{/* Route list */}
|
||||||
<div className="mb-4">
|
<div className="space-y-1">
|
||||||
<div className="flex items-center justify-between mb-1">
|
{filteredRoutes.length === 0 && (
|
||||||
<div className="flex items-center gap-3">
|
<p className="text-text-tertiary text-sm py-4 text-center">
|
||||||
<h2 className="text-lg font-semibold text-text-primary">Encounters</h2>
|
{filter === 'all'
|
||||||
{isActive && canEdit && completedCount < totalLocations && (
|
? 'Click a route to log your first encounter'
|
||||||
<button
|
: 'No routes match this filter — try a different one'}
|
||||||
type="button"
|
</p>
|
||||||
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>
|
{filteredRoutes.map((route) => {
|
||||||
<span className="text-sm text-text-tertiary">
|
// Collect all route IDs to check for boss cards after
|
||||||
{completedCount} / {totalLocations} locations
|
const routeIds: number[] =
|
||||||
</span>
|
route.children.length > 0
|
||||||
</div>
|
? [route.id, ...route.children.map((c) => c.id)]
|
||||||
<div className="h-2 bg-surface-3 rounded-full overflow-hidden">
|
: [route.id]
|
||||||
<div
|
|
||||||
className="h-full bg-blue-500 rounded-full transition-all"
|
|
||||||
style={{
|
|
||||||
width: `${totalLocations > 0 ? (completedCount / totalLocations) * 100 : 0}%`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Filter tabs */}
|
// Find boss battles positioned after this route (or any of its children)
|
||||||
<div className="flex gap-2 mb-4 flex-wrap">
|
const bossesHere: BossBattle[] = []
|
||||||
{(
|
for (const rid of routeIds) {
|
||||||
[
|
const b = bossesAfterRoute.get(rid)
|
||||||
{ key: 'all', label: 'All' },
|
if (b) bossesHere.push(...b)
|
||||||
{ 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>
|
|
||||||
|
|
||||||
{/* Route list */}
|
const routeElement =
|
||||||
<div className="space-y-1">
|
route.children.length > 0 ? (
|
||||||
{filteredRoutes.length === 0 && (
|
<RouteGroup
|
||||||
<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={canEdit ? handleRouteClick : undefined}
|
|
||||||
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}
|
key={route.id}
|
||||||
type="button"
|
group={route}
|
||||||
onClick={canEdit ? () => handleRouteClick(route) : undefined}
|
encounterByRoute={encounterByRoute}
|
||||||
disabled={!canEdit}
|
giftEncounterByRoute={giftEncounterByRoute}
|
||||||
className={`w-full flex items-center gap-3 px-4 py-3 rounded-lg text-left transition-colors ${!canEdit ? 'cursor-default' : 'hover:bg-surface-2/50'} ${si.bg}`}
|
isExpanded={expandedGroups.has(route.id)}
|
||||||
>
|
onToggleExpand={() => toggleGroup(route.id)}
|
||||||
<span className={`w-2.5 h-2.5 rounded-full shrink-0 ${si.dot}`} />
|
onRouteClick={canEdit ? handleRouteClick : undefined}
|
||||||
<div className="flex-1 min-w-0">
|
filter={filter}
|
||||||
<div className="text-sm font-medium text-text-primary">{route.name}</div>
|
pinwheelClause={pinwheelClause}
|
||||||
{encounter ? (
|
/>
|
||||||
<div className="flex items-center gap-2 mt-0.5">
|
) : (
|
||||||
{encounter.pokemon.spriteUrl && (
|
(() => {
|
||||||
<img
|
const encounter = encounterByRoute.get(route.id)
|
||||||
src={encounter.pokemon.spriteUrl}
|
const giftEncounter = giftEncounterByRoute.get(route.id)
|
||||||
alt={encounter.pokemon.name}
|
const displayEncounter = encounter ?? giftEncounter
|
||||||
className="w-10 h-10"
|
const rs = getRouteStatus(displayEncounter)
|
||||||
/>
|
const si = statusIndicator[rs]
|
||||||
)}
|
|
||||||
<span className="text-xs text-text-tertiary capitalize">
|
return (
|
||||||
{encounter.nickname ?? encounter.pokemon.name}
|
<button
|
||||||
{encounter.status === 'caught' &&
|
key={route.id}
|
||||||
encounter.faintLevel !== null &&
|
type="button"
|
||||||
(encounter.deathCause ? ` — ${encounter.deathCause}` : ' (dead)')}
|
onClick={canEdit ? () => handleRouteClick(route) : undefined}
|
||||||
</span>
|
disabled={!canEdit}
|
||||||
{giftEncounter && (
|
className={`w-full flex items-center gap-3 px-4 py-3 rounded-lg text-left transition-colors ${!canEdit ? 'cursor-default' : '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 && (
|
{giftEncounter.pokemon.spriteUrl && (
|
||||||
<img
|
<img
|
||||||
src={giftEncounter.pokemon.spriteUrl}
|
src={giftEncounter.pokemon.spriteUrl}
|
||||||
@@ -1504,257 +1530,251 @@ export function RunEncounters() {
|
|||||||
{giftEncounter.nickname ?? giftEncounter.pokemon.name}
|
{giftEncounter.nickname ?? giftEncounter.pokemon.name}
|
||||||
<span className="text-text-muted ml-1">(gift)</span>
|
<span className="text-text-muted ml-1">(gift)</span>
|
||||||
</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>
|
</div>
|
||||||
<p className="text-xs text-text-tertiary">
|
) : (
|
||||||
{boss.location} · Level Cap: {boss.levelCap}
|
route.encounterMethods.length > 0 && (
|
||||||
</p>
|
<div className="flex flex-wrap gap-1 mt-0.5">
|
||||||
</div>
|
{route.encounterMethods.map((m) => (
|
||||||
|
<EncounterMethodBadge key={m} method={m} size="xs" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div onClick={(e) => e.stopPropagation()}>
|
<span className="text-xs text-text-muted shrink-0">{si.label}</span>
|
||||||
{isDefeated ? (
|
</button>
|
||||||
<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 && canEdit ? (
|
|
||||||
<button
|
return (
|
||||||
onClick={() => setSelectedBoss(boss)}
|
<div key={route.id}>
|
||||||
className="px-3 py-1 text-xs font-medium rounded-full bg-accent-600 text-white hover:bg-accent-500 transition-colors"
|
{routeElement}
|
||||||
>
|
{/* Boss battle cards after this route */}
|
||||||
Battle
|
{bossesHere.map((boss) => {
|
||||||
</button>
|
const isDefeated = defeatedBossIds.has(boss.id)
|
||||||
) : null}
|
const sectionAfter = sectionDividerAfterBoss.get(boss.id)
|
||||||
</div>
|
const bossTypeLabel: Record<string, string> = {
|
||||||
</div>
|
gym_leader: 'Gym Leader',
|
||||||
{/* Boss pokemon team */}
|
elite_four: 'Elite Four',
|
||||||
{isBossExpanded && boss.pokemon.length > 0 && (
|
champion: 'Champion',
|
||||||
<BossTeamPreview pokemon={boss.pokemon} starterName={starterName} />
|
rival: 'Rival',
|
||||||
)}
|
evil_team: 'Evil Team',
|
||||||
{/* Player team snapshot */}
|
kahuna: 'Kahuna',
|
||||||
{isDefeated &&
|
totem: 'Totem',
|
||||||
(() => {
|
other: 'Boss',
|
||||||
const result = bossResultByBattleId.get(boss.id)
|
}
|
||||||
if (!result || result.team.length === 0) return null
|
const bossTypeColors: Record<string, string> = {
|
||||||
return (
|
gym_leader: 'border-yellow-600',
|
||||||
<div className="mt-3 pt-3 border-t border-border-default">
|
elite_four: 'border-purple-600',
|
||||||
<p className="text-xs font-medium text-text-secondary mb-2">
|
champion: 'border-red-600',
|
||||||
Your Team
|
rival: 'border-blue-600',
|
||||||
</p>
|
evil_team: 'border-gray-400',
|
||||||
<div className="flex gap-2 flex-wrap">
|
kahuna: 'border-orange-600',
|
||||||
{result.team.map((tm: BossResultTeamMember) => {
|
totem: 'border-teal-600',
|
||||||
const enc = encounterById.get(tm.encounterId)
|
other: 'border-gray-500',
|
||||||
if (!enc) return null
|
}
|
||||||
const dp = enc.currentPokemon ?? enc.pokemon
|
|
||||||
return (
|
const isBossExpanded = expandedBosses.has(boss.id)
|
||||||
<div key={tm.id} className="flex flex-col items-center">
|
const toggleBoss = () => {
|
||||||
{dp.spriteUrl ? (
|
setExpandedBosses((prev) => {
|
||||||
<img
|
const next = new Set(prev)
|
||||||
src={dp.spriteUrl}
|
if (next.has(boss.id)) next.delete(boss.id)
|
||||||
alt={dp.name}
|
else next.add(boss.id)
|
||||||
className="w-10 h-10"
|
return next
|
||||||
/>
|
})
|
||||||
) : (
|
}
|
||||||
<div className="w-10 h-10 bg-surface-3 rounded-full" />
|
|
||||||
)}
|
return (
|
||||||
<span className="text-[10px] text-text-tertiary capitalize">
|
<div key={`boss-${boss.id}`}>
|
||||||
{enc.nickname ?? dp.name}
|
<div
|
||||||
</span>
|
className={`my-2 rounded-lg border-2 ${bossTypeColors[boss.bossType] ?? bossTypeColors['other']} ${
|
||||||
<span className="text-[10px] text-text-muted">
|
isDefeated ? 'bg-green-900/10' : 'bg-surface-1'
|
||||||
Lv.{tm.level}
|
} px-4 py-3`}
|
||||||
</span>
|
>
|
||||||
</div>
|
<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>
|
</div>
|
||||||
)
|
<div onClick={(e) => e.stopPropagation()}>
|
||||||
})()}
|
{isDefeated ? (
|
||||||
</div>
|
<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">
|
||||||
{sectionAfter && (
|
Defeated ✓
|
||||||
<div className="flex items-center gap-3 my-4">
|
</span>
|
||||||
<div className="flex-1 h-px bg-surface-3" />
|
) : isActive && canEdit ? (
|
||||||
<span className="text-sm font-semibold text-text-tertiary uppercase tracking-wider">
|
<button
|
||||||
{sectionAfter}
|
onClick={() => setSelectedBoss(boss)}
|
||||||
</span>
|
className="px-3 py-1 text-xs font-medium rounded-full bg-accent-600 text-white hover:bg-accent-500 transition-colors"
|
||||||
<div className="flex-1 h-px bg-surface-3" />
|
>
|
||||||
|
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: BossResultTeamMember) => {
|
||||||
|
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>
|
||||||
|
{tm.level != null && (
|
||||||
|
<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>
|
})}
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Team Sidebar - Desktop only */}
|
|
||||||
{(alive.length > 0 || dead.length > 0) && (
|
|
||||||
<div className="hidden lg:block w-64 shrink-0">
|
|
||||||
<div className="sticky top-20 max-h-[calc(100vh-6rem)] overflow-y-auto">
|
|
||||||
<div className="bg-surface-1 border border-border-default rounded-lg p-4">
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
|
||||||
<h2 className="text-lg font-semibold text-text-primary">
|
|
||||||
{isActive ? 'Team' : 'Final Team'}
|
|
||||||
</h2>
|
|
||||||
<span className="text-xs text-text-muted">
|
|
||||||
{alive.length}/{alive.length + dead.length}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{alive.length > 1 && (
|
|
||||||
<select
|
|
||||||
value={teamSort}
|
|
||||||
onChange={(e) => setTeamSort(e.target.value as TeamSortKey)}
|
|
||||||
className="w-full text-sm border border-border-default rounded-lg px-3 py-1.5 bg-surface-0 text-text-primary mb-3"
|
|
||||||
>
|
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
{alive.length > 0 && (
|
|
||||||
<div className="grid grid-cols-2 gap-2 mb-3">
|
|
||||||
{alive.map((enc) => (
|
|
||||||
<PokemonCard
|
|
||||||
key={enc.id}
|
|
||||||
encounter={enc}
|
|
||||||
onClick={
|
|
||||||
isActive && canEdit ? () => setSelectedTeamEncounter(enc) : undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)
|
||||||
{dead.length > 0 && (
|
})}
|
||||||
<>
|
</div>
|
||||||
<h3 className="text-sm font-medium text-text-tertiary mb-2">Graveyard</h3>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
|
||||||
{dead.map((enc) => (
|
{/* Team Sidebar - Desktop only */}
|
||||||
|
{(alive.length > 0 || dead.length > 0) && (
|
||||||
|
<div className="hidden lg:block w-64 shrink-0">
|
||||||
|
<div className="sticky top-20 max-h-[calc(100vh-6rem)] overflow-y-auto">
|
||||||
|
<div className="bg-surface-1 border border-border-default rounded-lg p-4">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h2 className="text-lg font-semibold text-text-primary">
|
||||||
|
{isActive ? 'Team' : 'Final Team'}
|
||||||
|
</h2>
|
||||||
|
<span className="text-xs text-text-muted">
|
||||||
|
{alive.length}/{alive.length + dead.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{alive.length > 1 && (
|
||||||
|
<select
|
||||||
|
value={teamSort}
|
||||||
|
onChange={(e) => setTeamSort(e.target.value as TeamSortKey)}
|
||||||
|
className="w-full text-sm border border-border-default rounded-lg px-3 py-1.5 bg-surface-0 text-text-primary mb-3"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
{alive.length > 0 && (
|
||||||
|
<div className="grid grid-cols-2 gap-2 mb-3">
|
||||||
|
{alive.map((enc) => (
|
||||||
<PokemonCard
|
<PokemonCard
|
||||||
key={enc.id}
|
key={enc.id}
|
||||||
encounter={enc}
|
encounter={enc}
|
||||||
showFaintLevel
|
|
||||||
onClick={
|
onClick={
|
||||||
isActive && canEdit ? () => setSelectedTeamEncounter(enc) : undefined
|
isActive && canEdit ? () => setSelectedTeamEncounter(enc) : undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</>
|
)}
|
||||||
)}
|
{dead.length > 0 && (
|
||||||
|
<>
|
||||||
|
<h3 className="text-sm font-medium text-text-tertiary mb-2">Graveyard</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{dead.map((enc) => (
|
||||||
|
<PokemonCard
|
||||||
|
key={enc.id}
|
||||||
|
encounter={enc}
|
||||||
|
showFaintLevel
|
||||||
|
onClick={
|
||||||
|
isActive && canEdit
|
||||||
|
? () => setSelectedTeamEncounter(enc)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Encounter Modal */}
|
{/* Encounter Modal */}
|
||||||
{selectedRoute && (
|
{selectedRoute && (
|
||||||
|
|||||||
@@ -238,7 +238,7 @@ export interface BossBattle {
|
|||||||
export interface BossResultTeamMember {
|
export interface BossResultTeamMember {
|
||||||
id: number
|
id: number
|
||||||
encounterId: number
|
encounterId: number
|
||||||
level: number
|
level: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BossResult {
|
export interface BossResult {
|
||||||
@@ -253,7 +253,7 @@ export interface BossResult {
|
|||||||
|
|
||||||
export interface BossResultTeamMemberInput {
|
export interface BossResultTeamMemberInput {
|
||||||
encounterId: number
|
encounterId: number
|
||||||
level: number
|
level?: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateBossResultInput {
|
export interface CreateBossResultInput {
|
||||||
|
|||||||
Reference in New Issue
Block a user