11 Commits

Author SHA1 Message Date
Renovate Bot
c7df68ac7c chore(deps): update dependency react-router-dom to v7.14.0
All checks were successful
CI / backend-tests (pull_request) Successful in 31s
CI / frontend-tests (pull_request) Successful in 28s
2026-04-02 21:03:00 +00:00
d8fec0e5d7 fix:add debugging endpoint for auth issues
All checks were successful
CI / backend-tests (push) Successful in 30s
CI / frontend-tests (push) Successful in 28s
2026-03-22 12:15:25 +01:00
c9b09b8250 fix: fix supabase auth url
All checks were successful
CI / backend-tests (push) Successful in 30s
CI / frontend-tests (push) Successful in 30s
2026-03-22 12:10:03 +01:00
fde1867863 fix: add logging to debug auth issues
All checks were successful
CI / backend-tests (push) Successful in 29s
CI / frontend-tests (push) Successful in 28s
2026-03-22 12:01:28 +01:00
ce9d08963f Merge pull request 'Fix intermittent 401 errors and add ES256 JWT support' (#86) from feature/fix-intermittent-401-errors into develop
All checks were successful
CI / backend-tests (push) Successful in 30s
CI / frontend-tests (push) Successful in 29s
Reviewed-on: #86
2026-03-22 11:53:48 +01:00
c5959cfd14 chore: mark ES256 JWT support bean as completed
All checks were successful
CI / backend-tests (pull_request) Successful in 33s
CI / frontend-tests (pull_request) Successful in 33s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 11:53:13 +01:00
e935bc4d32 fix: accept ES256 (ECC P-256) JWT keys alongside RS256 in backend auth
Supabase JWT key was switched to ECC P-256, but the JWKS verification
only accepted RS256. Add ES256 to the accepted algorithms list.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 11:52:42 +01:00
79cbb06ec9 Merge pull request 'feat: team sidebar as floating panel on desktop' (#85) from feature/team-sidebar-desktop into develop
All checks were successful
CI / backend-tests (push) Successful in 30s
CI / frontend-tests (push) Successful in 28s
Reviewed-on: #85
2026-03-22 11:35:52 +01:00
4d6e1dc5b2 feat: make level field optional in boss defeat modal
All checks were successful
CI / backend-tests (pull_request) Successful in 29s
CI / frontend-tests (pull_request) Successful in 39s
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>
2026-03-22 10:16:15 +01:00
aee28cd7a1 chore: mark bean nuzlocke-tracker-lkro as completed
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 10:12:02 +01:00
3dbc3f35ba feat: make team section a floating sidebar on desktop
Add responsive 2-column layout for the encounters page:
- Desktop (lg, ≥1024px): Encounters on left, team in sticky right sidebar
- Mobile/tablet: Keep current stacked layout

The sidebar scrolls independently when team exceeds viewport height.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 10:11:40 +01:00
18 changed files with 784 additions and 503 deletions

View File

@@ -5,7 +5,7 @@ status: completed
type: bug
priority: high
created_at: 2026-03-22T09:41:57Z
updated_at: 2026-03-22T09:45:28Z
updated_at: 2026-03-22T09:45:38Z
parent: nuzlocke-tracker-bw1m
blocking:
- nuzlocke-tracker-2fp1

View File

@@ -1,11 +1,11 @@
---
# nuzlocke-tracker-532i
title: 'UX: Make level field optional in boss defeat modal'
status: todo
status: completed
type: feature
priority: normal
created_at: 2026-03-21T21:50:48Z
updated_at: 2026-03-21T22:04:08Z
updated_at: 2026-03-22T09:16:12Z
---
## 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 level input from `BossDefeatModal.tsx`
- [ ] Make `level` column nullable in the database (alembic migration)
- [ ] Update the API schema to make level optional (default to null)
- [ ] Update any backend validation that requires level
- [ ] Verify boss result display still works without level data
- [x] Remove level input from `BossDefeatModal.tsx`
- [x] Make `level` column nullable in the database (alembic migration)
- [x] Update the API schema to make level optional (default to null)
- [x] Update any backend validation that requires level
- [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

View File

@@ -0,0 +1,28 @@
---
# nuzlocke-tracker-95g1
title: 'Crash: Hide edit controls for non-owners in frontend'
status: completed
type: bug
priority: high
created_at: 2026-03-22T09:41:57Z
updated_at: 2026-03-22T09:46:59Z
parent: nuzlocke-tracker-bw1m
blocking:
- nuzlocke-tracker-i2va
---
Bean was found in 'in-progress' status on startup but no agent was running.
This likely indicates a crash or unexpected termination.
Manual review required before retrying.
Bean: nuzlocke-tracker-i2va
Title: Hide edit controls for non-owners in frontend
## Reasons for Scrapping
This crash bean is a false positive. The original task (nuzlocke-tracker-i2va) was already completed and merged to `develop` before this crash bean was created:
- Commit `3bd24fc`: fix: hide edit controls for non-owners in frontend
- Commit `118dbca`: chore: mark bean nuzlocke-tracker-i2va as completed
No additional work required.

View File

@@ -1,11 +1,11 @@
---
# nuzlocke-tracker-9rm8
title: 'Crash: Optional TOTP MFA for email/password accounts'
status: scrapped
status: completed
type: bug
priority: high
created_at: 2026-03-22T09:41:57Z
updated_at: 2026-03-22T09:46:14Z
updated_at: 2026-03-22T09:46:30Z
parent: nuzlocke-tracker-bw1m
blocking:
- nuzlocke-tracker-f2hs

View File

@@ -1,11 +1,11 @@
---
# nuzlocke-tracker-lkro
title: 'UX: Make team section a floating sidebar on desktop'
status: todo
status: completed
type: feature
priority: normal
created_at: 2026-03-21T21:50:48Z
updated_at: 2026-03-22T08:08:13Z
updated_at: 2026-03-22T09:11:58Z
---
## Problem
@@ -28,9 +28,31 @@ Alternative: A floating action button (FAB) that opens the team in a slide-over
## Checklist
- [ ] Add responsive 2-column layout to RunEncounters page (desktop only)
- [ ] Move team section into a sticky sidebar column
- [ ] Ensure sidebar scrolls independently if team is taller than viewport
- [ ] Keep current stacked layout on mobile/tablet
- [ ] Test with various team sizes (0-6 pokemon)
- [ ] Test evolution/nickname editing still works from sidebar
- [x] Add responsive 2-column layout to RunEncounters page (desktop only)
- [x] Move team section into a sticky sidebar column
- [x] Ensure sidebar scrolls independently if team is taller than viewport
- [x] Keep current stacked layout on mobile/tablet
- [x] Test with various team sizes (0-6 pokemon)
- [x] Test evolution/nickname editing still works from sidebar
## Summary of Changes
Implemented a responsive 2-column layout for the RunEncounters page:
**Desktop (lg, ≥1024px):**
- Encounters list on the left in a flex column
- Team section in a 256px sticky sidebar on the right
- Sidebar stays visible while scrolling through routes and bosses
- Independent scrolling for sidebar when team is taller than viewport (max-h-[calc(100vh-6rem)] overflow-y-auto)
- 2-column grid for pokemon cards in sidebar
**Mobile/Tablet (<1024px):**
- Original stacked layout preserved (team above encounters)
- Collapsible team section with expand/collapse toggle
**Technical changes:**
- Page container widened from max-w-4xl to lg:max-w-6xl
- Added lg:flex lg:gap-6 wrapper for 2-column layout
- Mobile team section hidden on lg with lg:hidden
- Desktop sidebar hidden below lg with hidden lg:block
- Sidebar styled with bg-surface-1 border and rounded corners

View File

@@ -0,0 +1,15 @@
---
# nuzlocke-tracker-snft
title: Support ES256 (ECC P-256) JWT keys in backend auth
status: completed
type: bug
priority: normal
created_at: 2026-03-22T10:51:30Z
updated_at: 2026-03-22T10:59:46Z
---
Backend JWKS verification only accepts RS256 algorithm, but Supabase JWT key was switched to ECC P-256 (ES256). This causes 401 errors on all authenticated requests. Fix: accept both RS256 and ES256 in the algorithms list, and update tests accordingly.
## Summary of Changes\n\nAdded ES256 to the accepted JWT algorithms in `_verify_jwt()` so ECC P-256 keys from Supabase are verified correctly alongside RSA keys. Added corresponding test with EC key fixtures.
Deployed to production via PR #86 merge on 2026-03-22.

View File

@@ -5,7 +5,7 @@ status: completed
type: bug
priority: high
created_at: 2026-03-21T21:50:48Z
updated_at: 2026-03-22T09:01:42Z
updated_at: 2026-03-22T09:44:54Z
---
## Problem

View File

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

View File

@@ -1,6 +1,10 @@
from fastapi import APIRouter
import urllib.request
from fastapi import APIRouter, Request
from sqlalchemy import text
from app.core.auth import _build_jwks_url, _extract_token, _get_jwks_client
from app.core.config import settings
from app.core.database import async_session
router = APIRouter(tags=["health"])
@@ -23,3 +27,45 @@ async def health_check():
async def root():
"""Root endpoint."""
return {"message": "Nuzlocke Tracker API", "docs": "/docs"}
@router.get("/auth-debug")
async def auth_debug(request: Request):
"""Temporary diagnostic endpoint for auth debugging."""
result: dict = {}
# Config
result["supabase_url"] = settings.supabase_url
result["has_jwt_secret"] = bool(settings.supabase_jwt_secret)
result["jwks_url"] = (
_build_jwks_url(settings.supabase_url) if settings.supabase_url else None
)
# JWKS fetch
jwks_url = result["jwks_url"]
if jwks_url:
try:
with urllib.request.urlopen(jwks_url, timeout=5) as resp:
result["jwks_status"] = resp.status
result["jwks_body"] = resp.read().decode()
except Exception as e:
result["jwks_fetch_error"] = str(e)
# JWKS client
client = _get_jwks_client()
result["jwks_client_exists"] = client is not None
# Token info (header only, no secrets)
token = _extract_token(request)
if token:
import jwt
try:
header = jwt.get_unverified_header(token)
result["token_header"] = header
except Exception as e:
result["token_header_error"] = str(e)
else:
result["token"] = "not provided"
return result

View File

@@ -1,3 +1,4 @@
import logging
from dataclasses import dataclass
from uuid import UUID
@@ -12,6 +13,7 @@ from app.core.database import get_session
from app.models.nuzlocke_run import NuzlockeRun
from app.models.user import User
logger = logging.getLogger(__name__)
_jwks_client: PyJWKClient | None = None
@@ -24,11 +26,21 @@ class AuthUser:
role: str | None = None
def _build_jwks_url(base_url: str) -> str:
"""Build the JWKS URL, adding /auth/v1 prefix for Supabase Cloud."""
base = base_url.rstrip("/")
if "/auth/v1" in base:
return f"{base}/.well-known/jwks.json"
# Supabase Cloud URLs need the /auth/v1 prefix;
# local GoTrue serves JWKS at root but uses HS256 fallback anyway.
return f"{base}/auth/v1/.well-known/jwks.json"
def _get_jwks_client() -> PyJWKClient | None:
"""Get or create a cached JWKS client."""
global _jwks_client
if _jwks_client is None and settings.supabase_url:
jwks_url = f"{settings.supabase_url.rstrip('/')}/.well-known/jwks.json"
jwks_url = _build_jwks_url(settings.supabase_url)
_jwks_client = PyJWKClient(jwks_url, cache_jwk_set=True, lifespan=300)
return _jwks_client
@@ -60,7 +72,7 @@ def _verify_jwt_hs256(token: str) -> dict | None:
def _verify_jwt(token: str) -> dict | None:
"""Verify JWT using JWKS (RS256), falling back to HS256 shared secret."""
"""Verify JWT using JWKS (RS256/ES256), falling back to HS256 shared secret."""
client = _get_jwks_client()
if client:
try:
@@ -68,15 +80,17 @@ def _verify_jwt(token: str) -> dict | None:
return jwt.decode(
token,
signing_key.key,
algorithms=["RS256"],
algorithms=["RS256", "ES256"],
audience="authenticated",
)
except jwt.InvalidTokenError:
pass
except PyJWKClientError:
pass
except PyJWKSetError:
pass
except jwt.InvalidTokenError as e:
logger.warning("JWKS JWT validation failed: %s", e)
except PyJWKClientError as e:
logger.warning("JWKS client error: %s", e)
except PyJWKSetError as e:
logger.warning("JWKS set error: %s", e)
else:
logger.warning("No JWKS client available (SUPABASE_URL not set?)")
return _verify_jwt_hs256(token)

View File

@@ -14,7 +14,7 @@ class BossResultTeam(Base):
encounter_id: Mapped[int] = mapped_column(
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")
encounter: Mapped[Encounter] = relationship()

View File

@@ -57,7 +57,7 @@ class BossBattleResponse(CamelModel):
class BossResultTeamMemberResponse(CamelModel):
id: int
encounter_id: int
level: int
level: int | None
class BossResultResponse(CamelModel):
@@ -120,7 +120,7 @@ class BossPokemonInput(CamelModel):
class BossResultTeamMemberInput(CamelModel):
encounter_id: int
level: int
level: int | None = None
class BossResultCreate(CamelModel):

View File

@@ -4,7 +4,7 @@ from uuid import UUID
import jwt
import pytest
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives.asymmetric import ec, rsa
from httpx import ASGITransport, AsyncClient
from app.core.auth import AuthUser, get_current_user, require_admin, require_auth
@@ -73,6 +73,55 @@ def mock_jwks_client(rsa_key_pair):
return mock_client
@pytest.fixture(scope="module")
def ec_key_pair():
"""Generate EC P-256 key pair for testing."""
private_key = ec.generate_private_key(ec.SECP256R1())
public_key = private_key.public_key()
return private_key, public_key
@pytest.fixture
def valid_es256_token(ec_key_pair):
"""Generate a valid ES256 JWT token."""
private_key, _ = ec_key_pair
payload = {
"sub": "user-456",
"email": "ec-user@example.com",
"role": "authenticated",
"aud": "authenticated",
"exp": int(time.time()) + 3600,
}
return jwt.encode(payload, private_key, algorithm="ES256")
@pytest.fixture
def mock_jwks_client_ec(ec_key_pair):
"""Create a mock JWKS client that returns our test EC public key."""
_, public_key = ec_key_pair
mock_client = MagicMock()
mock_signing_key = MagicMock()
mock_signing_key.key = public_key
mock_client.get_signing_key_from_jwt.return_value = mock_signing_key
return mock_client
async def test_get_current_user_valid_es256_token(
valid_es256_token, mock_jwks_client_ec
):
"""Test get_current_user works with ES256 (ECC P-256) tokens."""
with patch("app.core.auth._get_jwks_client", return_value=mock_jwks_client_ec):
class MockRequest:
headers = {"Authorization": f"Bearer {valid_es256_token}"}
user = get_current_user(MockRequest())
assert user is not None
assert user.id == "user-456"
assert user.email == "ec-user@example.com"
assert user.role == "authenticated"
async def test_get_current_user_valid_token(valid_token, mock_jwks_client):
"""Test get_current_user returns user for valid token."""
with patch("app.core.auth._get_jwks_client", return_value=mock_jwks_client):

View File

@@ -17,7 +17,7 @@
"react": "19.2.4",
"react-dom": "19.2.4",
"react-markdown": "^10.1.0",
"react-router-dom": "7.13.1",
"react-router-dom": "7.14.0",
"remark-gfm": "^4.0.1",
"sonner": "2.0.7"
},
@@ -4385,9 +4385,9 @@
}
},
"node_modules/react-router": {
"version": "7.13.1",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.1.tgz",
"integrity": "sha512-td+xP4X2/6BJvZoX6xw++A2DdEi++YypA69bJUV5oVvqf6/9/9nNlD70YO1e9d3MyamJEBQFEzk6mbfDYbqrSA==",
"version": "7.14.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.14.0.tgz",
"integrity": "sha512-m/xR9N4LQLmAS0ZhkY2nkPA1N7gQ5TUVa5n8TgANuDTARbn1gt+zLPXEm7W0XDTbrQ2AJSJKhoa6yx1D8BcpxQ==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
@@ -4407,12 +4407,12 @@
}
},
"node_modules/react-router-dom": {
"version": "7.13.1",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.1.tgz",
"integrity": "sha512-UJnV3Rxc5TgUPJt2KJpo1Jpy0OKQr0AjgbZzBFjaPJcFOb2Y8jA5H3LT8HUJAiRLlWrEXWHbF1Z4SCZaQjWDHw==",
"version": "7.14.0",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.14.0.tgz",
"integrity": "sha512-2G3ajSVSZMEtmTjIklRWlNvo8wICEpLihfD/0YMDxbWK2UyP5EGfnoIn9AIQGnF3G/FX0MRbHXdFcD+rL1ZreQ==",
"license": "MIT",
"dependencies": {
"react-router": "7.13.1"
"react-router": "7.14.0"
},
"engines": {
"node": ">=20.0.0"

View File

@@ -25,7 +25,7 @@
"react": "19.2.4",
"react-dom": "19.2.4",
"react-markdown": "^10.1.0",
"react-router-dom": "7.13.1",
"react-router-dom": "7.14.0",
"remark-gfm": "^4.0.1",
"sonner": "2.0.7"
},

View File

@@ -23,10 +23,7 @@ function matchVariant(labels: string[], starterName?: string | null): string | n
return matches.length === 1 ? (matches[0] ?? null) : null
}
interface TeamSelection {
encounterId: number
level: number
}
type TeamSelection = number
export function BossDefeatModal({
boss,
@@ -36,26 +33,15 @@ export function BossDefeatModal({
isPending,
starterName,
}: 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) => {
const next = new Map(prev)
if (next.has(enc.id)) {
next.delete(enc.id)
const next = new Set(prev)
if (next.has(encounterId)) {
next.delete(encounterId)
} else {
next.set(enc.id, { encounterId: enc.id, level: enc.catchLevel ?? 1 })
}
return next
})
}
const updateLevel = (encounterId: number, level: number) => {
setSelectedTeam((prev) => {
const next = new Map(prev)
const existing = next.get(encounterId)
if (existing) {
next.set(encounterId, { ...existing, level })
next.add(encounterId)
}
return next
})
@@ -87,7 +73,9 @@ export function BossDefeatModal({
const handleSubmit = (e: FormEvent) => {
e.preventDefault()
const team: BossResultTeamMemberInput[] = Array.from(selectedTeam.values())
const team: BossResultTeamMemberInput[] = Array.from(selectedTeam).map((encounterId) => ({
encounterId,
}))
onSubmit({
bossBattleId: boss.id,
result: 'won',
@@ -134,11 +122,17 @@ export function BossDefeatModal({
return (
<div key={bp.id} className="flex flex-col items-center">
{bp.pokemon.spriteUrl ? (
<img src={bp.pokemon.spriteUrl} alt={bp.pokemon.name} className="w-10 h-10" />
<img
src={bp.pokemon.spriteUrl}
alt={bp.pokemon.name}
className="w-10 h-10"
/>
) : (
<div className="w-10 h-10 bg-surface-3 rounded-full" />
)}
<span className="text-xs text-text-tertiary capitalize">{bp.pokemon.name}</span>
<span className="text-xs text-text-tertiary capitalize">
{bp.pokemon.name}
</span>
<span className="text-xs font-medium text-text-secondary">Lv.{bp.level}</span>
<ConditionBadge condition={bp.conditionLabel} size="xs" />
{bp.ability && (
@@ -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">
{aliveEncounters.map((enc) => {
const isSelected = selectedTeam.has(enc.id)
const selection = selectedTeam.get(enc.id)
const displayPokemon = enc.currentPokemon ?? enc.pokemon
return (
<div
@@ -176,12 +169,12 @@ export function BossDefeatModal({
? 'border-accent-500 bg-accent-500/10'
: 'border-border-default hover:bg-surface-2'
}`}
onClick={() => toggleTeamMember(enc)}
onClick={() => toggleTeamMember(enc.id)}
>
<input
type="checkbox"
checked={isSelected}
onChange={() => toggleTeamMember(enc)}
onChange={() => toggleTeamMember(enc.id)}
className="sr-only"
/>
{displayPokemon.spriteUrl ? (
@@ -193,26 +186,9 @@ export function BossDefeatModal({
) : (
<div className="w-8 h-8 bg-surface-3 rounded-full" />
)}
<div className="flex-1 min-w-0">
<p className="text-xs font-medium truncate">
<p className="flex-1 min-w-0 text-xs font-medium truncate">
{enc.nickname ?? displayPokemon.name}
</p>
{isSelected && (
<input
type="number"
min={1}
max={100}
value={selection?.level ?? enc.catchLevel ?? 1}
onChange={(e) => {
e.stopPropagation()
updateLevel(enc.id, Number.parseInt(e.target.value, 10) || 1)
}}
onClick={(e) => e.stopPropagation()}
className="w-14 text-xs px-1 py-0.5 mt-1 rounded border border-border-default bg-surface-1"
placeholder="Lv"
/>
)}
</div>
</div>
)
})}

View File

@@ -922,7 +922,7 @@ export function RunEncounters() {
})
return (
<div className="max-w-4xl mx-auto p-8">
<div className="max-w-4xl lg:max-w-6xl mx-auto p-8">
{/* Header */}
<div className="mb-6">
<Link
@@ -1246,9 +1246,12 @@ export function RunEncounters() {
{/* Encounters Tab */}
{activeTab === 'encounters' && (
<>
{/* Team Section */}
<div className="lg:flex lg:gap-6">
{/* Main content column */}
<div className="flex-1 min-w-0">
{/* Team Section - Mobile/Tablet only */}
{(alive.length > 0 || dead.length > 0) && (
<div className="mb-6">
<div className="mb-6 lg:hidden">
<div className="flex items-center justify-between mb-3">
<button
type="button"
@@ -1298,7 +1301,9 @@ export function RunEncounters() {
key={enc.id}
encounter={enc}
onClick={
isActive && canEdit ? () => setSelectedTeamEncounter(enc) : undefined
isActive && canEdit
? () => setSelectedTeamEncounter(enc)
: undefined
}
/>
))}
@@ -1314,7 +1319,9 @@ export function RunEncounters() {
encounter={enc}
showFaintLevel
onClick={
isActive && canEdit ? () => setSelectedTeamEncounter(enc) : undefined
isActive && canEdit
? () => setSelectedTeamEncounter(enc)
: undefined
}
/>
))}
@@ -1347,7 +1354,9 @@ export function RunEncounters() {
<PokemonCard
key={enc.id}
encounter={enc}
onClick={isActive && canEdit ? () => setSelectedTeamEncounter(enc) : undefined}
onClick={
isActive && canEdit ? () => setSelectedTeamEncounter(enc) : undefined
}
/>
))}
</div>
@@ -1472,7 +1481,9 @@ export function RunEncounters() {
>
<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>
<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 && (
@@ -1486,7 +1497,9 @@ export function RunEncounters() {
{encounter.nickname ?? encounter.pokemon.name}
{encounter.status === 'caught' &&
encounter.faintLevel !== null &&
(encounter.deathCause ? `${encounter.deathCause}` : ' (dead)')}
(encounter.deathCause
? `${encounter.deathCause}`
: ' (dead)')}
</span>
{giftEncounter && (
<>
@@ -1598,7 +1611,11 @@ export function RunEncounters() {
/>
</svg>
{boss.spriteUrl && (
<img src={boss.spriteUrl} alt={boss.name} className="h-10 w-auto" />
<img
src={boss.spriteUrl}
alt={boss.name}
className="h-10 w-auto"
/>
)}
<div>
<div className="flex items-center gap-2">
@@ -1608,7 +1625,9 @@ export function RunEncounters() {
<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} />}
{boss.specialtyType && (
<TypeBadge type={boss.specialtyType} />
)}
</div>
<p className="text-xs text-text-tertiary">
{boss.location} &middot; Level Cap: {boss.levelCap}
@@ -1663,9 +1682,11 @@ export function RunEncounters() {
<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>
)
})}
@@ -1690,6 +1711,70 @@ export function RunEncounters() {
)
})}
</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>
)}
{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>
{/* Encounter Modal */}
{selectedRoute && (

View File

@@ -238,7 +238,7 @@ export interface BossBattle {
export interface BossResultTeamMember {
id: number
encounterId: number
level: number
level: number | null
}
export interface BossResult {
@@ -253,7 +253,7 @@ export interface BossResult {
export interface BossResultTeamMemberInput {
encounterId: number
level: number
level?: number | null
}
export interface CreateBossResultInput {