Compare commits
36 Commits
80d5d01993
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| dc75c94fac | |||
| d8fec0e5d7 | |||
| 403ad823ef | |||
| c9b09b8250 | |||
| 1af2e37a7f | |||
| fde1867863 | |||
| 5a9848fd5f | |||
| ce9d08963f | |||
| c5959cfd14 | |||
| e935bc4d32 | |||
| 712badb69d | |||
| 79cbb06ec9 | |||
| d1ede63256 | |||
| 4d6e1dc5b2 | |||
| aee28cd7a1 | |||
| 3dbc3f35ba | |||
| c40dd38c99 | |||
| 98121d9954 | |||
| f340f8fd0d | |||
| d2fa9e46df | |||
| f770e4a785 | |||
| 013a45ab56 | |||
| 321b940398 | |||
| e21a8acc60 | |||
| f15e530130 | |||
| e533a3404e | |||
| a944da2204 | |||
| 012cfb96cd | |||
| e3e015852c | |||
| 59b4f7f28c | |||
| e212251da8 | |||
| f49c8cee85 | |||
| b34f1083a3 | |||
| b85668c233 | |||
| 45cbff7672 | |||
| 51b47dbfb0 |
@@ -5,7 +5,7 @@ status: completed
|
|||||||
type: bug
|
type: bug
|
||||||
priority: high
|
priority: high
|
||||||
created_at: 2026-03-22T09:41:57Z
|
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
|
parent: nuzlocke-tracker-bw1m
|
||||||
blocking:
|
blocking:
|
||||||
- nuzlocke-tracker-2fp1
|
- nuzlocke-tracker-2fp1
|
||||||
|
|||||||
@@ -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,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.
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
---
|
---
|
||||||
# nuzlocke-tracker-9rm8
|
# nuzlocke-tracker-9rm8
|
||||||
title: 'Crash: Optional TOTP MFA for email/password accounts'
|
title: 'Crash: Optional TOTP MFA for email/password accounts'
|
||||||
status: scrapped
|
status: completed
|
||||||
type: bug
|
type: bug
|
||||||
priority: high
|
priority: high
|
||||||
created_at: 2026-03-22T09:41:57Z
|
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
|
parent: nuzlocke-tracker-bw1m
|
||||||
blocking:
|
blocking:
|
||||||
- nuzlocke-tracker-f2hs
|
- nuzlocke-tracker-f2hs
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
---
|
---
|
||||||
# nuzlocke-tracker-lkro
|
# nuzlocke-tracker-lkro
|
||||||
title: 'UX: Make team section a floating sidebar on desktop'
|
title: 'UX: Make team section a floating sidebar on desktop'
|
||||||
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-22T08:08:13Z
|
updated_at: 2026-03-22T09:11:58Z
|
||||||
---
|
---
|
||||||
|
|
||||||
## Problem
|
## Problem
|
||||||
@@ -28,9 +28,31 @@ Alternative: A floating action button (FAB) that opens the team in a slide-over
|
|||||||
|
|
||||||
## Checklist
|
## Checklist
|
||||||
|
|
||||||
- [ ] Add responsive 2-column layout to RunEncounters page (desktop only)
|
- [x] Add responsive 2-column layout to RunEncounters page (desktop only)
|
||||||
- [ ] Move team section into a sticky sidebar column
|
- [x] Move team section into a sticky sidebar column
|
||||||
- [ ] Ensure sidebar scrolls independently if team is taller than viewport
|
- [x] Ensure sidebar scrolls independently if team is taller than viewport
|
||||||
- [ ] Keep current stacked layout on mobile/tablet
|
- [x] Keep current stacked layout on mobile/tablet
|
||||||
- [ ] Test with various team sizes (0-6 pokemon)
|
- [x] Test with various team sizes (0-6 pokemon)
|
||||||
- [ ] Test evolution/nickname editing still works from sidebar
|
- [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
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -5,7 +5,7 @@ status: completed
|
|||||||
type: bug
|
type: bug
|
||||||
priority: high
|
priority: high
|
||||||
created_at: 2026-03-21T21:50:48Z
|
created_at: 2026-03-21T21:50:48Z
|
||||||
updated_at: 2026-03-22T09:01:42Z
|
updated_at: 2026-03-22T09:44:54Z
|
||||||
---
|
---
|
||||||
|
|
||||||
## Problem
|
## Problem
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
from fastapi import APIRouter
|
import urllib.request
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Request
|
||||||
from sqlalchemy import text
|
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
|
from app.core.database import async_session
|
||||||
|
|
||||||
router = APIRouter(tags=["health"])
|
router = APIRouter(tags=["health"])
|
||||||
@@ -23,3 +27,45 @@ async def health_check():
|
|||||||
async def root():
|
async def root():
|
||||||
"""Root endpoint."""
|
"""Root endpoint."""
|
||||||
return {"message": "Nuzlocke Tracker API", "docs": "/docs"}
|
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
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import logging
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from uuid import UUID
|
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.nuzlocke_run import NuzlockeRun
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
_jwks_client: PyJWKClient | None = None
|
_jwks_client: PyJWKClient | None = None
|
||||||
|
|
||||||
|
|
||||||
@@ -24,11 +26,21 @@ class AuthUser:
|
|||||||
role: str | None = None
|
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:
|
def _get_jwks_client() -> PyJWKClient | None:
|
||||||
"""Get or create a cached JWKS client."""
|
"""Get or create a cached JWKS client."""
|
||||||
global _jwks_client
|
global _jwks_client
|
||||||
if _jwks_client is None and settings.supabase_url:
|
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)
|
_jwks_client = PyJWKClient(jwks_url, cache_jwk_set=True, lifespan=300)
|
||||||
return _jwks_client
|
return _jwks_client
|
||||||
|
|
||||||
@@ -60,7 +72,7 @@ def _verify_jwt_hs256(token: str) -> dict | None:
|
|||||||
|
|
||||||
|
|
||||||
def _verify_jwt(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()
|
client = _get_jwks_client()
|
||||||
if client:
|
if client:
|
||||||
try:
|
try:
|
||||||
@@ -68,15 +80,17 @@ def _verify_jwt(token: str) -> dict | None:
|
|||||||
return jwt.decode(
|
return jwt.decode(
|
||||||
token,
|
token,
|
||||||
signing_key.key,
|
signing_key.key,
|
||||||
algorithms=["RS256"],
|
algorithms=["RS256", "ES256"],
|
||||||
audience="authenticated",
|
audience="authenticated",
|
||||||
)
|
)
|
||||||
except jwt.InvalidTokenError:
|
except jwt.InvalidTokenError as e:
|
||||||
pass
|
logger.warning("JWKS JWT validation failed: %s", e)
|
||||||
except PyJWKClientError:
|
except PyJWKClientError as e:
|
||||||
pass
|
logger.warning("JWKS client error: %s", e)
|
||||||
except PyJWKSetError:
|
except PyJWKSetError as e:
|
||||||
pass
|
logger.warning("JWKS set error: %s", e)
|
||||||
|
else:
|
||||||
|
logger.warning("No JWKS client available (SUPABASE_URL not set?)")
|
||||||
return _verify_jwt_hs256(token)
|
return _verify_jwt_hs256(token)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from uuid import UUID
|
|||||||
|
|
||||||
import jwt
|
import jwt
|
||||||
import pytest
|
import pytest
|
||||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
from cryptography.hazmat.primitives.asymmetric import ec, rsa
|
||||||
from httpx import ASGITransport, AsyncClient
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
|
||||||
from app.core.auth import AuthUser, get_current_user, require_admin, require_auth
|
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
|
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):
|
async def test_get_current_user_valid_token(valid_token, mock_jwks_client):
|
||||||
"""Test get_current_user returns user for valid token."""
|
"""Test get_current_user returns user for valid token."""
|
||||||
with patch("app.core.auth._get_jwks_client", return_value=mock_jwks_client):
|
with patch("app.core.auth._get_jwks_client", return_value=mock_jwks_client):
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -922,7 +922,7 @@ export function RunEncounters() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-4xl mx-auto p-8">
|
<div className="max-w-4xl lg:max-w-6xl mx-auto p-8">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<Link
|
<Link
|
||||||
@@ -1246,250 +1246,279 @@ export function RunEncounters() {
|
|||||||
{/* Encounters Tab */}
|
{/* Encounters Tab */}
|
||||||
{activeTab === 'encounters' && (
|
{activeTab === 'encounters' && (
|
||||||
<>
|
<>
|
||||||
{/* Team Section */}
|
<div className="lg:flex lg:gap-6">
|
||||||
{(alive.length > 0 || dead.length > 0) && (
|
{/* Main content column */}
|
||||||
<div className="mb-6">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center justify-between mb-3">
|
{/* Team Section - Mobile/Tablet only */}
|
||||||
<button
|
{(alive.length > 0 || dead.length > 0) && (
|
||||||
type="button"
|
<div className="mb-6 lg:hidden">
|
||||||
onClick={() => setShowTeam(!showTeam)}
|
<div className="flex items-center justify-between mb-3">
|
||||||
className="flex items-center gap-2 group"
|
<button
|
||||||
>
|
type="button"
|
||||||
<h2 className="text-lg font-semibold text-text-primary">
|
onClick={() => setShowTeam(!showTeam)}
|
||||||
{isActive ? 'Team' : 'Final Team'}
|
className="flex items-center gap-2 group"
|
||||||
</h2>
|
>
|
||||||
<span className="text-xs text-text-muted">
|
<h2 className="text-lg font-semibold text-text-primary">
|
||||||
{alive.length} alive
|
{isActive ? 'Team' : 'Final Team'}
|
||||||
{dead.length > 0 ? `, ${dead.length} dead` : ''}
|
</h2>
|
||||||
</span>
|
<span className="text-xs text-text-muted">
|
||||||
<svg
|
{alive.length} alive
|
||||||
className={`w-4 h-4 text-text-tertiary transition-transform ${showTeam ? 'rotate-180' : ''}`}
|
{dead.length > 0 ? `, ${dead.length} dead` : ''}
|
||||||
fill="none"
|
</span>
|
||||||
stroke="currentColor"
|
<svg
|
||||||
viewBox="0 0 24 24"
|
className={`w-4 h-4 text-text-tertiary transition-transform ${showTeam ? 'rotate-180' : ''}`}
|
||||||
>
|
fill="none"
|
||||||
<path
|
stroke="currentColor"
|
||||||
strokeLinecap="round"
|
viewBox="0 0 24 24"
|
||||||
strokeLinejoin="round"
|
>
|
||||||
strokeWidth={2}
|
<path
|
||||||
d="M19 9l-7 7-7-7"
|
strokeLinecap="round"
|
||||||
/>
|
strokeLinejoin="round"
|
||||||
</svg>
|
strokeWidth={2}
|
||||||
</button>
|
d="M19 9l-7 7-7-7"
|
||||||
{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}
|
||||||
@@ -1501,194 +1530,250 @@ 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>
|
||||||
|
|
||||||
|
{/* 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 && (
|
||||||
|
<>
|
||||||
|
<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>
|
||||||
|
|
||||||
{/* Encounter Modal */}
|
{/* Encounter Modal */}
|
||||||
|
|||||||
@@ -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