Release: auth system, admin RBAC, and production Supabase setup #70
@@ -2,5 +2,12 @@
|
||||
DEBUG=true
|
||||
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/nuzlocke
|
||||
|
||||
# Supabase Auth (backend)
|
||||
SUPABASE_URL=https://your-project.supabase.co
|
||||
SUPABASE_ANON_KEY=your-anon-key
|
||||
SUPABASE_JWT_SECRET=your-jwt-secret
|
||||
|
||||
# Frontend settings (used by Vite)
|
||||
VITE_API_URL=http://localhost:8000
|
||||
VITE_SUPABASE_URL=https://your-project.supabase.co
|
||||
VITE_SUPABASE_ANON_KEY=your-anon-key
|
||||
|
||||
@@ -7,3 +7,8 @@ API_V1_PREFIX="/api/v1"
|
||||
|
||||
# Database settings
|
||||
DATABASE_URL="sqlite:///./nuzlocke.db"
|
||||
|
||||
# Supabase Auth
|
||||
SUPABASE_URL=https://your-project.supabase.co
|
||||
SUPABASE_ANON_KEY=your-anon-key
|
||||
SUPABASE_JWT_SECRET=your-jwt-secret
|
||||
|
||||
@@ -13,6 +13,7 @@ dependencies = [
|
||||
"sqlalchemy[asyncio]==2.0.48",
|
||||
"asyncpg==0.31.0",
|
||||
"alembic==1.18.4",
|
||||
"PyJWT==2.10.1",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
81
backend/scripts/assign_unowned_runs.py
Normal file
81
backend/scripts/assign_unowned_runs.py
Normal file
@@ -0,0 +1,81 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Assign existing unowned runs to a user.
|
||||
|
||||
Usage:
|
||||
cd backend && uv run python scripts/assign_unowned_runs.py <user_uuid>
|
||||
|
||||
This script assigns all runs without an owner to the specified user.
|
||||
Useful for migrating existing data after implementing user ownership.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import select, update
|
||||
|
||||
sys.path.insert(0, "src")
|
||||
|
||||
from app.core.database import async_session # noqa: E402
|
||||
from app.models.nuzlocke_run import NuzlockeRun # noqa: E402
|
||||
from app.models.user import User # noqa: E402
|
||||
|
||||
|
||||
async def main(user_uuid: str) -> None:
|
||||
try:
|
||||
user_id = UUID(user_uuid)
|
||||
except ValueError:
|
||||
print(f"Error: Invalid UUID format: {user_uuid}")
|
||||
sys.exit(1)
|
||||
|
||||
async with async_session() as session:
|
||||
# Verify user exists
|
||||
user_result = await session.execute(select(User).where(User.id == user_id))
|
||||
user = user_result.scalar_one_or_none()
|
||||
if user is None:
|
||||
print(f"Error: User {user_id} not found")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"Found user: {user.email} (display_name: {user.display_name})")
|
||||
|
||||
# Count unowned runs
|
||||
count_result = await session.execute(
|
||||
select(NuzlockeRun.id, NuzlockeRun.name).where(
|
||||
NuzlockeRun.owner_id.is_(None)
|
||||
)
|
||||
)
|
||||
unowned_runs = count_result.all()
|
||||
|
||||
if not unowned_runs:
|
||||
print("No unowned runs found.")
|
||||
return
|
||||
|
||||
print(f"\nFound {len(unowned_runs)} unowned run(s):")
|
||||
for run_id, run_name in unowned_runs:
|
||||
print(f" - [{run_id}] {run_name}")
|
||||
|
||||
# Confirm action
|
||||
confirm = input(f"\nAssign all {len(unowned_runs)} runs to this user? [y/N] ")
|
||||
if confirm.lower() != "y":
|
||||
print("Aborted.")
|
||||
return
|
||||
|
||||
# Perform the update
|
||||
await session.execute(
|
||||
update(NuzlockeRun)
|
||||
.where(NuzlockeRun.owner_id.is_(None))
|
||||
.values(owner_id=user_id)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
print(f"\nAssigned {len(unowned_runs)} run(s) to user {user.email}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) != 2:
|
||||
print("Usage: python scripts/assign_unowned_runs.py <user_uuid>")
|
||||
print("\nExample:")
|
||||
print(" uv run python scripts/assign_unowned_runs.py 550e8400-e29b-41d4-a716-446655440000")
|
||||
sys.exit(1)
|
||||
|
||||
asyncio.run(main(sys.argv[1]))
|
||||
@@ -0,0 +1,62 @@
|
||||
"""add boss pokemon details
|
||||
|
||||
Revision ID: l3a4b5c6d7e8
|
||||
Revises: k2f3a4b5c6d7
|
||||
Create Date: 2026-03-20 19:30:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "l3a4b5c6d7e8"
|
||||
down_revision: str | Sequence[str] | None = "k2f3a4b5c6d7"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Add ability reference
|
||||
op.add_column(
|
||||
"boss_pokemon",
|
||||
sa.Column(
|
||||
"ability_id", sa.Integer(), sa.ForeignKey("abilities.id"), nullable=True
|
||||
),
|
||||
)
|
||||
op.create_index("ix_boss_pokemon_ability_id", "boss_pokemon", ["ability_id"])
|
||||
|
||||
# Add held item (plain string)
|
||||
op.add_column(
|
||||
"boss_pokemon",
|
||||
sa.Column("held_item", sa.String(50), nullable=True),
|
||||
)
|
||||
|
||||
# Add nature (plain string)
|
||||
op.add_column(
|
||||
"boss_pokemon",
|
||||
sa.Column("nature", sa.String(20), nullable=True),
|
||||
)
|
||||
|
||||
# Add move references (up to 4 moves)
|
||||
for i in range(1, 5):
|
||||
op.add_column(
|
||||
"boss_pokemon",
|
||||
sa.Column(
|
||||
f"move{i}_id", sa.Integer(), sa.ForeignKey("moves.id"), nullable=True
|
||||
),
|
||||
)
|
||||
op.create_index(f"ix_boss_pokemon_move{i}_id", "boss_pokemon", [f"move{i}_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
for i in range(1, 5):
|
||||
op.drop_index(f"ix_boss_pokemon_move{i}_id", "boss_pokemon")
|
||||
op.drop_column("boss_pokemon", f"move{i}_id")
|
||||
|
||||
op.drop_column("boss_pokemon", "nature")
|
||||
op.drop_column("boss_pokemon", "held_item")
|
||||
op.drop_index("ix_boss_pokemon_ability_id", "boss_pokemon")
|
||||
op.drop_column("boss_pokemon", "ability_id")
|
||||
@@ -0,0 +1,44 @@
|
||||
"""add boss result team
|
||||
|
||||
Revision ID: m4b5c6d7e8f9
|
||||
Revises: l3a4b5c6d7e8
|
||||
Create Date: 2026-03-20 20:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "m4b5c6d7e8f9"
|
||||
down_revision: str | Sequence[str] | None = "l3a4b5c6d7e8"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"boss_result_team",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column(
|
||||
"boss_result_id",
|
||||
sa.Integer(),
|
||||
sa.ForeignKey("boss_results.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
),
|
||||
sa.Column(
|
||||
"encounter_id",
|
||||
sa.Integer(),
|
||||
sa.ForeignKey("encounters.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
),
|
||||
sa.Column("level", sa.SmallInteger(), nullable=False),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("boss_result_team")
|
||||
@@ -0,0 +1,37 @@
|
||||
"""create users table
|
||||
|
||||
Revision ID: n5c6d7e8f9a0
|
||||
Revises: m4b5c6d7e8f9
|
||||
Create Date: 2026-03-20 22:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "n5c6d7e8f9a0"
|
||||
down_revision: str | Sequence[str] | None = "m4b5c6d7e8f9"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"users",
|
||||
sa.Column("id", sa.UUID(), primary_key=True),
|
||||
sa.Column("email", sa.String(255), nullable=False, unique=True, index=True),
|
||||
sa.Column("display_name", sa.String(100), nullable=True),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("users")
|
||||
@@ -0,0 +1,60 @@
|
||||
"""add owner_id and visibility to runs
|
||||
|
||||
Revision ID: o6d7e8f9a0b1
|
||||
Revises: n5c6d7e8f9a0
|
||||
Create Date: 2026-03-20 22:01:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "o6d7e8f9a0b1"
|
||||
down_revision: str | Sequence[str] | None = "n5c6d7e8f9a0"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Create visibility enum
|
||||
visibility_enum = sa.Enum("public", "private", name="run_visibility")
|
||||
visibility_enum.create(op.get_bind(), checkfirst=True)
|
||||
|
||||
# Add owner_id (nullable FK to users)
|
||||
op.add_column(
|
||||
"nuzlocke_runs",
|
||||
sa.Column("owner_id", sa.UUID(), nullable=True),
|
||||
)
|
||||
op.create_foreign_key(
|
||||
"fk_nuzlocke_runs_owner_id",
|
||||
"nuzlocke_runs",
|
||||
"users",
|
||||
["owner_id"],
|
||||
["id"],
|
||||
ondelete="SET NULL",
|
||||
)
|
||||
op.create_index("ix_nuzlocke_runs_owner_id", "nuzlocke_runs", ["owner_id"])
|
||||
|
||||
# Add visibility column with default 'public'
|
||||
op.add_column(
|
||||
"nuzlocke_runs",
|
||||
sa.Column(
|
||||
"visibility",
|
||||
visibility_enum,
|
||||
nullable=False,
|
||||
server_default="public",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("nuzlocke_runs", "visibility")
|
||||
op.drop_index("ix_nuzlocke_runs_owner_id", table_name="nuzlocke_runs")
|
||||
op.drop_constraint("fk_nuzlocke_runs_owner_id", "nuzlocke_runs", type_="foreignkey")
|
||||
op.drop_column("nuzlocke_runs", "owner_id")
|
||||
|
||||
# Drop the enum type
|
||||
sa.Enum(name="run_visibility").drop(op.get_bind(), checkfirst=True)
|
||||
@@ -5,10 +5,13 @@ from sqlalchemy import or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.core.auth import AuthUser, require_auth
|
||||
from app.core.database import get_session
|
||||
from app.models.boss_battle import BossBattle
|
||||
from app.models.boss_pokemon import BossPokemon
|
||||
from app.models.boss_result import BossResult
|
||||
from app.models.boss_result_team import BossResultTeam
|
||||
from app.models.encounter import Encounter
|
||||
from app.models.game import Game
|
||||
from app.models.nuzlocke_run import NuzlockeRun
|
||||
from app.models.pokemon import Pokemon
|
||||
@@ -28,6 +31,18 @@ from app.seeds.loader import upsert_bosses
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _boss_pokemon_load_options():
|
||||
"""Standard eager-loading options for BossPokemon relationships."""
|
||||
return (
|
||||
selectinload(BossBattle.pokemon).selectinload(BossPokemon.pokemon),
|
||||
selectinload(BossBattle.pokemon).selectinload(BossPokemon.ability),
|
||||
selectinload(BossBattle.pokemon).selectinload(BossPokemon.move1),
|
||||
selectinload(BossBattle.pokemon).selectinload(BossPokemon.move2),
|
||||
selectinload(BossBattle.pokemon).selectinload(BossPokemon.move3),
|
||||
selectinload(BossBattle.pokemon).selectinload(BossPokemon.move4),
|
||||
)
|
||||
|
||||
|
||||
async def _get_version_group_id(session: AsyncSession, game_id: int) -> int:
|
||||
game = await session.get(Game, game_id)
|
||||
if game is None:
|
||||
@@ -53,7 +68,7 @@ async def list_bosses(
|
||||
query = (
|
||||
select(BossBattle)
|
||||
.where(BossBattle.version_group_id == vg_id)
|
||||
.options(selectinload(BossBattle.pokemon).selectinload(BossPokemon.pokemon))
|
||||
.options(*_boss_pokemon_load_options())
|
||||
.order_by(BossBattle.order)
|
||||
)
|
||||
|
||||
@@ -71,6 +86,7 @@ async def reorder_bosses(
|
||||
game_id: int,
|
||||
data: BossReorderRequest,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
_user: AuthUser = Depends(require_auth),
|
||||
):
|
||||
vg_id = await _get_version_group_id(session, game_id)
|
||||
|
||||
@@ -101,7 +117,7 @@ async def reorder_bosses(
|
||||
result = await session.execute(
|
||||
select(BossBattle)
|
||||
.where(BossBattle.version_group_id == vg_id)
|
||||
.options(selectinload(BossBattle.pokemon).selectinload(BossPokemon.pokemon))
|
||||
.options(*_boss_pokemon_load_options())
|
||||
.order_by(BossBattle.order)
|
||||
)
|
||||
return result.scalars().all()
|
||||
@@ -114,6 +130,7 @@ async def create_boss(
|
||||
game_id: int,
|
||||
data: BossBattleCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
_user: AuthUser = Depends(require_auth),
|
||||
):
|
||||
vg_id = await _get_version_group_id(session, game_id)
|
||||
|
||||
@@ -133,7 +150,7 @@ async def create_boss(
|
||||
result = await session.execute(
|
||||
select(BossBattle)
|
||||
.where(BossBattle.id == boss.id)
|
||||
.options(selectinload(BossBattle.pokemon).selectinload(BossPokemon.pokemon))
|
||||
.options(*_boss_pokemon_load_options())
|
||||
)
|
||||
return result.scalar_one()
|
||||
|
||||
@@ -144,6 +161,7 @@ async def update_boss(
|
||||
boss_id: int,
|
||||
data: BossBattleUpdate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
_user: AuthUser = Depends(require_auth),
|
||||
):
|
||||
vg_id = await _get_version_group_id(session, game_id)
|
||||
|
||||
@@ -158,7 +176,7 @@ async def update_boss(
|
||||
result = await session.execute(
|
||||
select(BossBattle)
|
||||
.where(BossBattle.id == boss_id, BossBattle.version_group_id == vg_id)
|
||||
.options(selectinload(BossBattle.pokemon).selectinload(BossPokemon.pokemon))
|
||||
.options(*_boss_pokemon_load_options())
|
||||
)
|
||||
boss = result.scalar_one_or_none()
|
||||
if boss is None:
|
||||
@@ -174,7 +192,7 @@ async def update_boss(
|
||||
result = await session.execute(
|
||||
select(BossBattle)
|
||||
.where(BossBattle.id == boss.id)
|
||||
.options(selectinload(BossBattle.pokemon).selectinload(BossPokemon.pokemon))
|
||||
.options(*_boss_pokemon_load_options())
|
||||
)
|
||||
return result.scalar_one()
|
||||
|
||||
@@ -184,6 +202,7 @@ async def delete_boss(
|
||||
game_id: int,
|
||||
boss_id: int,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
_user: AuthUser = Depends(require_auth),
|
||||
):
|
||||
vg_id = await _get_version_group_id(session, game_id)
|
||||
|
||||
@@ -206,6 +225,7 @@ async def bulk_import_bosses(
|
||||
game_id: int,
|
||||
items: list[BulkBossItem],
|
||||
session: AsyncSession = Depends(get_session),
|
||||
_user: AuthUser = Depends(require_auth),
|
||||
):
|
||||
vg_id = await _get_version_group_id(session, game_id)
|
||||
|
||||
@@ -248,6 +268,7 @@ async def set_boss_team(
|
||||
boss_id: int,
|
||||
team: list[BossPokemonInput],
|
||||
session: AsyncSession = Depends(get_session),
|
||||
_user: AuthUser = Depends(require_auth),
|
||||
):
|
||||
vg_id = await _get_version_group_id(session, game_id)
|
||||
|
||||
@@ -272,6 +293,13 @@ async def set_boss_team(
|
||||
level=item.level,
|
||||
order=item.order,
|
||||
condition_label=item.condition_label,
|
||||
ability_id=item.ability_id,
|
||||
held_item=item.held_item,
|
||||
nature=item.nature,
|
||||
move1_id=item.move1_id,
|
||||
move2_id=item.move2_id,
|
||||
move3_id=item.move3_id,
|
||||
move4_id=item.move4_id,
|
||||
)
|
||||
session.add(bp)
|
||||
|
||||
@@ -286,7 +314,7 @@ async def set_boss_team(
|
||||
result = await session.execute(
|
||||
select(BossBattle)
|
||||
.where(BossBattle.id == boss.id)
|
||||
.options(selectinload(BossBattle.pokemon).selectinload(BossPokemon.pokemon))
|
||||
.options(*_boss_pokemon_load_options())
|
||||
)
|
||||
return result.scalar_one()
|
||||
|
||||
@@ -301,7 +329,10 @@ async def list_boss_results(run_id: int, session: AsyncSession = Depends(get_ses
|
||||
raise HTTPException(status_code=404, detail="Run not found")
|
||||
|
||||
result = await session.execute(
|
||||
select(BossResult).where(BossResult.run_id == run_id).order_by(BossResult.id)
|
||||
select(BossResult)
|
||||
.where(BossResult.run_id == run_id)
|
||||
.options(selectinload(BossResult.team))
|
||||
.order_by(BossResult.id)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
@@ -313,6 +344,7 @@ async def create_boss_result(
|
||||
run_id: int,
|
||||
data: BossResultCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
_user: AuthUser = Depends(require_auth),
|
||||
):
|
||||
run = await session.get(NuzlockeRun, run_id)
|
||||
if run is None:
|
||||
@@ -322,12 +354,30 @@ async def create_boss_result(
|
||||
if boss is None:
|
||||
raise HTTPException(status_code=404, detail="Boss battle not found")
|
||||
|
||||
# Validate team encounter IDs belong to this run
|
||||
if data.team:
|
||||
encounter_ids = [t.encounter_id for t in data.team]
|
||||
enc_result = await session.execute(
|
||||
select(Encounter).where(
|
||||
Encounter.id.in_(encounter_ids), Encounter.run_id == run_id
|
||||
)
|
||||
)
|
||||
found_encounters = {e.id for e in enc_result.scalars().all()}
|
||||
missing = [eid for eid in encounter_ids if eid not in found_encounters]
|
||||
if missing:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Encounters not found in this run: {missing}",
|
||||
)
|
||||
|
||||
# Check for existing result (upsert)
|
||||
existing = await session.execute(
|
||||
select(BossResult).where(
|
||||
select(BossResult)
|
||||
.where(
|
||||
BossResult.run_id == run_id,
|
||||
BossResult.boss_battle_id == data.boss_battle_id,
|
||||
)
|
||||
.options(selectinload(BossResult.team))
|
||||
)
|
||||
result = existing.scalar_one_or_none()
|
||||
|
||||
@@ -335,6 +385,10 @@ async def create_boss_result(
|
||||
result.result = data.result
|
||||
result.attempts = data.attempts
|
||||
result.completed_at = datetime.now(UTC) if data.result == "won" else None
|
||||
# Clear existing team and add new
|
||||
for tm in result.team:
|
||||
await session.delete(tm)
|
||||
await session.flush()
|
||||
else:
|
||||
result = BossResult(
|
||||
run_id=run_id,
|
||||
@@ -344,10 +398,26 @@ async def create_boss_result(
|
||||
completed_at=datetime.now(UTC) if data.result == "won" else None,
|
||||
)
|
||||
session.add(result)
|
||||
await session.flush()
|
||||
|
||||
# Add team members
|
||||
for tm in data.team:
|
||||
team_member = BossResultTeam(
|
||||
boss_result_id=result.id,
|
||||
encounter_id=tm.encounter_id,
|
||||
level=tm.level,
|
||||
)
|
||||
session.add(team_member)
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(result)
|
||||
return result
|
||||
|
||||
# Re-fetch with team loaded
|
||||
fresh = await session.execute(
|
||||
select(BossResult)
|
||||
.where(BossResult.id == result.id)
|
||||
.options(selectinload(BossResult.team))
|
||||
)
|
||||
return fresh.scalar_one()
|
||||
|
||||
|
||||
@router.delete("/runs/{run_id}/boss-results/{result_id}", status_code=204)
|
||||
@@ -355,6 +425,7 @@ async def delete_boss_result(
|
||||
run_id: int,
|
||||
result_id: int,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
_user: AuthUser = Depends(require_auth),
|
||||
):
|
||||
result = await session.execute(
|
||||
select(BossResult).where(
|
||||
|
||||
@@ -5,6 +5,7 @@ from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import joinedload, selectinload
|
||||
|
||||
from app.core.auth import AuthUser, require_auth
|
||||
from app.core.database import get_session
|
||||
from app.models.encounter import Encounter
|
||||
from app.models.evolution import Evolution
|
||||
@@ -35,6 +36,7 @@ async def create_encounter(
|
||||
run_id: int,
|
||||
data: EncounterCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
_user: AuthUser = Depends(require_auth),
|
||||
):
|
||||
# Validate run exists
|
||||
run = await session.get(NuzlockeRun, run_id)
|
||||
@@ -137,6 +139,7 @@ async def update_encounter(
|
||||
encounter_id: int,
|
||||
data: EncounterUpdate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
_user: AuthUser = Depends(require_auth),
|
||||
):
|
||||
encounter = await session.get(Encounter, encounter_id)
|
||||
if encounter is None:
|
||||
@@ -163,7 +166,9 @@ async def update_encounter(
|
||||
|
||||
@router.delete("/encounters/{encounter_id}", status_code=204)
|
||||
async def delete_encounter(
|
||||
encounter_id: int, session: AsyncSession = Depends(get_session)
|
||||
encounter_id: int,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
_user: AuthUser = Depends(require_auth),
|
||||
):
|
||||
encounter = await session.get(Encounter, encounter_id)
|
||||
if encounter is None:
|
||||
@@ -195,6 +200,7 @@ async def delete_encounter(
|
||||
async def bulk_randomize_encounters(
|
||||
run_id: int,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
_user: AuthUser = Depends(require_auth),
|
||||
):
|
||||
# 1. Validate run
|
||||
run = await session.get(NuzlockeRun, run_id)
|
||||
|
||||
@@ -6,6 +6,7 @@ from sqlalchemy import delete, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.core.auth import AuthUser, require_auth
|
||||
from app.core.database import get_session
|
||||
from app.models.boss_battle import BossBattle
|
||||
from app.models.game import Game
|
||||
@@ -228,7 +229,11 @@ async def list_game_routes(
|
||||
|
||||
|
||||
@router.post("", response_model=GameResponse, status_code=201)
|
||||
async def create_game(data: GameCreate, session: AsyncSession = Depends(get_session)):
|
||||
async def create_game(
|
||||
data: GameCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
_user: AuthUser = Depends(require_auth),
|
||||
):
|
||||
existing = await session.execute(select(Game).where(Game.slug == data.slug))
|
||||
if existing.scalar_one_or_none() is not None:
|
||||
raise HTTPException(
|
||||
@@ -244,7 +249,10 @@ async def create_game(data: GameCreate, session: AsyncSession = Depends(get_sess
|
||||
|
||||
@router.put("/{game_id}", response_model=GameResponse)
|
||||
async def update_game(
|
||||
game_id: int, data: GameUpdate, session: AsyncSession = Depends(get_session)
|
||||
game_id: int,
|
||||
data: GameUpdate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
_user: AuthUser = Depends(require_auth),
|
||||
):
|
||||
game = await session.get(Game, game_id)
|
||||
if game is None:
|
||||
@@ -269,7 +277,11 @@ async def update_game(
|
||||
|
||||
|
||||
@router.delete("/{game_id}", status_code=204)
|
||||
async def delete_game(game_id: int, session: AsyncSession = Depends(get_session)):
|
||||
async def delete_game(
|
||||
game_id: int,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
_user: AuthUser = Depends(require_auth),
|
||||
):
|
||||
result = await session.execute(
|
||||
select(Game).where(Game.id == game_id).options(selectinload(Game.runs))
|
||||
)
|
||||
@@ -323,7 +335,10 @@ async def delete_game(game_id: int, session: AsyncSession = Depends(get_session)
|
||||
|
||||
@router.post("/{game_id}/routes", response_model=RouteResponse, status_code=201)
|
||||
async def create_route(
|
||||
game_id: int, data: RouteCreate, session: AsyncSession = Depends(get_session)
|
||||
game_id: int,
|
||||
data: RouteCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
_user: AuthUser = Depends(require_auth),
|
||||
):
|
||||
vg_id = await _get_version_group_id(session, game_id)
|
||||
|
||||
@@ -339,6 +354,7 @@ async def reorder_routes(
|
||||
game_id: int,
|
||||
data: RouteReorderRequest,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
_user: AuthUser = Depends(require_auth),
|
||||
):
|
||||
vg_id = await _get_version_group_id(session, game_id)
|
||||
|
||||
@@ -365,6 +381,7 @@ async def update_route(
|
||||
route_id: int,
|
||||
data: RouteUpdate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
_user: AuthUser = Depends(require_auth),
|
||||
):
|
||||
vg_id = await _get_version_group_id(session, game_id)
|
||||
|
||||
@@ -385,6 +402,7 @@ async def delete_route(
|
||||
game_id: int,
|
||||
route_id: int,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
_user: AuthUser = Depends(require_auth),
|
||||
):
|
||||
vg_id = await _get_version_group_id(session, game_id)
|
||||
|
||||
@@ -419,6 +437,7 @@ async def bulk_import_routes(
|
||||
game_id: int,
|
||||
items: list[BulkRouteItem],
|
||||
session: AsyncSession = Depends(get_session),
|
||||
_user: AuthUser = Depends(require_auth),
|
||||
):
|
||||
vg_id = await _get_version_group_id(session, game_id)
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ from sqlalchemy import update as sa_update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.core.auth import AuthUser, require_auth
|
||||
from app.core.database import get_session
|
||||
from app.models.encounter import Encounter
|
||||
from app.models.evolution import Evolution
|
||||
@@ -437,7 +438,9 @@ async def get_genlocke_lineages(
|
||||
|
||||
@router.post("", response_model=GenlockeResponse, status_code=201)
|
||||
async def create_genlocke(
|
||||
data: GenlockeCreate, session: AsyncSession = Depends(get_session)
|
||||
data: GenlockeCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
_user: AuthUser = Depends(require_auth),
|
||||
):
|
||||
if not data.game_ids:
|
||||
raise HTTPException(status_code=400, detail="At least one game is required")
|
||||
@@ -568,6 +571,7 @@ async def advance_leg(
|
||||
leg_order: int,
|
||||
data: AdvanceLegRequest | None = None,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
_user: AuthUser = Depends(require_auth),
|
||||
):
|
||||
# Load genlocke with legs
|
||||
result = await session.execute(
|
||||
@@ -822,6 +826,7 @@ async def update_genlocke(
|
||||
genlocke_id: int,
|
||||
data: GenlockeUpdate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
_user: AuthUser = Depends(require_auth),
|
||||
):
|
||||
result = await session.execute(
|
||||
select(Genlocke)
|
||||
@@ -858,6 +863,7 @@ async def update_genlocke(
|
||||
async def delete_genlocke(
|
||||
genlocke_id: int,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
_user: AuthUser = Depends(require_auth),
|
||||
):
|
||||
genlocke = await session.get(Genlocke, genlocke_id)
|
||||
if genlocke is None:
|
||||
@@ -889,6 +895,7 @@ async def add_leg(
|
||||
genlocke_id: int,
|
||||
data: AddLegRequest,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
_user: AuthUser = Depends(require_auth),
|
||||
):
|
||||
genlocke = await session.get(Genlocke, genlocke_id)
|
||||
if genlocke is None:
|
||||
@@ -931,6 +938,7 @@ async def remove_leg(
|
||||
genlocke_id: int,
|
||||
leg_id: int,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
_user: AuthUser = Depends(require_auth),
|
||||
):
|
||||
result = await session.execute(
|
||||
select(GenlockeLeg).where(
|
||||
|
||||
@@ -5,6 +5,7 @@ from fastapi import APIRouter, Depends, HTTPException, Response
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.auth import AuthUser, require_auth
|
||||
from app.core.database import get_session
|
||||
from app.models.boss_result import BossResult
|
||||
from app.models.journal_entry import JournalEntry
|
||||
@@ -45,6 +46,7 @@ async def create_journal_entry(
|
||||
run_id: int,
|
||||
data: JournalEntryCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
_user: AuthUser = Depends(require_auth),
|
||||
):
|
||||
# Validate run exists
|
||||
run = await session.get(NuzlockeRun, run_id)
|
||||
@@ -97,6 +99,7 @@ async def update_journal_entry(
|
||||
entry_id: UUID,
|
||||
data: JournalEntryUpdate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
_user: AuthUser = Depends(require_auth),
|
||||
):
|
||||
result = await session.execute(
|
||||
select(JournalEntry).where(
|
||||
@@ -135,6 +138,7 @@ async def delete_journal_entry(
|
||||
run_id: int,
|
||||
entry_id: UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
_user: AuthUser = Depends(require_auth),
|
||||
):
|
||||
result = await session.execute(
|
||||
select(JournalEntry).where(
|
||||
|
||||
95
backend/src/app/api/moves_abilities.py
Normal file
95
backend/src/app/api/moves_abilities.py
Normal file
@@ -0,0 +1,95 @@
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_session
|
||||
from app.models.ability import Ability
|
||||
from app.models.move import Move
|
||||
from app.schemas.move import (
|
||||
AbilityResponse,
|
||||
MoveResponse,
|
||||
PaginatedAbilityResponse,
|
||||
PaginatedMoveResponse,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/moves", response_model=PaginatedMoveResponse)
|
||||
async def list_moves(
|
||||
search: str | None = None,
|
||||
limit: int = Query(default=20, ge=1, le=100),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
query = select(Move)
|
||||
|
||||
if search:
|
||||
query = query.where(Move.name.ilike(f"%{search}%"))
|
||||
|
||||
query = query.order_by(Move.name).offset(offset).limit(limit)
|
||||
result = await session.execute(query)
|
||||
items = result.scalars().all()
|
||||
|
||||
# Count total
|
||||
count_query = select(func.count()).select_from(Move)
|
||||
if search:
|
||||
count_query = count_query.where(Move.name.ilike(f"%{search}%"))
|
||||
total_result = await session.execute(count_query)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
return PaginatedMoveResponse(items=items, total=total, limit=limit, offset=offset)
|
||||
|
||||
|
||||
@router.get("/moves/{move_id}", response_model=MoveResponse)
|
||||
async def get_move(
|
||||
move_id: int,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
move = await session.get(Move, move_id)
|
||||
if move is None:
|
||||
from fastapi import HTTPException
|
||||
|
||||
raise HTTPException(status_code=404, detail="Move not found")
|
||||
return move
|
||||
|
||||
|
||||
@router.get("/abilities", response_model=PaginatedAbilityResponse)
|
||||
async def list_abilities(
|
||||
search: str | None = None,
|
||||
limit: int = Query(default=20, ge=1, le=100),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
query = select(Ability)
|
||||
|
||||
if search:
|
||||
query = query.where(Ability.name.ilike(f"%{search}%"))
|
||||
|
||||
query = query.order_by(Ability.name).offset(offset).limit(limit)
|
||||
result = await session.execute(query)
|
||||
items = result.scalars().all()
|
||||
|
||||
# Count total
|
||||
count_query = select(func.count()).select_from(Ability)
|
||||
if search:
|
||||
count_query = count_query.where(Ability.name.ilike(f"%{search}%"))
|
||||
total_result = await session.execute(count_query)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
return PaginatedAbilityResponse(
|
||||
items=items, total=total, limit=limit, offset=offset
|
||||
)
|
||||
|
||||
|
||||
@router.get("/abilities/{ability_id}", response_model=AbilityResponse)
|
||||
async def get_ability(
|
||||
ability_id: int,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
ability = await session.get(Ability, ability_id)
|
||||
if ability is None:
|
||||
from fastapi import HTTPException
|
||||
|
||||
raise HTTPException(status_code=404, detail="Ability not found")
|
||||
return ability
|
||||
@@ -9,13 +9,16 @@ from app.api import (
|
||||
genlockes,
|
||||
health,
|
||||
journal_entries,
|
||||
moves_abilities,
|
||||
pokemon,
|
||||
runs,
|
||||
stats,
|
||||
users,
|
||||
)
|
||||
|
||||
api_router = APIRouter()
|
||||
api_router.include_router(health.router)
|
||||
api_router.include_router(users.router, prefix="/users", tags=["users"])
|
||||
api_router.include_router(games.router, prefix="/games", tags=["games"])
|
||||
api_router.include_router(pokemon.router, tags=["pokemon"])
|
||||
api_router.include_router(evolutions.router, tags=["evolutions"])
|
||||
@@ -25,4 +28,5 @@ api_router.include_router(genlockes.router, prefix="/genlockes", tags=["genlocke
|
||||
api_router.include_router(encounters.router, tags=["encounters"])
|
||||
api_router.include_router(stats.router, prefix="/stats", tags=["stats"])
|
||||
api_router.include_router(bosses.router, tags=["bosses"])
|
||||
api_router.include_router(moves_abilities.router, tags=["moves", "abilities"])
|
||||
api_router.include_router(export.router, prefix="/export", tags=["export"])
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
from datetime import UTC, datetime
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import joinedload, selectinload
|
||||
|
||||
from app.core.auth import AuthUser, get_current_user, require_auth
|
||||
from app.core.database import get_session
|
||||
from app.models.boss_result import BossResult
|
||||
from app.models.encounter import Encounter
|
||||
@@ -12,8 +14,10 @@ from app.models.evolution import Evolution
|
||||
from app.models.game import Game
|
||||
from app.models.genlocke import GenlockeLeg
|
||||
from app.models.genlocke_transfer import GenlockeTransfer
|
||||
from app.models.nuzlocke_run import NuzlockeRun
|
||||
from app.models.nuzlocke_run import NuzlockeRun, RunVisibility
|
||||
from app.models.user import User
|
||||
from app.schemas.run import (
|
||||
OwnerResponse,
|
||||
RunCreate,
|
||||
RunDetailResponse,
|
||||
RunGenlockeContext,
|
||||
@@ -157,41 +161,136 @@ async def _compute_lineage_suggestion(
|
||||
return f"{base_name} {numeral}"
|
||||
|
||||
|
||||
def _build_run_response(run: NuzlockeRun) -> RunResponse:
|
||||
"""Build RunResponse with owner info if present."""
|
||||
owner = None
|
||||
if run.owner:
|
||||
owner = OwnerResponse(id=run.owner.id, display_name=run.owner.display_name)
|
||||
return RunResponse(
|
||||
id=run.id,
|
||||
game_id=run.game_id,
|
||||
name=run.name,
|
||||
status=run.status,
|
||||
rules=run.rules,
|
||||
hof_encounter_ids=run.hof_encounter_ids,
|
||||
naming_scheme=run.naming_scheme,
|
||||
visibility=run.visibility,
|
||||
owner=owner,
|
||||
started_at=run.started_at,
|
||||
completed_at=run.completed_at,
|
||||
)
|
||||
|
||||
|
||||
def _check_run_access(
|
||||
run: NuzlockeRun, user: AuthUser | None, require_owner: bool = False
|
||||
) -> None:
|
||||
"""
|
||||
Check if user can access the run.
|
||||
Raises 403 for private runs if user is not owner.
|
||||
If require_owner=True, always requires ownership (for mutations).
|
||||
"""
|
||||
if run.owner_id is None:
|
||||
# Unowned runs are accessible by everyone (legacy)
|
||||
if require_owner:
|
||||
raise HTTPException(
|
||||
status_code=403, detail="Only the run owner can perform this action"
|
||||
)
|
||||
return
|
||||
|
||||
user_id = UUID(user.id) if user else None
|
||||
|
||||
if require_owner:
|
||||
if user_id != run.owner_id:
|
||||
raise HTTPException(
|
||||
status_code=403, detail="Only the run owner can perform this action"
|
||||
)
|
||||
return
|
||||
|
||||
if run.visibility == RunVisibility.PRIVATE and user_id != run.owner_id:
|
||||
raise HTTPException(status_code=403, detail="This run is private")
|
||||
|
||||
|
||||
@router.post("", response_model=RunResponse, status_code=201)
|
||||
async def create_run(data: RunCreate, session: AsyncSession = Depends(get_session)):
|
||||
async def create_run(
|
||||
data: RunCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
user: AuthUser = Depends(require_auth),
|
||||
):
|
||||
# Validate game exists
|
||||
game = await session.get(Game, data.game_id)
|
||||
if game is None:
|
||||
raise HTTPException(status_code=404, detail="Game not found")
|
||||
|
||||
# Ensure user exists in local DB
|
||||
user_id = UUID(user.id)
|
||||
db_user = await session.get(User, user_id)
|
||||
if db_user is None:
|
||||
db_user = User(id=user_id, email=user.email or "")
|
||||
session.add(db_user)
|
||||
|
||||
run = NuzlockeRun(
|
||||
game_id=data.game_id,
|
||||
owner_id=user_id,
|
||||
name=data.name,
|
||||
status="active",
|
||||
visibility=data.visibility,
|
||||
rules=data.rules,
|
||||
naming_scheme=data.naming_scheme,
|
||||
)
|
||||
session.add(run)
|
||||
await session.commit()
|
||||
await session.refresh(run)
|
||||
return run
|
||||
|
||||
# Reload with owner relationship
|
||||
result = await session.execute(
|
||||
select(NuzlockeRun)
|
||||
.where(NuzlockeRun.id == run.id)
|
||||
.options(joinedload(NuzlockeRun.owner))
|
||||
)
|
||||
run = result.scalar_one()
|
||||
return _build_run_response(run)
|
||||
|
||||
|
||||
@router.get("", response_model=list[RunResponse])
|
||||
async def list_runs(session: AsyncSession = Depends(get_session)):
|
||||
result = await session.execute(
|
||||
select(NuzlockeRun).order_by(NuzlockeRun.started_at.desc())
|
||||
)
|
||||
return result.scalars().all()
|
||||
async def list_runs(
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
user: AuthUser | None = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
List runs. Shows public runs and user's own private runs.
|
||||
"""
|
||||
query = select(NuzlockeRun).options(joinedload(NuzlockeRun.owner))
|
||||
|
||||
if user:
|
||||
user_id = UUID(user.id)
|
||||
# Show public runs OR runs owned by current user
|
||||
query = query.where(
|
||||
(NuzlockeRun.visibility == RunVisibility.PUBLIC)
|
||||
| (NuzlockeRun.owner_id == user_id)
|
||||
)
|
||||
else:
|
||||
# Anonymous: only public runs
|
||||
query = query.where(NuzlockeRun.visibility == RunVisibility.PUBLIC)
|
||||
|
||||
query = query.order_by(NuzlockeRun.started_at.desc())
|
||||
result = await session.execute(query)
|
||||
runs = result.scalars().all()
|
||||
return [_build_run_response(run) for run in runs]
|
||||
|
||||
|
||||
@router.get("/{run_id}", response_model=RunDetailResponse)
|
||||
async def get_run(run_id: int, session: AsyncSession = Depends(get_session)):
|
||||
async def get_run(
|
||||
run_id: int,
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
user: AuthUser | None = Depends(get_current_user),
|
||||
):
|
||||
result = await session.execute(
|
||||
select(NuzlockeRun)
|
||||
.where(NuzlockeRun.id == run_id)
|
||||
.options(
|
||||
joinedload(NuzlockeRun.game),
|
||||
joinedload(NuzlockeRun.owner),
|
||||
selectinload(NuzlockeRun.encounters).joinedload(Encounter.pokemon),
|
||||
selectinload(NuzlockeRun.encounters).joinedload(Encounter.current_pokemon),
|
||||
selectinload(NuzlockeRun.encounters).joinedload(Encounter.route),
|
||||
@@ -201,6 +300,9 @@ async def get_run(run_id: int, session: AsyncSession = Depends(get_session)):
|
||||
if run is None:
|
||||
raise HTTPException(status_code=404, detail="Run not found")
|
||||
|
||||
# Check visibility access
|
||||
_check_run_access(run, user)
|
||||
|
||||
# Check if this run belongs to a genlocke
|
||||
genlocke_context = None
|
||||
leg_result = await session.execute(
|
||||
@@ -262,11 +364,20 @@ async def update_run(
|
||||
run_id: int,
|
||||
data: RunUpdate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
user: AuthUser = Depends(require_auth),
|
||||
):
|
||||
run = await session.get(NuzlockeRun, run_id)
|
||||
result = await session.execute(
|
||||
select(NuzlockeRun)
|
||||
.where(NuzlockeRun.id == run_id)
|
||||
.options(joinedload(NuzlockeRun.owner))
|
||||
)
|
||||
run = result.scalar_one_or_none()
|
||||
if run is None:
|
||||
raise HTTPException(status_code=404, detail="Run not found")
|
||||
|
||||
# Check ownership for mutations (unowned runs allow anyone for backwards compat)
|
||||
_check_run_access(run, user, require_owner=run.owner_id is not None)
|
||||
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
|
||||
# Validate hof_encounter_ids if provided
|
||||
@@ -352,16 +463,30 @@ async def update_run(
|
||||
genlocke.status = "completed"
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(run)
|
||||
return run
|
||||
|
||||
# Reload with owner relationship
|
||||
result = await session.execute(
|
||||
select(NuzlockeRun)
|
||||
.where(NuzlockeRun.id == run.id)
|
||||
.options(joinedload(NuzlockeRun.owner))
|
||||
)
|
||||
run = result.scalar_one()
|
||||
return _build_run_response(run)
|
||||
|
||||
|
||||
@router.delete("/{run_id}", status_code=204)
|
||||
async def delete_run(run_id: int, session: AsyncSession = Depends(get_session)):
|
||||
async def delete_run(
|
||||
run_id: int,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
user: AuthUser = Depends(require_auth),
|
||||
):
|
||||
run = await session.get(NuzlockeRun, run_id)
|
||||
if run is None:
|
||||
raise HTTPException(status_code=404, detail="Run not found")
|
||||
|
||||
# Check ownership for deletion (unowned runs allow anyone for backwards compat)
|
||||
_check_run_access(run, user, require_owner=run.owner_id is not None)
|
||||
|
||||
# Block deletion if run is linked to a genlocke leg
|
||||
leg_result = await session.execute(
|
||||
select(GenlockeLeg).where(GenlockeLeg.run_id == run_id)
|
||||
|
||||
106
backend/src/app/api/users.py
Normal file
106
backend/src/app/api/users.py
Normal file
@@ -0,0 +1,106 @@
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.auth import AuthUser, require_auth
|
||||
from app.core.database import get_session
|
||||
from app.models.user import User
|
||||
from app.schemas.base import CamelModel
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class UserResponse(CamelModel):
|
||||
id: UUID
|
||||
email: str
|
||||
display_name: str | None = None
|
||||
|
||||
|
||||
@router.post("/me", response_model=UserResponse)
|
||||
async def sync_current_user(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
auth_user: AuthUser = Depends(require_auth),
|
||||
):
|
||||
"""
|
||||
Sync the current authenticated user from Supabase to local DB.
|
||||
Creates user on first login, updates email if changed.
|
||||
"""
|
||||
user_id = UUID(auth_user.id)
|
||||
|
||||
result = await session.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if user is None:
|
||||
# First login - create user record
|
||||
user = User(
|
||||
id=user_id,
|
||||
email=auth_user.email or "",
|
||||
display_name=None,
|
||||
)
|
||||
session.add(user)
|
||||
elif auth_user.email and user.email != auth_user.email:
|
||||
# Email changed in Supabase - update local record
|
||||
user.email = auth_user.email
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserResponse)
|
||||
async def get_current_user(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
auth_user: AuthUser = Depends(require_auth),
|
||||
):
|
||||
"""Get the current authenticated user's profile."""
|
||||
user_id = UUID(auth_user.id)
|
||||
|
||||
result = await session.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if user is None:
|
||||
# Auto-create if not exists (shouldn't happen if /me POST is called on login)
|
||||
user = User(
|
||||
id=user_id,
|
||||
email=auth_user.email or "",
|
||||
display_name=None,
|
||||
)
|
||||
session.add(user)
|
||||
await session.commit()
|
||||
await session.refresh(user)
|
||||
|
||||
return user
|
||||
|
||||
|
||||
class UserUpdateRequest(CamelModel):
|
||||
display_name: str | None = None
|
||||
|
||||
|
||||
@router.patch("/me", response_model=UserResponse)
|
||||
async def update_current_user(
|
||||
data: UserUpdateRequest,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
auth_user: AuthUser = Depends(require_auth),
|
||||
):
|
||||
"""Update the current user's profile (display name)."""
|
||||
user_id = UUID(auth_user.id)
|
||||
|
||||
result = await session.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if user is None:
|
||||
user = User(
|
||||
id=user_id,
|
||||
email=auth_user.email or "",
|
||||
display_name=data.display_name,
|
||||
)
|
||||
session.add(user)
|
||||
else:
|
||||
if data.display_name is not None:
|
||||
user.display_name = data.display_name
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(user)
|
||||
return user
|
||||
83
backend/src/app/core/auth.py
Normal file
83
backend/src/app/core/auth.py
Normal file
@@ -0,0 +1,83 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
import jwt
|
||||
from fastapi import Depends, HTTPException, Request, status
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
@dataclass
|
||||
class AuthUser:
|
||||
"""Authenticated user info extracted from JWT."""
|
||||
|
||||
id: str # Supabase user UUID
|
||||
email: str | None = None
|
||||
role: str | None = None
|
||||
|
||||
|
||||
def _extract_token(request: Request) -> str | None:
|
||||
"""Extract Bearer token from Authorization header."""
|
||||
auth_header = request.headers.get("Authorization")
|
||||
if not auth_header:
|
||||
return None
|
||||
parts = auth_header.split()
|
||||
if len(parts) != 2 or parts[0].lower() != "bearer":
|
||||
return None
|
||||
return parts[1]
|
||||
|
||||
|
||||
def _verify_jwt(token: str) -> dict | None:
|
||||
"""Verify JWT against Supabase JWT secret. Returns payload or None."""
|
||||
if not settings.supabase_jwt_secret:
|
||||
return None
|
||||
try:
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
settings.supabase_jwt_secret,
|
||||
algorithms=["HS256"],
|
||||
audience="authenticated",
|
||||
)
|
||||
return payload
|
||||
except jwt.ExpiredSignatureError:
|
||||
return None
|
||||
except jwt.InvalidTokenError:
|
||||
return None
|
||||
|
||||
|
||||
def get_current_user(request: Request) -> AuthUser | None:
|
||||
"""
|
||||
Extract and verify the current user from the request.
|
||||
Returns AuthUser if valid token, None otherwise.
|
||||
"""
|
||||
token = _extract_token(request)
|
||||
if not token:
|
||||
return None
|
||||
|
||||
payload = _verify_jwt(token)
|
||||
if not payload:
|
||||
return None
|
||||
|
||||
# Supabase JWT has 'sub' as user ID
|
||||
user_id = payload.get("sub")
|
||||
if not user_id:
|
||||
return None
|
||||
|
||||
return AuthUser(
|
||||
id=user_id,
|
||||
email=payload.get("email"),
|
||||
role=payload.get("role"),
|
||||
)
|
||||
|
||||
|
||||
def require_auth(user: AuthUser | None = Depends(get_current_user)) -> AuthUser:
|
||||
"""
|
||||
Dependency that requires authentication.
|
||||
Raises 401 if no valid token is present.
|
||||
"""
|
||||
if user is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Authentication required",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
return user
|
||||
@@ -17,5 +17,10 @@ class Settings(BaseSettings):
|
||||
# Database settings
|
||||
database_url: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/nuzlocke"
|
||||
|
||||
# Supabase Auth
|
||||
supabase_url: str | None = None
|
||||
supabase_anon_key: str | None = None
|
||||
supabase_jwt_secret: str | None = None
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
||||
@@ -2,6 +2,7 @@ from app.models.ability import Ability
|
||||
from app.models.boss_battle import BossBattle
|
||||
from app.models.boss_pokemon import BossPokemon
|
||||
from app.models.boss_result import BossResult
|
||||
from app.models.boss_result_team import BossResultTeam
|
||||
from app.models.encounter import Encounter
|
||||
from app.models.evolution import Evolution
|
||||
from app.models.game import Game
|
||||
@@ -13,6 +14,7 @@ from app.models.nuzlocke_run import NuzlockeRun
|
||||
from app.models.pokemon import Pokemon
|
||||
from app.models.route import Route
|
||||
from app.models.route_encounter import RouteEncounter
|
||||
from app.models.user import User
|
||||
from app.models.version_group import VersionGroup
|
||||
|
||||
__all__ = [
|
||||
@@ -20,6 +22,7 @@ __all__ = [
|
||||
"BossBattle",
|
||||
"BossPokemon",
|
||||
"BossResult",
|
||||
"BossResultTeam",
|
||||
"Encounter",
|
||||
"Evolution",
|
||||
"Game",
|
||||
@@ -32,5 +35,6 @@ __all__ = [
|
||||
"Pokemon",
|
||||
"Route",
|
||||
"RouteEncounter",
|
||||
"User",
|
||||
"VersionGroup",
|
||||
]
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import ForeignKey, SmallInteger, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.ability import Ability
|
||||
from app.models.boss_battle import BossBattle
|
||||
from app.models.move import Move
|
||||
from app.models.pokemon import Pokemon
|
||||
|
||||
|
||||
class BossPokemon(Base):
|
||||
__tablename__ = "boss_pokemon"
|
||||
@@ -16,8 +26,24 @@ class BossPokemon(Base):
|
||||
order: Mapped[int] = mapped_column(SmallInteger)
|
||||
condition_label: Mapped[str | None] = mapped_column(String(100))
|
||||
|
||||
# Detail fields
|
||||
ability_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("abilities.id"), index=True
|
||||
)
|
||||
held_item: Mapped[str | None] = mapped_column(String(50))
|
||||
nature: Mapped[str | None] = mapped_column(String(20))
|
||||
move1_id: Mapped[int | None] = mapped_column(ForeignKey("moves.id"), index=True)
|
||||
move2_id: Mapped[int | None] = mapped_column(ForeignKey("moves.id"), index=True)
|
||||
move3_id: Mapped[int | None] = mapped_column(ForeignKey("moves.id"), index=True)
|
||||
move4_id: Mapped[int | None] = mapped_column(ForeignKey("moves.id"), index=True)
|
||||
|
||||
boss_battle: Mapped[BossBattle] = relationship(back_populates="pokemon")
|
||||
pokemon: Mapped[Pokemon] = relationship()
|
||||
ability: Mapped[Ability | None] = relationship()
|
||||
move1: Mapped[Move | None] = relationship(foreign_keys=[move1_id])
|
||||
move2: Mapped[Move | None] = relationship(foreign_keys=[move2_id])
|
||||
move3: Mapped[Move | None] = relationship(foreign_keys=[move3_id])
|
||||
move4: Mapped[Move | None] = relationship(foreign_keys=[move4_id])
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<BossPokemon(id={self.id}, boss_battle_id={self.boss_battle_id}, pokemon_id={self.pokemon_id})>"
|
||||
|
||||
@@ -25,6 +25,12 @@ class BossResult(Base):
|
||||
|
||||
run: Mapped[NuzlockeRun] = relationship(back_populates="boss_results")
|
||||
boss_battle: Mapped[BossBattle] = relationship()
|
||||
team: Mapped[list[BossResultTeam]] = relationship(
|
||||
back_populates="boss_result", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<BossResult(id={self.id}, run_id={self.run_id}, boss_battle_id={self.boss_battle_id}, result='{self.result}')>"
|
||||
return (
|
||||
f"<BossResult(id={self.id}, run_id={self.run_id}, "
|
||||
f"boss_battle_id={self.boss_battle_id}, result='{self.result}')>"
|
||||
)
|
||||
|
||||
26
backend/src/app/models/boss_result_team.py
Normal file
26
backend/src/app/models/boss_result_team.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from sqlalchemy import ForeignKey, SmallInteger
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class BossResultTeam(Base):
|
||||
__tablename__ = "boss_result_team"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
boss_result_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("boss_results.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
encounter_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("encounters.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
level: Mapped[int] = mapped_column(SmallInteger)
|
||||
|
||||
boss_result: Mapped[BossResult] = relationship(back_populates="team")
|
||||
encounter: Mapped[Encounter] = relationship()
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"<BossResultTeam(id={self.id}, boss_result_id={self.boss_result_id}, "
|
||||
f"encounter_id={self.encounter_id}, level={self.level})>"
|
||||
)
|
||||
@@ -1,21 +1,46 @@
|
||||
from datetime import datetime
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, String, func
|
||||
from datetime import datetime
|
||||
from enum import StrEnum
|
||||
from typing import TYPE_CHECKING
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import DateTime, Enum, ForeignKey, String, func
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.boss_result import BossResult
|
||||
from app.models.encounter import Encounter
|
||||
from app.models.game import Game
|
||||
from app.models.journal_entry import JournalEntry
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
class RunVisibility(StrEnum):
|
||||
PUBLIC = "public"
|
||||
PRIVATE = "private"
|
||||
|
||||
|
||||
class NuzlockeRun(Base):
|
||||
__tablename__ = "nuzlocke_runs"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
game_id: Mapped[int] = mapped_column(ForeignKey("games.id"), index=True)
|
||||
owner_id: Mapped[UUID | None] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="SET NULL"), index=True
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(100))
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(20), index=True
|
||||
) # active, completed, failed
|
||||
visibility: Mapped[RunVisibility] = mapped_column(
|
||||
Enum(RunVisibility, name="run_visibility", create_constraint=False),
|
||||
default=RunVisibility.PUBLIC,
|
||||
server_default="public",
|
||||
)
|
||||
rules: Mapped[dict] = mapped_column(JSONB, default=dict)
|
||||
started_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
@@ -25,6 +50,7 @@ class NuzlockeRun(Base):
|
||||
naming_scheme: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
|
||||
game: Mapped[Game] = relationship(back_populates="runs")
|
||||
owner: Mapped[User | None] = relationship(back_populates="runs")
|
||||
encounters: Mapped[list[Encounter]] = relationship(back_populates="run")
|
||||
boss_results: Mapped[list[BossResult]] = relationship(back_populates="run")
|
||||
journal_entries: Mapped[list[JournalEntry]] = relationship(back_populates="run")
|
||||
|
||||
29
backend/src/app/models/user.py
Normal file
29
backend/src/app/models/user.py
Normal file
@@ -0,0 +1,29 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import DateTime, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.nuzlocke_run import NuzlockeRun
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[UUID] = mapped_column(primary_key=True)
|
||||
email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
|
||||
display_name: Mapped[str | None] = mapped_column(String(100))
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
)
|
||||
|
||||
runs: Mapped[list[NuzlockeRun]] = relationship(back_populates="owner")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<User(id={self.id}, email='{self.email}')>"
|
||||
@@ -4,6 +4,16 @@ from app.schemas.base import CamelModel
|
||||
from app.schemas.pokemon import PokemonResponse
|
||||
|
||||
|
||||
class MoveRef(CamelModel):
|
||||
id: int
|
||||
name: str
|
||||
|
||||
|
||||
class AbilityRef(CamelModel):
|
||||
id: int
|
||||
name: str
|
||||
|
||||
|
||||
class BossPokemonResponse(CamelModel):
|
||||
id: int
|
||||
pokemon_id: int
|
||||
@@ -11,6 +21,19 @@ class BossPokemonResponse(CamelModel):
|
||||
order: int
|
||||
condition_label: str | None
|
||||
pokemon: PokemonResponse
|
||||
# Detail fields
|
||||
ability_id: int | None = None
|
||||
ability: AbilityRef | None = None
|
||||
held_item: str | None = None
|
||||
nature: str | None = None
|
||||
move1_id: int | None = None
|
||||
move2_id: int | None = None
|
||||
move3_id: int | None = None
|
||||
move4_id: int | None = None
|
||||
move1: MoveRef | None = None
|
||||
move2: MoveRef | None = None
|
||||
move3: MoveRef | None = None
|
||||
move4: MoveRef | None = None
|
||||
|
||||
|
||||
class BossBattleResponse(CamelModel):
|
||||
@@ -31,6 +54,12 @@ class BossBattleResponse(CamelModel):
|
||||
pokemon: list[BossPokemonResponse] = []
|
||||
|
||||
|
||||
class BossResultTeamMemberResponse(CamelModel):
|
||||
id: int
|
||||
encounter_id: int
|
||||
level: int
|
||||
|
||||
|
||||
class BossResultResponse(CamelModel):
|
||||
id: int
|
||||
run_id: int
|
||||
@@ -38,6 +67,7 @@ class BossResultResponse(CamelModel):
|
||||
result: str
|
||||
attempts: int
|
||||
completed_at: datetime | None
|
||||
team: list[BossResultTeamMemberResponse] = []
|
||||
|
||||
|
||||
# --- Input schemas ---
|
||||
@@ -78,12 +108,26 @@ class BossPokemonInput(CamelModel):
|
||||
level: int
|
||||
order: int
|
||||
condition_label: str | None = None
|
||||
# Detail fields
|
||||
ability_id: int | None = None
|
||||
held_item: str | None = None
|
||||
nature: str | None = None
|
||||
move1_id: int | None = None
|
||||
move2_id: int | None = None
|
||||
move3_id: int | None = None
|
||||
move4_id: int | None = None
|
||||
|
||||
|
||||
class BossResultTeamMemberInput(CamelModel):
|
||||
encounter_id: int
|
||||
level: int
|
||||
|
||||
|
||||
class BossResultCreate(CamelModel):
|
||||
boss_battle_id: int
|
||||
result: str
|
||||
attempts: int = 1
|
||||
team: list[BossResultTeamMemberInput] = []
|
||||
|
||||
|
||||
class BossReorderItem(CamelModel):
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
from app.models.nuzlocke_run import RunVisibility
|
||||
from app.schemas.base import CamelModel
|
||||
from app.schemas.encounter import EncounterDetailResponse
|
||||
from app.schemas.game import GameResponse
|
||||
|
||||
|
||||
class OwnerResponse(CamelModel):
|
||||
id: UUID
|
||||
display_name: str | None = None
|
||||
|
||||
|
||||
class RunCreate(CamelModel):
|
||||
game_id: int
|
||||
name: str
|
||||
rules: dict = {}
|
||||
naming_scheme: str | None = None
|
||||
visibility: RunVisibility = RunVisibility.PUBLIC
|
||||
|
||||
|
||||
class RunUpdate(CamelModel):
|
||||
@@ -18,6 +26,7 @@ class RunUpdate(CamelModel):
|
||||
rules: dict | None = None
|
||||
hof_encounter_ids: list[int] | None = None
|
||||
naming_scheme: str | None = None
|
||||
visibility: RunVisibility | None = None
|
||||
|
||||
|
||||
class RunResponse(CamelModel):
|
||||
@@ -28,6 +37,8 @@ class RunResponse(CamelModel):
|
||||
rules: dict
|
||||
hof_encounter_ids: list[int] | None = None
|
||||
naming_scheme: str | None = None
|
||||
visibility: RunVisibility
|
||||
owner: OwnerResponse | None = None
|
||||
started_at: datetime
|
||||
completed_at: datetime | None
|
||||
|
||||
|
||||
@@ -87,7 +87,9 @@ RUN_DEFS = [
|
||||
"name": "Kanto Heartbreak",
|
||||
"status": "failed",
|
||||
"progress": 0.45,
|
||||
"rules": {"customRules": "- Hardcore mode: no items in battle\n- Set mode only"},
|
||||
"rules": {
|
||||
"customRules": "- Hardcore mode: no items in battle\n- Set mode only"
|
||||
},
|
||||
"started_days_ago": 30,
|
||||
"ended_days_ago": 20,
|
||||
},
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import os
|
||||
import time
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
|
||||
import app.models # noqa: F401 — ensures all models register with Base.metadata
|
||||
from app.core.auth import AuthUser, get_current_user
|
||||
from app.core.database import Base, get_session
|
||||
from app.main import app
|
||||
|
||||
TEST_JWT_SECRET = "test-jwt-secret-for-testing-only"
|
||||
|
||||
TEST_DATABASE_URL = os.getenv(
|
||||
"TEST_DATABASE_URL",
|
||||
"postgresql+asyncpg://postgres:postgres@localhost:5433/nuzlocke_test",
|
||||
@@ -59,3 +64,43 @@ async def client(db_session):
|
||||
transport=ASGITransport(app=app), base_url="http://test"
|
||||
) as ac:
|
||||
yield ac
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_auth_user():
|
||||
"""Return a mock authenticated user for tests."""
|
||||
return AuthUser(id="test-user-123", email="test@example.com", role="authenticated")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def auth_override(mock_auth_user):
|
||||
"""Override get_current_user to return a mock user."""
|
||||
|
||||
def _override():
|
||||
return mock_auth_user
|
||||
|
||||
app.dependency_overrides[get_current_user] = _override
|
||||
yield
|
||||
app.dependency_overrides.pop(get_current_user, None)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def auth_client(db_session, auth_override):
|
||||
"""Async HTTP client with mocked authentication."""
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=app), base_url="http://test"
|
||||
) as ac:
|
||||
yield ac
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def valid_token():
|
||||
"""Generate a valid JWT token for testing."""
|
||||
payload = {
|
||||
"sub": "test-user-123",
|
||||
"email": "test@example.com",
|
||||
"role": "authenticated",
|
||||
"aud": "authenticated",
|
||||
"exp": int(time.time()) + 3600,
|
||||
}
|
||||
return jwt.encode(payload, TEST_JWT_SECRET, algorithm="HS256")
|
||||
|
||||
179
backend/tests/test_auth.py
Normal file
179
backend/tests/test_auth.py
Normal file
@@ -0,0 +1,179 @@
|
||||
import time
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.core.auth import AuthUser, get_current_user, require_auth
|
||||
from app.core.config import settings
|
||||
from app.main import app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def jwt_secret():
|
||||
"""Provide a test JWT secret."""
|
||||
return "test-jwt-secret-for-testing-only"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def valid_token(jwt_secret):
|
||||
"""Generate a valid JWT token."""
|
||||
payload = {
|
||||
"sub": "user-123",
|
||||
"email": "test@example.com",
|
||||
"role": "authenticated",
|
||||
"aud": "authenticated",
|
||||
"exp": int(time.time()) + 3600,
|
||||
}
|
||||
return jwt.encode(payload, jwt_secret, algorithm="HS256")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def expired_token(jwt_secret):
|
||||
"""Generate an expired JWT token."""
|
||||
payload = {
|
||||
"sub": "user-123",
|
||||
"email": "test@example.com",
|
||||
"role": "authenticated",
|
||||
"aud": "authenticated",
|
||||
"exp": int(time.time()) - 3600, # Expired 1 hour ago
|
||||
}
|
||||
return jwt.encode(payload, jwt_secret, algorithm="HS256")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def invalid_token():
|
||||
"""Generate a token signed with wrong secret."""
|
||||
payload = {
|
||||
"sub": "user-123",
|
||||
"email": "test@example.com",
|
||||
"role": "authenticated",
|
||||
"aud": "authenticated",
|
||||
"exp": int(time.time()) + 3600,
|
||||
}
|
||||
return jwt.encode(payload, "wrong-secret", algorithm="HS256")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def auth_client(db_session, jwt_secret, valid_token, monkeypatch):
|
||||
"""Client with valid auth token and configured JWT secret."""
|
||||
monkeypatch.setattr(settings, "supabase_jwt_secret", jwt_secret)
|
||||
|
||||
async def _get_client():
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=app),
|
||||
base_url="http://test",
|
||||
headers={"Authorization": f"Bearer {valid_token}"},
|
||||
) as ac:
|
||||
yield ac
|
||||
|
||||
return _get_client
|
||||
|
||||
|
||||
async def test_get_current_user_valid_token(jwt_secret, valid_token, monkeypatch):
|
||||
"""Test get_current_user returns user for valid token."""
|
||||
monkeypatch.setattr(settings, "supabase_jwt_secret", jwt_secret)
|
||||
|
||||
class MockRequest:
|
||||
headers = {"Authorization": f"Bearer {valid_token}"}
|
||||
|
||||
user = get_current_user(MockRequest())
|
||||
assert user is not None
|
||||
assert user.id == "user-123"
|
||||
assert user.email == "test@example.com"
|
||||
assert user.role == "authenticated"
|
||||
|
||||
|
||||
async def test_get_current_user_no_token(jwt_secret, monkeypatch):
|
||||
"""Test get_current_user returns None when no token."""
|
||||
monkeypatch.setattr(settings, "supabase_jwt_secret", jwt_secret)
|
||||
|
||||
class MockRequest:
|
||||
headers = {}
|
||||
|
||||
user = get_current_user(MockRequest())
|
||||
assert user is None
|
||||
|
||||
|
||||
async def test_get_current_user_expired_token(jwt_secret, expired_token, monkeypatch):
|
||||
"""Test get_current_user returns None for expired token."""
|
||||
monkeypatch.setattr(settings, "supabase_jwt_secret", jwt_secret)
|
||||
|
||||
class MockRequest:
|
||||
headers = {"Authorization": f"Bearer {expired_token}"}
|
||||
|
||||
user = get_current_user(MockRequest())
|
||||
assert user is None
|
||||
|
||||
|
||||
async def test_get_current_user_invalid_token(jwt_secret, invalid_token, monkeypatch):
|
||||
"""Test get_current_user returns None for invalid token."""
|
||||
monkeypatch.setattr(settings, "supabase_jwt_secret", jwt_secret)
|
||||
|
||||
class MockRequest:
|
||||
headers = {"Authorization": f"Bearer {invalid_token}"}
|
||||
|
||||
user = get_current_user(MockRequest())
|
||||
assert user is None
|
||||
|
||||
|
||||
async def test_get_current_user_malformed_header(jwt_secret, monkeypatch):
|
||||
"""Test get_current_user returns None for malformed auth header."""
|
||||
monkeypatch.setattr(settings, "supabase_jwt_secret", jwt_secret)
|
||||
|
||||
class MockRequest:
|
||||
headers = {"Authorization": "NotBearer token"}
|
||||
|
||||
user = get_current_user(MockRequest())
|
||||
assert user is None
|
||||
|
||||
|
||||
async def test_require_auth_valid_user():
|
||||
"""Test require_auth passes through valid user."""
|
||||
user = AuthUser(id="user-123", email="test@example.com")
|
||||
result = require_auth(user)
|
||||
assert result is user
|
||||
|
||||
|
||||
async def test_require_auth_no_user():
|
||||
"""Test require_auth raises 401 for no user."""
|
||||
from fastapi import HTTPException
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
require_auth(None)
|
||||
assert exc_info.value.status_code == 401
|
||||
assert exc_info.value.detail == "Authentication required"
|
||||
|
||||
|
||||
async def test_protected_endpoint_without_token(db_session):
|
||||
"""Test that write endpoint returns 401 without token."""
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=app), base_url="http://test"
|
||||
) as ac:
|
||||
response = await ac.post("/runs", json={"game_id": 1, "name": "Test Run"})
|
||||
assert response.status_code == 401
|
||||
assert response.json()["detail"] == "Authentication required"
|
||||
|
||||
|
||||
async def test_protected_endpoint_with_expired_token(
|
||||
db_session, jwt_secret, expired_token, monkeypatch
|
||||
):
|
||||
"""Test that write endpoint returns 401 with expired token."""
|
||||
monkeypatch.setattr(settings, "supabase_jwt_secret", jwt_secret)
|
||||
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=app),
|
||||
base_url="http://test",
|
||||
headers={"Authorization": f"Bearer {expired_token}"},
|
||||
) as ac:
|
||||
response = await ac.post("/runs", json={"game_id": 1, "name": "Test Run"})
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
async def test_read_endpoint_without_token(db_session):
|
||||
"""Test that read endpoints work without authentication."""
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=app), base_url="http://test"
|
||||
) as ac:
|
||||
response = await ac.get("/runs")
|
||||
assert response.status_code == 200
|
||||
@@ -17,9 +17,9 @@ GAME_PAYLOAD = {
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def game(client: AsyncClient) -> dict:
|
||||
async def game(auth_client: AsyncClient) -> dict:
|
||||
"""A game created via the API (no version_group_id)."""
|
||||
response = await client.post(BASE, json=GAME_PAYLOAD)
|
||||
response = await auth_client.post(BASE, json=GAME_PAYLOAD)
|
||||
assert response.status_code == 201
|
||||
return response.json()
|
||||
|
||||
@@ -68,22 +68,24 @@ class TestListGames:
|
||||
|
||||
|
||||
class TestCreateGame:
|
||||
async def test_creates_and_returns_game(self, client: AsyncClient):
|
||||
response = await client.post(BASE, json=GAME_PAYLOAD)
|
||||
async def test_creates_and_returns_game(self, auth_client: AsyncClient):
|
||||
response = await auth_client.post(BASE, json=GAME_PAYLOAD)
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["name"] == "Pokemon Red"
|
||||
assert data["slug"] == "red"
|
||||
assert isinstance(data["id"], int)
|
||||
|
||||
async def test_duplicate_slug_returns_409(self, client: AsyncClient, game: dict):
|
||||
response = await client.post(
|
||||
async def test_duplicate_slug_returns_409(
|
||||
self, auth_client: AsyncClient, game: dict
|
||||
):
|
||||
response = await auth_client.post(
|
||||
BASE, json={**GAME_PAYLOAD, "name": "Pokemon Red v2"}
|
||||
)
|
||||
assert response.status_code == 409
|
||||
|
||||
async def test_missing_required_field_returns_422(self, client: AsyncClient):
|
||||
response = await client.post(BASE, json={"name": "Pokemon Red"})
|
||||
async def test_missing_required_field_returns_422(self, auth_client: AsyncClient):
|
||||
response = await auth_client.post(BASE, json={"name": "Pokemon Red"})
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
@@ -113,29 +115,35 @@ class TestGetGame:
|
||||
|
||||
|
||||
class TestUpdateGame:
|
||||
async def test_updates_name(self, client: AsyncClient, game: dict):
|
||||
response = await client.put(
|
||||
async def test_updates_name(self, auth_client: AsyncClient, game: dict):
|
||||
response = await auth_client.put(
|
||||
f"{BASE}/{game['id']}", json={"name": "Pokemon Blue"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["name"] == "Pokemon Blue"
|
||||
|
||||
async def test_slug_unchanged_on_partial_update(
|
||||
self, client: AsyncClient, game: dict
|
||||
self, auth_client: AsyncClient, game: dict
|
||||
):
|
||||
response = await client.put(f"{BASE}/{game['id']}", json={"name": "New Name"})
|
||||
response = await auth_client.put(
|
||||
f"{BASE}/{game['id']}", json={"name": "New Name"}
|
||||
)
|
||||
assert response.json()["slug"] == "red"
|
||||
|
||||
async def test_not_found_returns_404(self, client: AsyncClient):
|
||||
assert (await client.put(f"{BASE}/9999", json={"name": "x"})).status_code == 404
|
||||
async def test_not_found_returns_404(self, auth_client: AsyncClient):
|
||||
assert (
|
||||
await auth_client.put(f"{BASE}/9999", json={"name": "x"})
|
||||
).status_code == 404
|
||||
|
||||
async def test_duplicate_slug_returns_409(self, client: AsyncClient):
|
||||
await client.post(BASE, json={**GAME_PAYLOAD, "slug": "blue", "name": "Blue"})
|
||||
r1 = await client.post(
|
||||
async def test_duplicate_slug_returns_409(self, auth_client: AsyncClient):
|
||||
await auth_client.post(
|
||||
BASE, json={**GAME_PAYLOAD, "slug": "blue", "name": "Blue"}
|
||||
)
|
||||
r1 = await auth_client.post(
|
||||
BASE, json={**GAME_PAYLOAD, "slug": "red", "name": "Red"}
|
||||
)
|
||||
game_id = r1.json()["id"]
|
||||
response = await client.put(f"{BASE}/{game_id}", json={"slug": "blue"})
|
||||
response = await auth_client.put(f"{BASE}/{game_id}", json={"slug": "blue"})
|
||||
assert response.status_code == 409
|
||||
|
||||
|
||||
@@ -145,13 +153,13 @@ class TestUpdateGame:
|
||||
|
||||
|
||||
class TestDeleteGame:
|
||||
async def test_deletes_game(self, client: AsyncClient, game: dict):
|
||||
response = await client.delete(f"{BASE}/{game['id']}")
|
||||
async def test_deletes_game(self, auth_client: AsyncClient, game: dict):
|
||||
response = await auth_client.delete(f"{BASE}/{game['id']}")
|
||||
assert response.status_code == 204
|
||||
assert (await client.get(f"{BASE}/{game['id']}")).status_code == 404
|
||||
assert (await auth_client.get(f"{BASE}/{game['id']}")).status_code == 404
|
||||
|
||||
async def test_not_found_returns_404(self, client: AsyncClient):
|
||||
assert (await client.delete(f"{BASE}/9999")).status_code == 404
|
||||
async def test_not_found_returns_404(self, auth_client: AsyncClient):
|
||||
assert (await auth_client.delete(f"{BASE}/9999")).status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -187,9 +195,9 @@ class TestListByRegion:
|
||||
|
||||
|
||||
class TestCreateRoute:
|
||||
async def test_creates_route(self, client: AsyncClient, game_with_vg: tuple):
|
||||
async def test_creates_route(self, auth_client: AsyncClient, game_with_vg: tuple):
|
||||
game_id, _ = game_with_vg
|
||||
response = await client.post(
|
||||
response = await auth_client.post(
|
||||
f"{BASE}/{game_id}/routes",
|
||||
json={"name": "Pallet Town", "order": 1},
|
||||
)
|
||||
@@ -200,35 +208,35 @@ class TestCreateRoute:
|
||||
assert isinstance(data["id"], int)
|
||||
|
||||
async def test_game_detail_includes_route(
|
||||
self, client: AsyncClient, game_with_vg: tuple
|
||||
self, auth_client: AsyncClient, game_with_vg: tuple
|
||||
):
|
||||
game_id, _ = game_with_vg
|
||||
await client.post(
|
||||
await auth_client.post(
|
||||
f"{BASE}/{game_id}/routes", json={"name": "Route 1", "order": 1}
|
||||
)
|
||||
response = await client.get(f"{BASE}/{game_id}")
|
||||
response = await auth_client.get(f"{BASE}/{game_id}")
|
||||
routes = response.json()["routes"]
|
||||
assert len(routes) == 1
|
||||
assert routes[0]["name"] == "Route 1"
|
||||
|
||||
async def test_game_without_version_group_returns_400(
|
||||
self, client: AsyncClient, game: dict
|
||||
self, auth_client: AsyncClient, game: dict
|
||||
):
|
||||
response = await client.post(
|
||||
response = await auth_client.post(
|
||||
f"{BASE}/{game['id']}/routes",
|
||||
json={"name": "Route 1", "order": 1},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
|
||||
async def test_list_routes_excludes_routes_without_encounters(
|
||||
self, client: AsyncClient, game_with_vg: tuple
|
||||
self, auth_client: AsyncClient, game_with_vg: tuple
|
||||
):
|
||||
"""list_game_routes only returns routes that have Pokemon encounters."""
|
||||
game_id, _ = game_with_vg
|
||||
await client.post(
|
||||
await auth_client.post(
|
||||
f"{BASE}/{game_id}/routes", json={"name": "Route 1", "order": 1}
|
||||
)
|
||||
response = await client.get(f"{BASE}/{game_id}/routes?flat=true")
|
||||
response = await auth_client.get(f"{BASE}/{game_id}/routes?flat=true")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == []
|
||||
|
||||
@@ -239,14 +247,16 @@ class TestCreateRoute:
|
||||
|
||||
|
||||
class TestUpdateRoute:
|
||||
async def test_updates_route_name(self, client: AsyncClient, game_with_vg: tuple):
|
||||
async def test_updates_route_name(
|
||||
self, auth_client: AsyncClient, game_with_vg: tuple
|
||||
):
|
||||
game_id, _ = game_with_vg
|
||||
r = (
|
||||
await client.post(
|
||||
await auth_client.post(
|
||||
f"{BASE}/{game_id}/routes", json={"name": "Old Name", "order": 1}
|
||||
)
|
||||
).json()
|
||||
response = await client.put(
|
||||
response = await auth_client.put(
|
||||
f"{BASE}/{game_id}/routes/{r['id']}",
|
||||
json={"name": "New Name"},
|
||||
)
|
||||
@@ -254,11 +264,11 @@ class TestUpdateRoute:
|
||||
assert response.json()["name"] == "New Name"
|
||||
|
||||
async def test_route_not_found_returns_404(
|
||||
self, client: AsyncClient, game_with_vg: tuple
|
||||
self, auth_client: AsyncClient, game_with_vg: tuple
|
||||
):
|
||||
game_id, _ = game_with_vg
|
||||
assert (
|
||||
await client.put(f"{BASE}/{game_id}/routes/9999", json={"name": "x"})
|
||||
await auth_client.put(f"{BASE}/{game_id}/routes/9999", json={"name": "x"})
|
||||
).status_code == 404
|
||||
|
||||
|
||||
@@ -268,25 +278,27 @@ class TestUpdateRoute:
|
||||
|
||||
|
||||
class TestDeleteRoute:
|
||||
async def test_deletes_route(self, client: AsyncClient, game_with_vg: tuple):
|
||||
async def test_deletes_route(self, auth_client: AsyncClient, game_with_vg: tuple):
|
||||
game_id, _ = game_with_vg
|
||||
r = (
|
||||
await client.post(
|
||||
await auth_client.post(
|
||||
f"{BASE}/{game_id}/routes", json={"name": "Route 1", "order": 1}
|
||||
)
|
||||
).json()
|
||||
assert (
|
||||
await client.delete(f"{BASE}/{game_id}/routes/{r['id']}")
|
||||
await auth_client.delete(f"{BASE}/{game_id}/routes/{r['id']}")
|
||||
).status_code == 204
|
||||
# No longer in game detail
|
||||
detail = (await client.get(f"{BASE}/{game_id}")).json()
|
||||
detail = (await auth_client.get(f"{BASE}/{game_id}")).json()
|
||||
assert all(route["id"] != r["id"] for route in detail["routes"])
|
||||
|
||||
async def test_route_not_found_returns_404(
|
||||
self, client: AsyncClient, game_with_vg: tuple
|
||||
self, auth_client: AsyncClient, game_with_vg: tuple
|
||||
):
|
||||
game_id, _ = game_with_vg
|
||||
assert (await client.delete(f"{BASE}/{game_id}/routes/9999")).status_code == 404
|
||||
assert (
|
||||
await auth_client.delete(f"{BASE}/{game_id}/routes/9999")
|
||||
).status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -295,20 +307,20 @@ class TestDeleteRoute:
|
||||
|
||||
|
||||
class TestReorderRoutes:
|
||||
async def test_reorders_routes(self, client: AsyncClient, game_with_vg: tuple):
|
||||
async def test_reorders_routes(self, auth_client: AsyncClient, game_with_vg: tuple):
|
||||
game_id, _ = game_with_vg
|
||||
r1 = (
|
||||
await client.post(
|
||||
await auth_client.post(
|
||||
f"{BASE}/{game_id}/routes", json={"name": "A", "order": 1}
|
||||
)
|
||||
).json()
|
||||
r2 = (
|
||||
await client.post(
|
||||
await auth_client.post(
|
||||
f"{BASE}/{game_id}/routes", json={"name": "B", "order": 2}
|
||||
)
|
||||
).json()
|
||||
|
||||
response = await client.put(
|
||||
response = await auth_client.put(
|
||||
f"{BASE}/{game_id}/routes/reorder",
|
||||
json={
|
||||
"routes": [{"id": r1["id"], "order": 2}, {"id": r2["id"], "order": 1}]
|
||||
|
||||
@@ -30,9 +30,11 @@ async def game_id(db_session: AsyncSession) -> int:
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def run(client: AsyncClient, game_id: int) -> dict:
|
||||
async def run(auth_client: AsyncClient, game_id: int) -> dict:
|
||||
"""An active run created via the API."""
|
||||
response = await client.post(RUNS_BASE, json={"gameId": game_id, "name": "My Run"})
|
||||
response = await auth_client.post(
|
||||
RUNS_BASE, json={"gameId": game_id, "name": "My Run"}
|
||||
)
|
||||
assert response.status_code == 201
|
||||
return response.json()
|
||||
|
||||
@@ -127,8 +129,8 @@ class TestListRuns:
|
||||
|
||||
|
||||
class TestCreateRun:
|
||||
async def test_creates_active_run(self, client: AsyncClient, game_id: int):
|
||||
response = await client.post(
|
||||
async def test_creates_active_run(self, auth_client: AsyncClient, game_id: int):
|
||||
response = await auth_client.post(
|
||||
RUNS_BASE, json={"gameId": game_id, "name": "New Run"}
|
||||
)
|
||||
assert response.status_code == 201
|
||||
@@ -138,20 +140,22 @@ class TestCreateRun:
|
||||
assert data["gameId"] == game_id
|
||||
assert isinstance(data["id"], int)
|
||||
|
||||
async def test_rules_stored(self, client: AsyncClient, game_id: int):
|
||||
async def test_rules_stored(self, auth_client: AsyncClient, game_id: int):
|
||||
rules = {"duplicatesClause": True, "shinyClause": False}
|
||||
response = await client.post(
|
||||
response = await auth_client.post(
|
||||
RUNS_BASE, json={"gameId": game_id, "name": "Run", "rules": rules}
|
||||
)
|
||||
assert response.status_code == 201
|
||||
assert response.json()["rules"]["duplicatesClause"] is True
|
||||
|
||||
async def test_invalid_game_returns_404(self, client: AsyncClient):
|
||||
response = await client.post(RUNS_BASE, json={"gameId": 9999, "name": "Run"})
|
||||
async def test_invalid_game_returns_404(self, auth_client: AsyncClient):
|
||||
response = await auth_client.post(
|
||||
RUNS_BASE, json={"gameId": 9999, "name": "Run"}
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
async def test_missing_required_returns_422(self, client: AsyncClient):
|
||||
response = await client.post(RUNS_BASE, json={"name": "Run"})
|
||||
async def test_missing_required_returns_422(self, auth_client: AsyncClient):
|
||||
response = await auth_client.post(RUNS_BASE, json={"name": "Run"})
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
@@ -181,15 +185,17 @@ class TestGetRun:
|
||||
|
||||
|
||||
class TestUpdateRun:
|
||||
async def test_updates_name(self, client: AsyncClient, run: dict):
|
||||
response = await client.patch(
|
||||
async def test_updates_name(self, auth_client: AsyncClient, run: dict):
|
||||
response = await auth_client.patch(
|
||||
f"{RUNS_BASE}/{run['id']}", json={"name": "Renamed"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["name"] == "Renamed"
|
||||
|
||||
async def test_complete_run_sets_completed_at(self, client: AsyncClient, run: dict):
|
||||
response = await client.patch(
|
||||
async def test_complete_run_sets_completed_at(
|
||||
self, auth_client: AsyncClient, run: dict
|
||||
):
|
||||
response = await auth_client.patch(
|
||||
f"{RUNS_BASE}/{run['id']}", json={"status": "completed"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
@@ -197,25 +203,27 @@ class TestUpdateRun:
|
||||
assert data["status"] == "completed"
|
||||
assert data["completedAt"] is not None
|
||||
|
||||
async def test_fail_run(self, client: AsyncClient, run: dict):
|
||||
response = await client.patch(
|
||||
async def test_fail_run(self, auth_client: AsyncClient, run: dict):
|
||||
response = await auth_client.patch(
|
||||
f"{RUNS_BASE}/{run['id']}", json={"status": "failed"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "failed"
|
||||
|
||||
async def test_ending_already_ended_run_returns_400(
|
||||
self, client: AsyncClient, run: dict
|
||||
self, auth_client: AsyncClient, run: dict
|
||||
):
|
||||
await client.patch(f"{RUNS_BASE}/{run['id']}", json={"status": "completed"})
|
||||
response = await client.patch(
|
||||
await auth_client.patch(
|
||||
f"{RUNS_BASE}/{run['id']}", json={"status": "completed"}
|
||||
)
|
||||
response = await auth_client.patch(
|
||||
f"{RUNS_BASE}/{run['id']}", json={"status": "failed"}
|
||||
)
|
||||
assert response.status_code == 400
|
||||
|
||||
async def test_not_found_returns_404(self, client: AsyncClient):
|
||||
async def test_not_found_returns_404(self, auth_client: AsyncClient):
|
||||
assert (
|
||||
await client.patch(f"{RUNS_BASE}/9999", json={"name": "x"})
|
||||
await auth_client.patch(f"{RUNS_BASE}/9999", json={"name": "x"})
|
||||
).status_code == 404
|
||||
|
||||
|
||||
@@ -225,12 +233,12 @@ class TestUpdateRun:
|
||||
|
||||
|
||||
class TestDeleteRun:
|
||||
async def test_deletes_run(self, client: AsyncClient, run: dict):
|
||||
assert (await client.delete(f"{RUNS_BASE}/{run['id']}")).status_code == 204
|
||||
assert (await client.get(f"{RUNS_BASE}/{run['id']}")).status_code == 404
|
||||
async def test_deletes_run(self, auth_client: AsyncClient, run: dict):
|
||||
assert (await auth_client.delete(f"{RUNS_BASE}/{run['id']}")).status_code == 204
|
||||
assert (await auth_client.get(f"{RUNS_BASE}/{run['id']}")).status_code == 404
|
||||
|
||||
async def test_not_found_returns_404(self, client: AsyncClient):
|
||||
assert (await client.delete(f"{RUNS_BASE}/9999")).status_code == 404
|
||||
async def test_not_found_returns_404(self, auth_client: AsyncClient):
|
||||
assert (await auth_client.delete(f"{RUNS_BASE}/9999")).status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -239,8 +247,8 @@ class TestDeleteRun:
|
||||
|
||||
|
||||
class TestCreateEncounter:
|
||||
async def test_creates_encounter(self, client: AsyncClient, enc_ctx: dict):
|
||||
response = await client.post(
|
||||
async def test_creates_encounter(self, auth_client: AsyncClient, enc_ctx: dict):
|
||||
response = await auth_client.post(
|
||||
f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters",
|
||||
json={
|
||||
"routeId": enc_ctx["standalone_id"],
|
||||
@@ -255,8 +263,10 @@ class TestCreateEncounter:
|
||||
assert data["status"] == "caught"
|
||||
assert data["isShiny"] is False
|
||||
|
||||
async def test_invalid_run_returns_404(self, client: AsyncClient, enc_ctx: dict):
|
||||
response = await client.post(
|
||||
async def test_invalid_run_returns_404(
|
||||
self, auth_client: AsyncClient, enc_ctx: dict
|
||||
):
|
||||
response = await auth_client.post(
|
||||
f"{RUNS_BASE}/9999/encounters",
|
||||
json={
|
||||
"routeId": enc_ctx["standalone_id"],
|
||||
@@ -266,8 +276,10 @@ class TestCreateEncounter:
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
async def test_invalid_route_returns_404(self, client: AsyncClient, enc_ctx: dict):
|
||||
response = await client.post(
|
||||
async def test_invalid_route_returns_404(
|
||||
self, auth_client: AsyncClient, enc_ctx: dict
|
||||
):
|
||||
response = await auth_client.post(
|
||||
f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters",
|
||||
json={
|
||||
"routeId": 9999,
|
||||
@@ -278,9 +290,9 @@ class TestCreateEncounter:
|
||||
assert response.status_code == 404
|
||||
|
||||
async def test_invalid_pokemon_returns_404(
|
||||
self, client: AsyncClient, enc_ctx: dict
|
||||
self, auth_client: AsyncClient, enc_ctx: dict
|
||||
):
|
||||
response = await client.post(
|
||||
response = await auth_client.post(
|
||||
f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters",
|
||||
json={
|
||||
"routeId": enc_ctx["standalone_id"],
|
||||
@@ -290,9 +302,11 @@ class TestCreateEncounter:
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
async def test_parent_route_rejected_400(self, client: AsyncClient, enc_ctx: dict):
|
||||
async def test_parent_route_rejected_400(
|
||||
self, auth_client: AsyncClient, enc_ctx: dict
|
||||
):
|
||||
"""Cannot create an encounter directly on a parent route (use child routes)."""
|
||||
response = await client.post(
|
||||
response = await auth_client.post(
|
||||
f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters",
|
||||
json={
|
||||
"routeId": enc_ctx["parent_id"],
|
||||
@@ -303,10 +317,10 @@ class TestCreateEncounter:
|
||||
assert response.status_code == 400
|
||||
|
||||
async def test_route_lock_prevents_second_sibling_encounter(
|
||||
self, client: AsyncClient, enc_ctx: dict
|
||||
self, auth_client: AsyncClient, enc_ctx: dict
|
||||
):
|
||||
"""Once a sibling child has an encounter, other siblings in the group return 409."""
|
||||
await client.post(
|
||||
await auth_client.post(
|
||||
f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters",
|
||||
json={
|
||||
"routeId": enc_ctx["child1_id"],
|
||||
@@ -314,7 +328,7 @@ class TestCreateEncounter:
|
||||
"status": "caught",
|
||||
},
|
||||
)
|
||||
response = await client.post(
|
||||
response = await auth_client.post(
|
||||
f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters",
|
||||
json={
|
||||
"routeId": enc_ctx["child2_id"],
|
||||
@@ -325,11 +339,11 @@ class TestCreateEncounter:
|
||||
assert response.status_code == 409
|
||||
|
||||
async def test_shiny_bypasses_route_lock(
|
||||
self, client: AsyncClient, enc_ctx: dict, db_session: AsyncSession
|
||||
self, auth_client: AsyncClient, enc_ctx: dict, db_session: AsyncSession
|
||||
):
|
||||
"""A shiny encounter bypasses the route-lock when shinyClause is enabled."""
|
||||
# First encounter occupies the group
|
||||
await client.post(
|
||||
await auth_client.post(
|
||||
f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters",
|
||||
json={
|
||||
"routeId": enc_ctx["child1_id"],
|
||||
@@ -338,7 +352,7 @@ class TestCreateEncounter:
|
||||
},
|
||||
)
|
||||
# Shiny encounter on sibling should succeed
|
||||
response = await client.post(
|
||||
response = await auth_client.post(
|
||||
f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters",
|
||||
json={
|
||||
"routeId": enc_ctx["child2_id"],
|
||||
@@ -351,7 +365,7 @@ class TestCreateEncounter:
|
||||
assert response.json()["isShiny"] is True
|
||||
|
||||
async def test_gift_bypasses_route_lock_when_clause_on(
|
||||
self, client: AsyncClient, enc_ctx: dict, db_session: AsyncSession
|
||||
self, auth_client: AsyncClient, enc_ctx: dict, db_session: AsyncSession
|
||||
):
|
||||
"""A gift encounter bypasses route-lock when giftClause is enabled."""
|
||||
# Enable giftClause on the run
|
||||
@@ -359,7 +373,7 @@ class TestCreateEncounter:
|
||||
run.rules = {"shinyClause": True, "giftClause": True}
|
||||
await db_session.commit()
|
||||
|
||||
await client.post(
|
||||
await auth_client.post(
|
||||
f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters",
|
||||
json={
|
||||
"routeId": enc_ctx["child1_id"],
|
||||
@@ -367,7 +381,7 @@ class TestCreateEncounter:
|
||||
"status": "caught",
|
||||
},
|
||||
)
|
||||
response = await client.post(
|
||||
response = await auth_client.post(
|
||||
f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters",
|
||||
json={
|
||||
"routeId": enc_ctx["child2_id"],
|
||||
@@ -387,8 +401,8 @@ class TestCreateEncounter:
|
||||
|
||||
class TestUpdateEncounter:
|
||||
@pytest.fixture
|
||||
async def encounter(self, client: AsyncClient, enc_ctx: dict) -> dict:
|
||||
response = await client.post(
|
||||
async def encounter(self, auth_client: AsyncClient, enc_ctx: dict) -> dict:
|
||||
response = await auth_client.post(
|
||||
f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters",
|
||||
json={
|
||||
"routeId": enc_ctx["standalone_id"],
|
||||
@@ -398,17 +412,17 @@ class TestUpdateEncounter:
|
||||
)
|
||||
return response.json()
|
||||
|
||||
async def test_updates_nickname(self, client: AsyncClient, encounter: dict):
|
||||
response = await client.patch(
|
||||
async def test_updates_nickname(self, auth_client: AsyncClient, encounter: dict):
|
||||
response = await auth_client.patch(
|
||||
f"{ENC_BASE}/{encounter['id']}", json={"nickname": "Sparky"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["nickname"] == "Sparky"
|
||||
|
||||
async def test_updates_status_to_fainted(
|
||||
self, client: AsyncClient, encounter: dict
|
||||
self, auth_client: AsyncClient, encounter: dict
|
||||
):
|
||||
response = await client.patch(
|
||||
response = await auth_client.patch(
|
||||
f"{ENC_BASE}/{encounter['id']}",
|
||||
json={"status": "fainted", "faintLevel": 12, "deathCause": "wild battle"},
|
||||
)
|
||||
@@ -418,9 +432,9 @@ class TestUpdateEncounter:
|
||||
assert data["faintLevel"] == 12
|
||||
assert data["deathCause"] == "wild battle"
|
||||
|
||||
async def test_not_found_returns_404(self, client: AsyncClient):
|
||||
async def test_not_found_returns_404(self, auth_client: AsyncClient):
|
||||
assert (
|
||||
await client.patch(f"{ENC_BASE}/9999", json={"nickname": "x"})
|
||||
await auth_client.patch(f"{ENC_BASE}/9999", json={"nickname": "x"})
|
||||
).status_code == 404
|
||||
|
||||
|
||||
@@ -431,8 +445,8 @@ class TestUpdateEncounter:
|
||||
|
||||
class TestDeleteEncounter:
|
||||
@pytest.fixture
|
||||
async def encounter(self, client: AsyncClient, enc_ctx: dict) -> dict:
|
||||
response = await client.post(
|
||||
async def encounter(self, auth_client: AsyncClient, enc_ctx: dict) -> dict:
|
||||
response = await auth_client.post(
|
||||
f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters",
|
||||
json={
|
||||
"routeId": enc_ctx["standalone_id"],
|
||||
@@ -443,12 +457,14 @@ class TestDeleteEncounter:
|
||||
return response.json()
|
||||
|
||||
async def test_deletes_encounter(
|
||||
self, client: AsyncClient, encounter: dict, enc_ctx: dict
|
||||
self, auth_client: AsyncClient, encounter: dict, enc_ctx: dict
|
||||
):
|
||||
assert (await client.delete(f"{ENC_BASE}/{encounter['id']}")).status_code == 204
|
||||
assert (
|
||||
await auth_client.delete(f"{ENC_BASE}/{encounter['id']}")
|
||||
).status_code == 204
|
||||
# Run detail should no longer include it
|
||||
detail = (await client.get(f"{RUNS_BASE}/{enc_ctx['run_id']}")).json()
|
||||
detail = (await auth_client.get(f"{RUNS_BASE}/{enc_ctx['run_id']}")).json()
|
||||
assert all(e["id"] != encounter["id"] for e in detail["encounters"])
|
||||
|
||||
async def test_not_found_returns_404(self, client: AsyncClient):
|
||||
assert (await client.delete(f"{ENC_BASE}/9999")).status_code == 404
|
||||
async def test_not_found_returns_404(self, auth_client: AsyncClient):
|
||||
assert (await auth_client.delete(f"{ENC_BASE}/9999")).status_code == 404
|
||||
|
||||
128
docs/supabase-auth-setup.md
Normal file
128
docs/supabase-auth-setup.md
Normal file
@@ -0,0 +1,128 @@
|
||||
# Supabase Auth Setup
|
||||
|
||||
This guide walks through setting up Supabase authentication for local development.
|
||||
|
||||
## 1. Create a Supabase Project
|
||||
|
||||
1. Go to [supabase.com](https://supabase.com) and sign in
|
||||
2. Click "New project"
|
||||
3. Choose your organization, enter a project name (e.g., `nuzlocke-tracker-dev`), and set a database password
|
||||
4. Select a region close to you
|
||||
5. Wait for the project to finish provisioning
|
||||
|
||||
## 2. Get Your Project Credentials
|
||||
|
||||
From the Supabase dashboard:
|
||||
|
||||
1. Go to **Project Settings** > **API**
|
||||
2. Copy the following values:
|
||||
- **Project URL** -> `SUPABASE_URL` / `VITE_SUPABASE_URL`
|
||||
- **anon public** key -> `SUPABASE_ANON_KEY` / `VITE_SUPABASE_ANON_KEY`
|
||||
3. Go to **Project Settings** > **API** > **JWT Settings**
|
||||
4. Copy the **JWT Secret** -> `SUPABASE_JWT_SECRET`
|
||||
|
||||
## 3. Enable Email/Password Auth
|
||||
|
||||
1. Go to **Authentication** > **Providers**
|
||||
2. Ensure **Email** provider is enabled (it's enabled by default)
|
||||
3. Configure options as needed:
|
||||
- **Confirm email**: Enable for production, disable for local dev convenience
|
||||
- **Secure email change**: Recommended enabled
|
||||
|
||||
## 4. Configure Google OAuth
|
||||
|
||||
### Google Cloud Console Setup
|
||||
|
||||
1. Go to [Google Cloud Console](https://console.cloud.google.com/)
|
||||
2. Create a new project or select an existing one
|
||||
3. Go to **APIs & Services** > **OAuth consent screen**
|
||||
- Choose "External" user type
|
||||
- Fill in app name, user support email, and developer contact
|
||||
- Add scopes: `email`, `profile`, `openid`
|
||||
- Add test users if in testing mode
|
||||
4. Go to **APIs & Services** > **Credentials**
|
||||
5. Click **Create Credentials** > **OAuth client ID**
|
||||
6. Select "Web application"
|
||||
7. Add authorized redirect URIs:
|
||||
- `https://<your-project-ref>.supabase.co/auth/v1/callback`
|
||||
- For local dev: `http://localhost:5173/auth/callback`
|
||||
8. Copy the **Client ID** and **Client Secret**
|
||||
|
||||
### Supabase Setup
|
||||
|
||||
1. Go to **Authentication** > **Providers** > **Google**
|
||||
2. Enable the provider
|
||||
3. Paste the **Client ID** and **Client Secret** from Google
|
||||
4. Save
|
||||
|
||||
## 5. Configure Discord OAuth
|
||||
|
||||
### Discord Developer Portal Setup
|
||||
|
||||
1. Go to [Discord Developer Portal](https://discord.com/developers/applications)
|
||||
2. Click **New Application** and give it a name
|
||||
3. Go to **OAuth2** > **General**
|
||||
4. Add redirect URIs:
|
||||
- `https://<your-project-ref>.supabase.co/auth/v1/callback`
|
||||
- For local dev: `http://localhost:5173/auth/callback`
|
||||
5. Copy the **Client ID** and **Client Secret**
|
||||
|
||||
### Supabase Setup
|
||||
|
||||
1. Go to **Authentication** > **Providers** > **Discord**
|
||||
2. Enable the provider
|
||||
3. Paste the **Client ID** and **Client Secret** from Discord
|
||||
4. Save
|
||||
|
||||
## 6. Configure Environment Variables
|
||||
|
||||
### Backend (.env)
|
||||
|
||||
```bash
|
||||
SUPABASE_URL=https://your-project-ref.supabase.co
|
||||
SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
||||
SUPABASE_JWT_SECRET=your-jwt-secret-from-dashboard
|
||||
```
|
||||
|
||||
### Frontend (.env)
|
||||
|
||||
```bash
|
||||
VITE_SUPABASE_URL=https://your-project-ref.supabase.co
|
||||
VITE_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
||||
```
|
||||
|
||||
## 7. Configure Redirect URLs
|
||||
|
||||
In Supabase Dashboard:
|
||||
|
||||
1. Go to **Authentication** > **URL Configuration**
|
||||
2. Set **Site URL**: `http://localhost:5173` (for local dev)
|
||||
3. Add **Redirect URLs**:
|
||||
- `http://localhost:5173/auth/callback`
|
||||
- `http://localhost:5173/**` (for flexibility during dev)
|
||||
|
||||
For production, add your production URLs here as well.
|
||||
|
||||
## Verification
|
||||
|
||||
After setup, you can verify by:
|
||||
|
||||
1. Starting the app with `docker compose up`
|
||||
2. Navigating to the login page
|
||||
3. Testing email/password signup
|
||||
4. Testing Google and Discord OAuth flows
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Invalid redirect URI" error
|
||||
- Ensure the callback URL in your OAuth provider matches exactly what Supabase expects
|
||||
- Check that your Site URL in Supabase matches your app's URL
|
||||
|
||||
### "JWT verification failed"
|
||||
- Verify `SUPABASE_JWT_SECRET` matches the one in your Supabase dashboard
|
||||
- Ensure there are no trailing spaces in your environment variables
|
||||
|
||||
### OAuth popup closes without logging in
|
||||
- Check browser console for errors
|
||||
- Verify the OAuth provider is properly enabled in Supabase
|
||||
- Ensure redirect URLs are correctly configured in both the OAuth provider and Supabase
|
||||
128
frontend/package-lock.json
generated
128
frontend/package-lock.json
generated
@@ -11,6 +11,7 @@
|
||||
"@dnd-kit/core": "6.3.1",
|
||||
"@dnd-kit/sortable": "10.0.0",
|
||||
"@dnd-kit/utilities": "3.2.2",
|
||||
"@supabase/supabase-js": "^2.99.3",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tanstack/react-query": "5.91.3",
|
||||
"react": "19.2.4",
|
||||
@@ -2148,6 +2149,86 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@supabase/auth-js": {
|
||||
"version": "2.99.3",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.99.3.tgz",
|
||||
"integrity": "sha512-vMEVLA1kGGYd/kdsJSwtjiFUZM1nGfrz2DWmgMBZtocV48qL+L2+4QpIkueXyBEumMQZFEyhz57i/5zGHjvdBw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/functions-js": {
|
||||
"version": "2.99.3",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.99.3.tgz",
|
||||
"integrity": "sha512-6tk2zrcBkzKaaBXPOG5nshn30uJNFGOH9LxOnE8i850eQmsX+jVm7vql9kTPyvUzEHwU4zdjSOkXS9M+9ukMVA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/postgrest-js": {
|
||||
"version": "2.99.3",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.99.3.tgz",
|
||||
"integrity": "sha512-8HxEf+zNycj7Z8+ONhhlu+7J7Ha+L6weyCtdEeK2mN5OWJbh6n4LPU4iuJ5UlCvvNnbSXMoutY7piITEEAgl2g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/realtime-js": {
|
||||
"version": "2.99.3",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.99.3.tgz",
|
||||
"integrity": "sha512-c1azgZ2nZPczbY5k5u5iFrk1InpxN81IvNE+UBAkjrBz3yc5ALLJNkeTQwbJZT4PZBuYXEzqYGLMuh9fdTtTMg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/phoenix": "^1.6.6",
|
||||
"@types/ws": "^8.18.1",
|
||||
"tslib": "2.8.1",
|
||||
"ws": "^8.18.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/storage-js": {
|
||||
"version": "2.99.3",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.99.3.tgz",
|
||||
"integrity": "sha512-lOfIm4hInNcd8x0i1LWphnLKxec42wwbjs+vhaVAvR801Vda0UAMbTooUY6gfqgQb8v29GofqKuQMMTAsl6w/w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"iceberg-js": "^0.8.1",
|
||||
"tslib": "2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/supabase-js": {
|
||||
"version": "2.99.3",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.99.3.tgz",
|
||||
"integrity": "sha512-GuPbzoEaI51AkLw9VGhLNvnzw4PHbS3p8j2/JlvLeZNQMKwZw4aEYQIDBRtFwL5Nv7/275n9m4DHtakY8nCvgg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@supabase/auth-js": "2.99.3",
|
||||
"@supabase/functions-js": "2.99.3",
|
||||
"@supabase/postgrest-js": "2.99.3",
|
||||
"@supabase/realtime-js": "2.99.3",
|
||||
"@supabase/storage-js": "2.99.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/node": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz",
|
||||
@@ -2735,12 +2816,17 @@
|
||||
"version": "24.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.0.tgz",
|
||||
"integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/phoenix": {
|
||||
"version": "1.6.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.7.tgz",
|
||||
"integrity": "sha512-oN9ive//QSBkf19rfDv45M7eZPi0eEXylht2OLEXicu5b4KoQ1OzXIw+xDSGWxSxe1JmepRR/ZH283vsu518/Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "19.2.14",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||
@@ -2766,6 +2852,15 @@
|
||||
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/ws": {
|
||||
"version": "8.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@ungap/structured-clone": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz",
|
||||
@@ -3584,6 +3679,15 @@
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/iceberg-js": {
|
||||
"version": "0.8.1",
|
||||
"resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz",
|
||||
"integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/indent-string": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
|
||||
@@ -5778,7 +5882,6 @@
|
||||
"version": "7.16.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
|
||||
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unified": {
|
||||
@@ -6155,6 +6258,27 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.19.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
|
||||
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/xml-name-validator": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"@dnd-kit/core": "6.3.1",
|
||||
"@dnd-kit/sortable": "10.0.0",
|
||||
"@dnd-kit/utilities": "3.2.2",
|
||||
"@supabase/supabase-js": "^2.99.3",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tanstack/react-query": "5.91.3",
|
||||
"react": "19.2.4",
|
||||
|
||||
@@ -2,14 +2,17 @@ import { Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { Layout } from './components'
|
||||
import { AdminLayout } from './components/admin'
|
||||
import {
|
||||
AuthCallback,
|
||||
GenlockeDetail,
|
||||
GenlockeList,
|
||||
Home,
|
||||
JournalEntryPage,
|
||||
Login,
|
||||
NewGenlocke,
|
||||
NewRun,
|
||||
RunList,
|
||||
RunEncounters,
|
||||
Signup,
|
||||
Stats,
|
||||
} from './pages'
|
||||
import {
|
||||
@@ -28,6 +31,9 @@ function App() {
|
||||
<Routes>
|
||||
<Route path="/" element={<Layout />}>
|
||||
<Route index element={<Home />} />
|
||||
<Route path="login" element={<Login />} />
|
||||
<Route path="signup" element={<Signup />} />
|
||||
<Route path="auth/callback" element={<AuthCallback />} />
|
||||
<Route path="runs" element={<RunList />} />
|
||||
<Route path="runs/new" element={<NewRun />} />
|
||||
<Route path="runs/:runId" element={<RunEncounters />} />
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { supabase } from '../lib/supabase'
|
||||
|
||||
const API_BASE = import.meta.env['VITE_API_URL'] ?? ''
|
||||
|
||||
export class ApiError extends Error {
|
||||
@@ -10,11 +12,21 @@ export class ApiError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
async function getAuthHeaders(): Promise<Record<string, string>> {
|
||||
const { data } = await supabase.auth.getSession()
|
||||
if (data.session?.access_token) {
|
||||
return { Authorization: `Bearer ${data.session.access_token}` }
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
const authHeaders = await getAuthHeaders()
|
||||
const res = await fetch(`${API_BASE}/api/v1${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...authHeaders,
|
||||
...options?.headers,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -5,10 +5,7 @@ import type {
|
||||
UpdateJournalEntryInput,
|
||||
} from '../types/journal'
|
||||
|
||||
export function getJournalEntries(
|
||||
runId: number,
|
||||
bossResultId?: number
|
||||
): Promise<JournalEntry[]> {
|
||||
export function getJournalEntries(runId: number, bossResultId?: number): Promise<JournalEntry[]> {
|
||||
const params = bossResultId != null ? `?boss_result_id=${bossResultId}` : ''
|
||||
return api.get(`/runs/${runId}/journal${params}`)
|
||||
}
|
||||
|
||||
30
frontend/src/api/moves.ts
Normal file
30
frontend/src/api/moves.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { api } from './client'
|
||||
import type { MoveRef, AbilityRef } from '../types/game'
|
||||
|
||||
export interface PaginatedMoves {
|
||||
items: MoveRef[]
|
||||
total: number
|
||||
limit: number
|
||||
offset: number
|
||||
}
|
||||
|
||||
export interface PaginatedAbilities {
|
||||
items: AbilityRef[]
|
||||
total: number
|
||||
limit: number
|
||||
offset: number
|
||||
}
|
||||
|
||||
export function searchMoves(search: string, limit = 20): Promise<PaginatedMoves> {
|
||||
const params = new URLSearchParams()
|
||||
if (search) params.set('search', search)
|
||||
params.set('limit', String(limit))
|
||||
return api.get(`/moves?${params}`)
|
||||
}
|
||||
|
||||
export function searchAbilities(search: string, limit = 20): Promise<PaginatedAbilities> {
|
||||
const params = new URLSearchParams()
|
||||
if (search) params.set('search', search)
|
||||
params.set('limit', String(limit))
|
||||
return api.get(`/abilities?${params}`)
|
||||
}
|
||||
@@ -1,9 +1,15 @@
|
||||
import { type FormEvent, useMemo, useState } from 'react'
|
||||
import type { BossBattle, CreateBossResultInput } from '../types/game'
|
||||
import type {
|
||||
BossBattle,
|
||||
BossResultTeamMemberInput,
|
||||
CreateBossResultInput,
|
||||
EncounterDetail,
|
||||
} from '../types/game'
|
||||
import { ConditionBadge } from './ConditionBadge'
|
||||
|
||||
interface BossDefeatModalProps {
|
||||
boss: BossBattle
|
||||
aliveEncounters: EncounterDetail[]
|
||||
onSubmit: (data: CreateBossResultInput) => void
|
||||
onClose: () => void
|
||||
isPending?: boolean
|
||||
@@ -17,14 +23,43 @@ function matchVariant(labels: string[], starterName?: string | null): string | n
|
||||
return matches.length === 1 ? (matches[0] ?? null) : null
|
||||
}
|
||||
|
||||
interface TeamSelection {
|
||||
encounterId: number
|
||||
level: number
|
||||
}
|
||||
|
||||
export function BossDefeatModal({
|
||||
boss,
|
||||
aliveEncounters,
|
||||
onSubmit,
|
||||
onClose,
|
||||
isPending,
|
||||
starterName,
|
||||
}: BossDefeatModalProps) {
|
||||
const [selectedTeam, setSelectedTeam] = useState<Map<number, TeamSelection>>(new Map())
|
||||
|
||||
const toggleTeamMember = (enc: EncounterDetail) => {
|
||||
setSelectedTeam((prev) => {
|
||||
const next = new Map(prev)
|
||||
if (next.has(enc.id)) {
|
||||
next.delete(enc.id)
|
||||
} 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 })
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
const variantLabels = useMemo(() => {
|
||||
const labels = new Set<string>()
|
||||
for (const bp of boss.pokemon) {
|
||||
@@ -52,10 +87,12 @@ export function BossDefeatModal({
|
||||
|
||||
const handleSubmit = (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
const team: BossResultTeamMemberInput[] = Array.from(selectedTeam.values())
|
||||
onSubmit({
|
||||
bossBattleId: boss.id,
|
||||
result: 'won',
|
||||
attempts: 1,
|
||||
team,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -92,18 +129,93 @@ export function BossDefeatModal({
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{[...displayedPokemon]
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((bp) => (
|
||||
<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" />
|
||||
.map((bp) => {
|
||||
const moves = [bp.move1, bp.move2, bp.move3, bp.move4].filter(Boolean)
|
||||
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" />
|
||||
) : (
|
||||
<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 font-medium text-text-secondary">Lv.{bp.level}</span>
|
||||
<ConditionBadge condition={bp.conditionLabel} size="xs" />
|
||||
{bp.ability && (
|
||||
<span className="text-[10px] text-text-muted">{bp.ability.name}</span>
|
||||
)}
|
||||
{bp.heldItem && (
|
||||
<span className="text-[10px] text-yellow-500/80">{bp.heldItem}</span>
|
||||
)}
|
||||
{moves.length > 0 && (
|
||||
<div className="text-[9px] text-text-muted text-center leading-tight max-w-[80px]">
|
||||
{moves.map((m) => m!.name).join(', ')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Team selection */}
|
||||
{aliveEncounters.length > 0 && (
|
||||
<div className="px-6 py-3 border-b border-border-default">
|
||||
<p className="text-sm font-medium text-text-secondary mb-2">Your team (optional)</p>
|
||||
<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
|
||||
key={enc.id}
|
||||
className={`flex items-center gap-2 p-2 rounded border cursor-pointer transition-colors ${
|
||||
isSelected
|
||||
? 'border-accent-500 bg-accent-500/10'
|
||||
: 'border-border-default hover:bg-surface-2'
|
||||
}`}
|
||||
onClick={() => toggleTeamMember(enc)}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => toggleTeamMember(enc)}
|
||||
className="sr-only"
|
||||
/>
|
||||
{displayPokemon.spriteUrl ? (
|
||||
<img
|
||||
src={displayPokemon.spriteUrl}
|
||||
alt={displayPokemon.name}
|
||||
className="w-8 h-8"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-10 h-10 bg-surface-3 rounded-full" />
|
||||
<div className="w-8 h-8 bg-surface-3 rounded-full" />
|
||||
)}
|
||||
<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" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="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>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import { Layout } from './Layout'
|
||||
import { AuthProvider } from '../contexts/AuthContext'
|
||||
|
||||
vi.mock('../hooks/useTheme', () => ({
|
||||
useTheme: () => ({ theme: 'dark' as const, toggle: vi.fn() }),
|
||||
@@ -10,7 +11,9 @@ vi.mock('../hooks/useTheme', () => ({
|
||||
function renderLayout(initialPath = '/') {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={[initialPath]}>
|
||||
<Layout />
|
||||
<AuthProvider>
|
||||
<Layout />
|
||||
</AuthProvider>
|
||||
</MemoryRouter>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from 'react'
|
||||
import { Link, Outlet, useLocation } from 'react-router-dom'
|
||||
import { useTheme } from '../hooks/useTheme'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
|
||||
const navLinks = [
|
||||
{ to: '/runs/new', label: 'New Run' },
|
||||
@@ -71,6 +72,67 @@ function ThemeToggle() {
|
||||
)
|
||||
}
|
||||
|
||||
function UserMenu({ onAction }: { onAction?: () => void }) {
|
||||
const { user, loading, signOut } = useAuth()
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
if (loading) {
|
||||
return <div className="w-8 h-8 rounded-full bg-surface-3 animate-pulse" />
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<Link
|
||||
to="/login"
|
||||
onClick={onAction}
|
||||
className="px-3 py-2 rounded-md text-sm font-medium text-text-secondary hover:text-text-primary hover:bg-surface-3 transition-colors"
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
const email = user.email ?? ''
|
||||
const initials = email.charAt(0).toUpperCase()
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(!open)}
|
||||
className="flex items-center gap-2 p-1 rounded-full hover:bg-surface-3 transition-colors"
|
||||
>
|
||||
<div className="w-8 h-8 rounded-full bg-accent-600 flex items-center justify-center text-white text-sm font-medium">
|
||||
{initials}
|
||||
</div>
|
||||
</button>
|
||||
{open && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={() => setOpen(false)} />
|
||||
<div className="absolute right-0 mt-2 w-48 bg-surface-2 border border-border-default rounded-lg shadow-lg z-50">
|
||||
<div className="px-4 py-3 border-b border-border-default">
|
||||
<p className="text-sm text-text-primary truncate">{email}</p>
|
||||
</div>
|
||||
<div className="py-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
setOpen(false)
|
||||
onAction?.()
|
||||
await signOut()
|
||||
}}
|
||||
className="w-full text-left px-4 py-2 text-sm text-text-secondary hover:text-text-primary hover:bg-surface-3 transition-colors"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Layout() {
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const location = useLocation()
|
||||
@@ -103,6 +165,7 @@ export function Layout() {
|
||||
</NavLink>
|
||||
))}
|
||||
<ThemeToggle />
|
||||
<UserMenu />
|
||||
</div>
|
||||
{/* Mobile hamburger */}
|
||||
<div className="flex items-center gap-1 sm:hidden">
|
||||
@@ -149,6 +212,9 @@ export function Layout() {
|
||||
{link.label}
|
||||
</NavLink>
|
||||
))}
|
||||
<div className="pt-2 border-t border-border-default mt-2">
|
||||
<UserMenu onAction={() => setMenuOpen(false)} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
21
frontend/src/components/ProtectedRoute.tsx
Normal file
21
frontend/src/components/ProtectedRoute.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Navigate, useLocation } from 'react-router-dom'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
|
||||
export function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
const { user, loading } = useAuth()
|
||||
const location = useLocation()
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-accent-500" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return <Navigate to="/login" state={{ from: location }} replace />
|
||||
}
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
69
frontend/src/components/admin/AbilitySelector.tsx
Normal file
69
frontend/src/components/admin/AbilitySelector.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { useSearchAbilities } from '../../hooks/useMoves'
|
||||
|
||||
interface AbilitySelectorProps {
|
||||
label: string
|
||||
selectedId: number | null
|
||||
initialName?: string
|
||||
onChange: (id: number | null, name: string) => void
|
||||
}
|
||||
|
||||
export function AbilitySelector({
|
||||
label,
|
||||
selectedId,
|
||||
initialName,
|
||||
onChange,
|
||||
}: AbilitySelectorProps) {
|
||||
const [search, setSearch] = useState(initialName ?? '')
|
||||
const [open, setOpen] = useState(false)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const { data } = useSearchAbilities(search)
|
||||
const abilities = data?.items ?? []
|
||||
|
||||
useEffect(() => {
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClick)
|
||||
return () => document.removeEventListener('mousedown', handleClick)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<label className="block text-xs font-medium mb-0.5 text-text-secondary">{label}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value)
|
||||
setOpen(true)
|
||||
if (!e.target.value) onChange(null, '')
|
||||
}}
|
||||
onFocus={() => search && setOpen(true)}
|
||||
placeholder="Search ability..."
|
||||
className="w-full px-2 py-1.5 text-sm border rounded bg-surface-2 border-border-default"
|
||||
/>
|
||||
{open && abilities.length > 0 && (
|
||||
<ul className="absolute z-20 mt-1 w-full bg-surface-1 border border-border-default rounded shadow-lg max-h-40 overflow-y-auto">
|
||||
{abilities.map((a) => (
|
||||
<li
|
||||
key={a.id}
|
||||
onClick={() => {
|
||||
onChange(a.id, a.name)
|
||||
setSearch(a.name)
|
||||
setOpen(false)
|
||||
}}
|
||||
className={`px-2 py-1.5 cursor-pointer hover:bg-surface-2 text-sm ${
|
||||
a.id === selectedId ? 'bg-accent-900/30' : ''
|
||||
}`}
|
||||
>
|
||||
{a.name}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,38 @@
|
||||
import { type FormEvent, useState } from 'react'
|
||||
import { PokemonSelector } from './PokemonSelector'
|
||||
import { MoveSelector } from './MoveSelector'
|
||||
import { AbilitySelector } from './AbilitySelector'
|
||||
import type { BossBattle } from '../../types/game'
|
||||
import type { BossPokemonInput } from '../../types/admin'
|
||||
|
||||
const NATURES = [
|
||||
'Hardy',
|
||||
'Lonely',
|
||||
'Brave',
|
||||
'Adamant',
|
||||
'Naughty',
|
||||
'Bold',
|
||||
'Docile',
|
||||
'Relaxed',
|
||||
'Impish',
|
||||
'Lax',
|
||||
'Timid',
|
||||
'Hasty',
|
||||
'Serious',
|
||||
'Jolly',
|
||||
'Naive',
|
||||
'Modest',
|
||||
'Mild',
|
||||
'Quiet',
|
||||
'Bashful',
|
||||
'Rash',
|
||||
'Calm',
|
||||
'Gentle',
|
||||
'Sassy',
|
||||
'Careful',
|
||||
'Quirky',
|
||||
]
|
||||
|
||||
interface BossTeamEditorProps {
|
||||
boss: BossBattle
|
||||
onSave: (team: BossPokemonInput[]) => void
|
||||
@@ -15,6 +45,19 @@ interface PokemonSlot {
|
||||
pokemonName: string
|
||||
level: string
|
||||
order: number
|
||||
// Detail fields
|
||||
abilityId: number | null
|
||||
abilityName: string
|
||||
heldItem: string
|
||||
nature: string
|
||||
move1Id: number | null
|
||||
move1Name: string
|
||||
move2Id: number | null
|
||||
move2Name: string
|
||||
move3Id: number | null
|
||||
move3Name: string
|
||||
move4Id: number | null
|
||||
move4Name: string
|
||||
}
|
||||
|
||||
interface Variant {
|
||||
@@ -22,6 +65,27 @@ interface Variant {
|
||||
pokemon: PokemonSlot[]
|
||||
}
|
||||
|
||||
function createEmptySlot(order: number): PokemonSlot {
|
||||
return {
|
||||
pokemonId: null,
|
||||
pokemonName: '',
|
||||
level: '',
|
||||
order,
|
||||
abilityId: null,
|
||||
abilityName: '',
|
||||
heldItem: '',
|
||||
nature: '',
|
||||
move1Id: null,
|
||||
move1Name: '',
|
||||
move2Id: null,
|
||||
move2Name: '',
|
||||
move3Id: null,
|
||||
move3Name: '',
|
||||
move4Id: null,
|
||||
move4Name: '',
|
||||
}
|
||||
}
|
||||
|
||||
function groupByVariant(boss: BossBattle): Variant[] {
|
||||
const sorted = [...boss.pokemon].sort((a, b) => a.order - b.order)
|
||||
const map = new Map<string | null, PokemonSlot[]>()
|
||||
@@ -34,25 +98,30 @@ function groupByVariant(boss: BossBattle): Variant[] {
|
||||
pokemonName: bp.pokemon.name,
|
||||
level: String(bp.level),
|
||||
order: bp.order,
|
||||
abilityId: bp.abilityId,
|
||||
abilityName: bp.ability?.name ?? '',
|
||||
heldItem: bp.heldItem ?? '',
|
||||
nature: bp.nature ?? '',
|
||||
move1Id: bp.move1Id,
|
||||
move1Name: bp.move1?.name ?? '',
|
||||
move2Id: bp.move2Id,
|
||||
move2Name: bp.move2?.name ?? '',
|
||||
move3Id: bp.move3Id,
|
||||
move3Name: bp.move3?.name ?? '',
|
||||
move4Id: bp.move4Id,
|
||||
move4Name: bp.move4?.name ?? '',
|
||||
})
|
||||
}
|
||||
|
||||
if (map.size === 0) {
|
||||
return [
|
||||
{
|
||||
label: null,
|
||||
pokemon: [{ pokemonId: null, pokemonName: '', level: '', order: 1 }],
|
||||
},
|
||||
]
|
||||
return [{ label: null, pokemon: [createEmptySlot(1)] }]
|
||||
}
|
||||
|
||||
const variants: Variant[] = []
|
||||
// null (default) first
|
||||
if (map.has(null)) {
|
||||
variants.push({ label: null, pokemon: map.get(null)! })
|
||||
map.delete(null)
|
||||
}
|
||||
// Then alphabetical
|
||||
const remaining = [...map.entries()].sort((a, b) => (a[0] ?? '').localeCompare(b[0] ?? ''))
|
||||
for (const [label, pokemon] of remaining) {
|
||||
variants.push({ label, pokemon })
|
||||
@@ -65,9 +134,19 @@ export function BossTeamEditor({ boss, onSave, onClose, isSaving }: BossTeamEdit
|
||||
const [activeTab, setActiveTab] = useState(0)
|
||||
const [newVariantName, setNewVariantName] = useState('')
|
||||
const [showAddVariant, setShowAddVariant] = useState(false)
|
||||
const [expandedSlots, setExpandedSlots] = useState<Set<string>>(new Set())
|
||||
|
||||
const activeVariant = variants[activeTab] ?? variants[0]
|
||||
|
||||
const toggleExpanded = (key: string) => {
|
||||
setExpandedSlots((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(key)) next.delete(key)
|
||||
else next.add(key)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const updateVariant = (tabIndex: number, updater: (v: Variant) => Variant) => {
|
||||
setVariants((prev) => prev.map((v, i) => (i === tabIndex ? updater(v) : v)))
|
||||
}
|
||||
@@ -75,15 +154,7 @@ export function BossTeamEditor({ boss, onSave, onClose, isSaving }: BossTeamEdit
|
||||
const addSlot = () => {
|
||||
updateVariant(activeTab, (v) => ({
|
||||
...v,
|
||||
pokemon: [
|
||||
...v.pokemon,
|
||||
{
|
||||
pokemonId: null,
|
||||
pokemonName: '',
|
||||
level: '',
|
||||
order: v.pokemon.length + 1,
|
||||
},
|
||||
],
|
||||
pokemon: [...v.pokemon, createEmptySlot(v.pokemon.length + 1)],
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -96,10 +167,10 @@ export function BossTeamEditor({ boss, onSave, onClose, isSaving }: BossTeamEdit
|
||||
}))
|
||||
}
|
||||
|
||||
const updateSlot = (index: number, field: string, value: number | string | null) => {
|
||||
const updateSlot = (index: number, updates: Partial<PokemonSlot>) => {
|
||||
updateVariant(activeTab, (v) => ({
|
||||
...v,
|
||||
pokemon: v.pokemon.map((item, i) => (i === index ? { ...item, [field]: value } : item)),
|
||||
pokemon: v.pokemon.map((item, i) => (i === index ? { ...item, ...updates } : item)),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -107,13 +178,7 @@ export function BossTeamEditor({ boss, onSave, onClose, isSaving }: BossTeamEdit
|
||||
const name = newVariantName.trim()
|
||||
if (!name) return
|
||||
if (variants.some((v) => v.label === name)) return
|
||||
setVariants((prev) => [
|
||||
...prev,
|
||||
{
|
||||
label: name,
|
||||
pokemon: [{ pokemonId: null, pokemonName: '', level: '', order: 1 }],
|
||||
},
|
||||
])
|
||||
setVariants((prev) => [...prev, { label: name, pokemon: [createEmptySlot(1)] }])
|
||||
setActiveTab(variants.length)
|
||||
setNewVariantName('')
|
||||
setShowAddVariant(false)
|
||||
@@ -141,6 +206,13 @@ export function BossTeamEditor({ boss, onSave, onClose, isSaving }: BossTeamEdit
|
||||
level: Number(p.level),
|
||||
order: i + 1,
|
||||
conditionLabel,
|
||||
abilityId: p.abilityId,
|
||||
heldItem: p.heldItem || null,
|
||||
nature: p.nature || null,
|
||||
move1Id: p.move1Id,
|
||||
move2Id: p.move2Id,
|
||||
move3Id: p.move3Id,
|
||||
move4Id: p.move4Id,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -150,7 +222,7 @@ export function BossTeamEditor({ boss, onSave, onClose, isSaving }: BossTeamEdit
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="fixed inset-0 bg-black/50" onClick={onClose} />
|
||||
<div className="relative bg-surface-1 rounded-lg shadow-xl max-w-lg w-full mx-4 max-h-[90vh] overflow-y-auto">
|
||||
<div className="relative bg-surface-1 rounded-lg shadow-xl max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
|
||||
<div className="px-6 py-4 border-b border-border-default">
|
||||
<h2 className="text-lg font-semibold">{boss.name}'s Team</h2>
|
||||
</div>
|
||||
@@ -209,11 +281,7 @@ export function BossTeamEditor({ boss, onSave, onClose, isSaving }: BossTeamEdit
|
||||
className="px-2 py-1 text-sm border rounded bg-surface-2 border-border-default w-40"
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addVariant}
|
||||
className="px-2 py-1 text-sm text-text-link"
|
||||
>
|
||||
<button type="button" onClick={addVariant} className="px-2 py-1 text-sm text-text-link">
|
||||
Add
|
||||
</button>
|
||||
<button
|
||||
@@ -228,38 +296,149 @@ export function BossTeamEditor({ boss, onSave, onClose, isSaving }: BossTeamEdit
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="px-6 py-4 space-y-3">
|
||||
{activeVariant?.pokemon.map((slot, index) => (
|
||||
<div key={`${activeTab}-${index}`} className="flex items-end gap-2">
|
||||
<div className="flex-1">
|
||||
<PokemonSelector
|
||||
label={`Pokemon ${index + 1}`}
|
||||
selectedId={slot.pokemonId}
|
||||
initialName={slot.pokemonName}
|
||||
onChange={(id) => updateSlot(index, 'pokemonId', id)}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-20">
|
||||
<label className="block text-sm font-medium mb-1">Level</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
value={slot.level}
|
||||
onChange={(e) => updateSlot(index, 'level', e.target.value)}
|
||||
className="w-full px-3 py-2 border rounded-md bg-surface-2 border-border-default"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeSlot(index)}
|
||||
className="px-2 py-2 text-red-500 hover:text-red-700 text-sm"
|
||||
title="Remove"
|
||||
<div className="px-6 py-4 space-y-4">
|
||||
{activeVariant?.pokemon.map((slot, index) => {
|
||||
const slotKey = `${activeTab}-${index}`
|
||||
const isExpanded = expandedSlots.has(slotKey)
|
||||
const hasDetails =
|
||||
slot.abilityId ||
|
||||
slot.heldItem ||
|
||||
slot.nature ||
|
||||
slot.move1Id ||
|
||||
slot.move2Id ||
|
||||
slot.move3Id ||
|
||||
slot.move4Id
|
||||
|
||||
return (
|
||||
<div
|
||||
key={slotKey}
|
||||
className="border border-border-default rounded-lg p-3 bg-surface-0"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{/* Main row: Pokemon + Level */}
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex-1">
|
||||
<PokemonSelector
|
||||
label={`Pokemon ${index + 1}`}
|
||||
selectedId={slot.pokemonId}
|
||||
initialName={slot.pokemonName}
|
||||
onChange={(id) => updateSlot(index, { pokemonId: id })}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-20">
|
||||
<label className="block text-sm font-medium mb-1">Level</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
value={slot.level}
|
||||
onChange={(e) => updateSlot(index, { level: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-md bg-surface-2 border-border-default"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleExpanded(slotKey)}
|
||||
className={`px-2 py-2 text-sm transition-colors ${
|
||||
hasDetails ? 'text-accent-500' : 'text-text-tertiary hover:text-text-secondary'
|
||||
}`}
|
||||
title={isExpanded ? 'Hide details' : 'Show details'}
|
||||
>
|
||||
{isExpanded ? '▼' : '▶'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeSlot(index)}
|
||||
className="px-2 py-2 text-red-500 hover:text-red-700 text-sm"
|
||||
title="Remove"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Expandable details */}
|
||||
{isExpanded && (
|
||||
<div className="mt-3 pt-3 border-t border-border-default space-y-3">
|
||||
{/* Row 1: Ability, Held Item, Nature */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<AbilitySelector
|
||||
label="Ability"
|
||||
selectedId={slot.abilityId}
|
||||
initialName={slot.abilityName}
|
||||
onChange={(id, name) =>
|
||||
updateSlot(index, { abilityId: id, abilityName: name })
|
||||
}
|
||||
/>
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-0.5 text-text-secondary">
|
||||
Held Item
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={slot.heldItem}
|
||||
onChange={(e) => updateSlot(index, { heldItem: e.target.value })}
|
||||
placeholder="e.g. Leftovers"
|
||||
className="w-full px-2 py-1.5 text-sm border rounded bg-surface-2 border-border-default"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-0.5 text-text-secondary">
|
||||
Nature
|
||||
</label>
|
||||
<select
|
||||
value={slot.nature}
|
||||
onChange={(e) => updateSlot(index, { nature: e.target.value })}
|
||||
className="w-full px-2 py-1.5 text-sm border rounded bg-surface-2 border-border-default"
|
||||
>
|
||||
<option value="">—</option>
|
||||
{NATURES.map((n) => (
|
||||
<option key={n} value={n}>
|
||||
{n}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 2: Moves */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<MoveSelector
|
||||
label="Move 1"
|
||||
selectedId={slot.move1Id}
|
||||
initialName={slot.move1Name}
|
||||
onChange={(id, name) =>
|
||||
updateSlot(index, { move1Id: id, move1Name: name })
|
||||
}
|
||||
/>
|
||||
<MoveSelector
|
||||
label="Move 2"
|
||||
selectedId={slot.move2Id}
|
||||
initialName={slot.move2Name}
|
||||
onChange={(id, name) =>
|
||||
updateSlot(index, { move2Id: id, move2Name: name })
|
||||
}
|
||||
/>
|
||||
<MoveSelector
|
||||
label="Move 3"
|
||||
selectedId={slot.move3Id}
|
||||
initialName={slot.move3Name}
|
||||
onChange={(id, name) =>
|
||||
updateSlot(index, { move3Id: id, move3Name: name })
|
||||
}
|
||||
/>
|
||||
<MoveSelector
|
||||
label="Move 4"
|
||||
selectedId={slot.move4Id}
|
||||
initialName={slot.move4Name}
|
||||
onChange={(id, name) =>
|
||||
updateSlot(index, { move4Id: id, move4Name: name })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{activeVariant && activeVariant.pokemon.length < 6 && (
|
||||
<button
|
||||
|
||||
64
frontend/src/components/admin/MoveSelector.tsx
Normal file
64
frontend/src/components/admin/MoveSelector.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { useSearchMoves } from '../../hooks/useMoves'
|
||||
|
||||
interface MoveSelectorProps {
|
||||
label: string
|
||||
selectedId: number | null
|
||||
initialName?: string
|
||||
onChange: (id: number | null, name: string) => void
|
||||
}
|
||||
|
||||
export function MoveSelector({ label, selectedId, initialName, onChange }: MoveSelectorProps) {
|
||||
const [search, setSearch] = useState(initialName ?? '')
|
||||
const [open, setOpen] = useState(false)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const { data } = useSearchMoves(search)
|
||||
const moves = data?.items ?? []
|
||||
|
||||
useEffect(() => {
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClick)
|
||||
return () => document.removeEventListener('mousedown', handleClick)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<label className="block text-xs font-medium mb-0.5 text-text-secondary">{label}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value)
|
||||
setOpen(true)
|
||||
if (!e.target.value) onChange(null, '')
|
||||
}}
|
||||
onFocus={() => search && setOpen(true)}
|
||||
placeholder="Search move..."
|
||||
className="w-full px-2 py-1.5 text-sm border rounded bg-surface-2 border-border-default"
|
||||
/>
|
||||
{open && moves.length > 0 && (
|
||||
<ul className="absolute z-20 mt-1 w-full bg-surface-1 border border-border-default rounded shadow-lg max-h-40 overflow-y-auto">
|
||||
{moves.map((m) => (
|
||||
<li
|
||||
key={m.id}
|
||||
onClick={() => {
|
||||
onChange(m.id, m.name)
|
||||
setSearch(m.name)
|
||||
setOpen(false)
|
||||
}}
|
||||
className={`px-2 py-1.5 cursor-pointer hover:bg-surface-2 text-sm ${
|
||||
m.id === selectedId ? 'bg-accent-900/30' : ''
|
||||
}`}
|
||||
>
|
||||
{m.name}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
export { CustomRulesDisplay } from './CustomRulesDisplay'
|
||||
export { ProtectedRoute } from './ProtectedRoute'
|
||||
export { EggEncounterModal } from './EggEncounterModal'
|
||||
export { EncounterMethodBadge } from './EncounterMethodBadge'
|
||||
export { EncounterModal } from './EncounterModal'
|
||||
|
||||
@@ -6,8 +6,8 @@ import type { BossResult, BossBattle } from '../../types/game'
|
||||
|
||||
interface JournalEditorProps {
|
||||
entry?: JournalEntry | null
|
||||
bossResults?: BossResult[]
|
||||
bosses?: BossBattle[]
|
||||
bossResults?: BossResult[] | undefined
|
||||
bosses?: BossBattle[] | undefined
|
||||
onSave: (data: { title: string; body: string; bossResultId: number | null }) => void
|
||||
onDelete?: () => void
|
||||
onCancel: () => void
|
||||
@@ -67,7 +67,10 @@ export function JournalEditor({
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="journal-title" className="block text-sm font-medium text-text-secondary mb-1">
|
||||
<label
|
||||
htmlFor="journal-title"
|
||||
className="block text-sm font-medium text-text-secondary mb-1"
|
||||
>
|
||||
Title
|
||||
</label>
|
||||
<input
|
||||
@@ -82,7 +85,10 @@ export function JournalEditor({
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="journal-boss" className="block text-sm font-medium text-text-secondary mb-1">
|
||||
<label
|
||||
htmlFor="journal-boss"
|
||||
className="block text-sm font-medium text-text-secondary mb-1"
|
||||
>
|
||||
Linked Boss Battle (optional)
|
||||
</label>
|
||||
<select
|
||||
|
||||
@@ -5,8 +5,8 @@ import type { BossResult, BossBattle } from '../../types/game'
|
||||
|
||||
interface JournalEntryViewProps {
|
||||
entry: JournalEntry
|
||||
bossResult?: BossResult | null
|
||||
boss?: BossBattle | null
|
||||
bossResult?: BossResult | null | undefined
|
||||
boss?: BossBattle | null | undefined
|
||||
onEdit?: () => void
|
||||
onBack?: () => void
|
||||
}
|
||||
@@ -38,7 +38,12 @@ export function JournalEntryView({
|
||||
className="text-text-secondary hover:text-text-primary transition-colors flex items-center gap-1"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 19l-7-7 7-7"
|
||||
/>
|
||||
</svg>
|
||||
Back to Journal
|
||||
</button>
|
||||
|
||||
@@ -19,7 +19,10 @@ function formatDate(dateString: string): string {
|
||||
}
|
||||
|
||||
function getPreviewSnippet(body: string, maxLength = 120): string {
|
||||
const stripped = body.replace(/[#*_`~[\]]/g, '').replace(/\n+/g, ' ').trim()
|
||||
const stripped = body
|
||||
.replace(/[#*_`~[\]]/g, '')
|
||||
.replace(/\n+/g, ' ')
|
||||
.trim()
|
||||
if (stripped.length <= maxLength) return stripped
|
||||
return stripped.slice(0, maxLength).trim() + '...'
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@ import type { BossResult, BossBattle } from '../../types/game'
|
||||
|
||||
interface JournalSectionProps {
|
||||
runId: number
|
||||
bossResults?: BossResult[]
|
||||
bosses?: BossBattle[]
|
||||
bossResults?: BossResult[] | undefined
|
||||
bosses?: BossBattle[] | undefined
|
||||
}
|
||||
|
||||
type Mode = 'list' | 'new'
|
||||
|
||||
93
frontend/src/contexts/AuthContext.tsx
Normal file
93
frontend/src/contexts/AuthContext.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import { createContext, useContext, useEffect, useState, useCallback, useMemo } from 'react'
|
||||
import type { User, Session, AuthError } from '@supabase/supabase-js'
|
||||
import { supabase } from '../lib/supabase'
|
||||
|
||||
interface AuthState {
|
||||
user: User | null
|
||||
session: Session | null
|
||||
loading: boolean
|
||||
}
|
||||
|
||||
interface AuthContextValue extends AuthState {
|
||||
signInWithEmail: (email: string, password: string) => Promise<{ error: AuthError | null }>
|
||||
signUpWithEmail: (email: string, password: string) => Promise<{ error: AuthError | null }>
|
||||
signInWithGoogle: () => Promise<{ error: AuthError | null }>
|
||||
signInWithDiscord: () => Promise<{ error: AuthError | null }>
|
||||
signOut: () => Promise<void>
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null)
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const [state, setState] = useState<AuthState>({
|
||||
user: null,
|
||||
session: null,
|
||||
loading: true,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
supabase.auth.getSession().then(({ data: { session } }) => {
|
||||
setState({ user: session?.user ?? null, session, loading: false })
|
||||
})
|
||||
|
||||
const {
|
||||
data: { subscription },
|
||||
} = supabase.auth.onAuthStateChange((_event, session) => {
|
||||
setState({ user: session?.user ?? null, session, loading: false })
|
||||
})
|
||||
|
||||
return () => subscription.unsubscribe()
|
||||
}, [])
|
||||
|
||||
const signInWithEmail = useCallback(async (email: string, password: string) => {
|
||||
const { error } = await supabase.auth.signInWithPassword({ email, password })
|
||||
return { error }
|
||||
}, [])
|
||||
|
||||
const signUpWithEmail = useCallback(async (email: string, password: string) => {
|
||||
const { error } = await supabase.auth.signUp({ email, password })
|
||||
return { error }
|
||||
}, [])
|
||||
|
||||
const signInWithGoogle = useCallback(async () => {
|
||||
const { error } = await supabase.auth.signInWithOAuth({
|
||||
provider: 'google',
|
||||
options: { redirectTo: `${window.location.origin}/auth/callback` },
|
||||
})
|
||||
return { error }
|
||||
}, [])
|
||||
|
||||
const signInWithDiscord = useCallback(async () => {
|
||||
const { error } = await supabase.auth.signInWithOAuth({
|
||||
provider: 'discord',
|
||||
options: { redirectTo: `${window.location.origin}/auth/callback` },
|
||||
})
|
||||
return { error }
|
||||
}, [])
|
||||
|
||||
const signOut = useCallback(async () => {
|
||||
await supabase.auth.signOut()
|
||||
}, [])
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
...state,
|
||||
signInWithEmail,
|
||||
signUpWithEmail,
|
||||
signInWithGoogle,
|
||||
signInWithDiscord,
|
||||
signOut,
|
||||
}),
|
||||
[state, signInWithEmail, signUpWithEmail, signInWithGoogle, signInWithDiscord, signOut]
|
||||
)
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext)
|
||||
if (!context) {
|
||||
throw new Error('useAuth must be used within an AuthProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
20
frontend/src/hooks/useMoves.ts
Normal file
20
frontend/src/hooks/useMoves.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { searchMoves, searchAbilities } from '../api/moves'
|
||||
|
||||
export function useSearchMoves(search: string, limit = 20) {
|
||||
return useQuery({
|
||||
queryKey: ['moves', 'search', search, limit],
|
||||
queryFn: () => searchMoves(search, limit),
|
||||
enabled: search.length > 0,
|
||||
staleTime: 60_000,
|
||||
})
|
||||
}
|
||||
|
||||
export function useSearchAbilities(search: string, limit = 20) {
|
||||
return useQuery({
|
||||
queryKey: ['abilities', 'search', search, limit],
|
||||
queryFn: () => searchAbilities(search, limit),
|
||||
enabled: search.length > 0,
|
||||
staleTime: 60_000,
|
||||
})
|
||||
}
|
||||
14
frontend/src/lib/supabase.ts
Normal file
14
frontend/src/lib/supabase.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { createClient, type SupabaseClient } from '@supabase/supabase-js'
|
||||
|
||||
const supabaseUrl = import.meta.env['VITE_SUPABASE_URL'] ?? ''
|
||||
const supabaseAnonKey = import.meta.env['VITE_SUPABASE_ANON_KEY'] ?? ''
|
||||
|
||||
function createSupabaseClient(): SupabaseClient {
|
||||
if (!supabaseUrl || !supabaseAnonKey) {
|
||||
// Return a stub client for tests/dev without Supabase configured
|
||||
return createClient('http://localhost:54321', 'stub-key')
|
||||
}
|
||||
return createClient(supabaseUrl, supabaseAnonKey)
|
||||
}
|
||||
|
||||
export const supabase = createSupabaseClient()
|
||||
@@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { Toaster } from 'sonner'
|
||||
import { AuthProvider } from './contexts/AuthContext'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
@@ -19,8 +20,10 @@ createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
<Toaster position="bottom-right" richColors />
|
||||
<AuthProvider>
|
||||
<App />
|
||||
<Toaster position="bottom-right" richColors />
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
</StrictMode>
|
||||
|
||||
24
frontend/src/pages/AuthCallback.tsx
Normal file
24
frontend/src/pages/AuthCallback.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { supabase } from '../lib/supabase'
|
||||
|
||||
export function AuthCallback() {
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
supabase.auth.onAuthStateChange((event) => {
|
||||
if (event === 'SIGNED_IN') {
|
||||
navigate('/', { replace: true })
|
||||
}
|
||||
})
|
||||
}, [navigate])
|
||||
|
||||
return (
|
||||
<div className="min-h-[80vh] flex items-center justify-center">
|
||||
<div className="text-center space-y-4">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-accent-500 mx-auto" />
|
||||
<p className="text-text-secondary">Completing sign in...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,13 @@
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { useGenlocke } from '../hooks/useGenlockes'
|
||||
import { usePokemonFamilies } from '../hooks/usePokemon'
|
||||
import { CustomRulesDisplay, GenlockeGraveyard, GenlockeLineage, StatCard, RuleBadges } from '../components'
|
||||
import {
|
||||
CustomRulesDisplay,
|
||||
GenlockeGraveyard,
|
||||
GenlockeLineage,
|
||||
StatCard,
|
||||
RuleBadges,
|
||||
} from '../components'
|
||||
import type { GenlockeLegDetail, RetiredPokemon, RunStatus } from '../types'
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
|
||||
@@ -2,11 +2,7 @@ import { useState } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { useRun } from '../hooks/useRuns'
|
||||
import { useBossResults, useGameBosses } from '../hooks/useBosses'
|
||||
import {
|
||||
useJournalEntry,
|
||||
useUpdateJournalEntry,
|
||||
useDeleteJournalEntry,
|
||||
} from '../hooks/useJournal'
|
||||
import { useJournalEntry, useUpdateJournalEntry, useDeleteJournalEntry } from '../hooks/useJournal'
|
||||
import { JournalEntryView } from '../components/journal/JournalEntryView'
|
||||
import { JournalEditor } from '../components/journal/JournalEditor'
|
||||
|
||||
|
||||
154
frontend/src/pages/Login.tsx
Normal file
154
frontend/src/pages/Login.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
import { useState } from 'react'
|
||||
import { Link, useNavigate, useLocation } from 'react-router-dom'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
|
||||
export function Login() {
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const { signInWithEmail, signInWithGoogle, signInWithDiscord } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
|
||||
const from = (location.state as { from?: { pathname: string } })?.from?.pathname ?? '/'
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
setLoading(true)
|
||||
|
||||
const { error } = await signInWithEmail(email, password)
|
||||
setLoading(false)
|
||||
|
||||
if (error) {
|
||||
setError(error.message)
|
||||
} else {
|
||||
navigate(from, { replace: true })
|
||||
}
|
||||
}
|
||||
|
||||
async function handleGoogleLogin() {
|
||||
setError(null)
|
||||
const { error } = await signInWithGoogle()
|
||||
if (error) setError(error.message)
|
||||
}
|
||||
|
||||
async function handleDiscordLogin() {
|
||||
setError(null)
|
||||
const { error } = await signInWithDiscord()
|
||||
if (error) setError(error.message)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-[80vh] flex items-center justify-center px-4">
|
||||
<div className="w-full max-w-sm space-y-6">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-bold">Welcome back</h1>
|
||||
<p className="text-text-secondary mt-1">Sign in to your account</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-500/10 border border-red-500/20 text-red-400 px-4 py-3 rounded-lg text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-text-secondary mb-1">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
className="w-full px-3 py-2 bg-surface-2 border border-border-default rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="block text-sm font-medium text-text-secondary mb-1"
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
className="w-full px-3 py-2 bg-surface-2 border border-border-default rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-2 px-4 bg-accent-600 hover:bg-accent-700 disabled:opacity-50 disabled:cursor-not-allowed text-white font-medium rounded-lg transition-colors"
|
||||
>
|
||||
{loading ? 'Signing in...' : 'Sign in'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-border-default" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-sm">
|
||||
<span className="px-2 bg-surface-0 text-text-tertiary">Or continue with</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleGoogleLogin}
|
||||
className="flex items-center justify-center gap-2 py-2 px-4 bg-surface-2 hover:bg-surface-3 border border-border-default rounded-lg transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5" viewBox="0 0 24 24">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||
/>
|
||||
</svg>
|
||||
Google
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDiscordLogin}
|
||||
className="flex items-center justify-center gap-2 py-2 px-4 bg-surface-2 hover:bg-surface-3 border border-border-default rounded-lg transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z" />
|
||||
</svg>
|
||||
Discord
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-sm text-text-secondary">
|
||||
Don't have an account?{' '}
|
||||
<Link to="/signup" className="text-accent-400 hover:text-accent-300">
|
||||
Sign up
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -115,8 +115,8 @@ export function NewGenlocke() {
|
||||
// In preset modes, filter out regions already used.
|
||||
const availableRegions =
|
||||
preset === 'custom'
|
||||
? regions ?? []
|
||||
: regions?.filter((r) => !legs.some((l) => l.region === r.name)) ?? []
|
||||
? (regions ?? [])
|
||||
: (regions?.filter((r) => !legs.some((l) => l.region === r.name)) ?? [])
|
||||
|
||||
const usedRegionNames = new Set(legs.map((l) => l.region))
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useNavigate } from 'react-router-dom'
|
||||
import { GameGrid, RulesConfiguration, StepIndicator } from '../components'
|
||||
import { useGames, useGameRoutes } from '../hooks/useGames'
|
||||
import { useCreateRun, useRuns, useNamingCategories } from '../hooks/useRuns'
|
||||
import type { Game, NuzlockeRules } from '../types'
|
||||
import type { Game, NuzlockeRules, RunVisibility } from '../types'
|
||||
import { DEFAULT_RULES } from '../types'
|
||||
import { RULE_DEFINITIONS } from '../types/rules'
|
||||
|
||||
@@ -21,6 +21,7 @@ export function NewRun() {
|
||||
const [rules, setRules] = useState<NuzlockeRules>(DEFAULT_RULES)
|
||||
const [runName, setRunName] = useState('')
|
||||
const [namingScheme, setNamingScheme] = useState<string | null>(null)
|
||||
const [visibility, setVisibility] = useState<RunVisibility>('public')
|
||||
const { data: routes } = useGameRoutes(selectedGame?.id ?? null)
|
||||
|
||||
const hiddenRules = useMemo(() => {
|
||||
@@ -46,7 +47,7 @@ export function NewRun() {
|
||||
const handleCreate = () => {
|
||||
if (!selectedGame) return
|
||||
createRun.mutate(
|
||||
{ gameId: selectedGame.id, name: runName, rules, namingScheme },
|
||||
{ gameId: selectedGame.id, name: runName, rules, namingScheme, visibility },
|
||||
{ onSuccess: (data) => navigate(`/runs/${data.id}`) }
|
||||
)
|
||||
}
|
||||
@@ -195,6 +196,29 @@ export function NewRun() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="visibility"
|
||||
className="block text-sm font-medium text-text-secondary mb-1"
|
||||
>
|
||||
Visibility
|
||||
</label>
|
||||
<select
|
||||
id="visibility"
|
||||
value={visibility}
|
||||
onChange={(e) => setVisibility(e.target.value as RunVisibility)}
|
||||
className="w-full px-3 py-2 rounded-lg border border-border-default bg-surface-2 text-text-primary focus:outline-none focus:ring-2 focus:ring-accent-400 focus:border-transparent"
|
||||
>
|
||||
<option value="public">Public</option>
|
||||
<option value="private">Private</option>
|
||||
</select>
|
||||
<p className="mt-1 text-xs text-text-tertiary">
|
||||
{visibility === 'private'
|
||||
? 'Only you will be able to see this run'
|
||||
: 'Anyone can view this run'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border-default pt-4">
|
||||
<h3 className="text-sm font-medium text-text-tertiary mb-2">Summary</h3>
|
||||
<dl className="space-y-1 text-sm">
|
||||
@@ -223,6 +247,10 @@ export function NewRun() {
|
||||
: 'None'}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-text-tertiary">Visibility</dt>
|
||||
<dd className="text-text-primary font-medium capitalize">{visibility}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useParams, Link } from 'react-router-dom'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { useRun, useUpdateRun, useNamingCategories } from '../hooks/useRuns'
|
||||
import { useGameRoutes } from '../hooks/useGames'
|
||||
import { useCreateEncounter, useUpdateEncounter } from '../hooks/useEncounters'
|
||||
import { CustomRulesDisplay, StatCard, PokemonCard, RuleBadges, StatusChangeModal, EndRunModal } from '../components'
|
||||
import type { RunStatus, EncounterDetail } from '../types'
|
||||
import {
|
||||
CustomRulesDisplay,
|
||||
StatCard,
|
||||
PokemonCard,
|
||||
RuleBadges,
|
||||
StatusChangeModal,
|
||||
EndRunModal,
|
||||
} from '../components'
|
||||
import type { RunStatus, EncounterDetail, RunVisibility } from '../types'
|
||||
|
||||
type TeamSortKey = 'route' | 'level' | 'species' | 'dex'
|
||||
|
||||
@@ -49,6 +57,7 @@ export function RunDashboard() {
|
||||
const runIdNum = Number(runId)
|
||||
const { data: run, isLoading, error } = useRun(runIdNum)
|
||||
const { data: routes } = useGameRoutes(run?.gameId ?? null)
|
||||
const { user } = useAuth()
|
||||
const createEncounter = useCreateEncounter(runIdNum)
|
||||
const updateEncounter = useUpdateEncounter(runIdNum)
|
||||
const updateRun = useUpdateRun(runIdNum)
|
||||
@@ -57,6 +66,9 @@ export function RunDashboard() {
|
||||
const [showEndRun, setShowEndRun] = useState(false)
|
||||
const [teamSort, setTeamSort] = useState<TeamSortKey>('route')
|
||||
|
||||
const isOwner = user && run?.owner?.id === user.id
|
||||
const canEdit = isOwner || !run?.owner
|
||||
|
||||
const encounters = run?.encounters ?? []
|
||||
const alive = useMemo(
|
||||
() =>
|
||||
@@ -190,11 +202,31 @@ export function RunDashboard() {
|
||||
<CustomRulesDisplay customRules={run.rules?.customRules ?? ''} />
|
||||
</div>
|
||||
|
||||
{/* Visibility */}
|
||||
{canEdit && (
|
||||
<div className="mb-6">
|
||||
<h2 className="text-sm font-medium text-text-tertiary mb-2">Visibility</h2>
|
||||
<select
|
||||
value={run.visibility}
|
||||
onChange={(e) => updateRun.mutate({ visibility: e.target.value as RunVisibility })}
|
||||
className="text-sm border border-border-default rounded-lg px-3 py-1.5 bg-surface-1 text-text-primary"
|
||||
>
|
||||
<option value="public">Public</option>
|
||||
<option value="private">Private</option>
|
||||
</select>
|
||||
<p className="mt-1 text-xs text-text-tertiary">
|
||||
{run.visibility === 'private'
|
||||
? 'Only you can see this run'
|
||||
: 'Anyone can view this run'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Naming Scheme */}
|
||||
{namingCategories && namingCategories.length > 0 && (
|
||||
<div className="mb-6">
|
||||
<h2 className="text-sm font-medium text-text-tertiary mb-2">Naming Scheme</h2>
|
||||
{isActive ? (
|
||||
{isActive && canEdit ? (
|
||||
<select
|
||||
value={run.namingScheme ?? ''}
|
||||
onChange={(e) => updateRun.mutate({ namingScheme: e.target.value || null })}
|
||||
@@ -246,7 +278,7 @@ export function RunDashboard() {
|
||||
<PokemonCard
|
||||
key={enc.id}
|
||||
encounter={enc}
|
||||
onClick={isActive ? () => setSelectedEncounter(enc) : undefined}
|
||||
onClick={isActive && canEdit ? () => setSelectedEncounter(enc) : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -263,7 +295,7 @@ export function RunDashboard() {
|
||||
key={enc.id}
|
||||
encounter={enc}
|
||||
showFaintLevel
|
||||
onClick={isActive ? () => setSelectedEncounter(enc) : undefined}
|
||||
onClick={isActive && canEdit ? () => setSelectedEncounter(enc) : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -272,7 +304,7 @@ export function RunDashboard() {
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="mt-8 flex gap-3">
|
||||
{isActive && (
|
||||
{isActive && canEdit && (
|
||||
<>
|
||||
<Link
|
||||
to={`/runs/${runId}/encounters`}
|
||||
|
||||
@@ -246,19 +246,33 @@ function BossTeamPreview({
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{[...displayed]
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((bp) => (
|
||||
<div key={bp.id} className="flex items-center gap-1">
|
||||
{bp.pokemon.spriteUrl ? (
|
||||
<img src={bp.pokemon.spriteUrl} alt={bp.pokemon.name} className="w-20 h-20" />
|
||||
) : (
|
||||
<div className="w-20 h-20 bg-surface-3 rounded-full" />
|
||||
)}
|
||||
<div className="flex flex-col items-start gap-0.5">
|
||||
<span className="text-xs text-text-tertiary">Lvl {bp.level}</span>
|
||||
<ConditionBadge condition={bp.conditionLabel} size="xs" />
|
||||
.map((bp) => {
|
||||
const moves = [bp.move1, bp.move2, bp.move3, bp.move4].filter(Boolean)
|
||||
return (
|
||||
<div key={bp.id} className="flex items-center gap-1">
|
||||
{bp.pokemon.spriteUrl ? (
|
||||
<img src={bp.pokemon.spriteUrl} alt={bp.pokemon.name} className="w-20 h-20" />
|
||||
) : (
|
||||
<div className="w-20 h-20 bg-surface-3 rounded-full" />
|
||||
)}
|
||||
<div className="flex flex-col items-start gap-0.5">
|
||||
<span className="text-xs text-text-tertiary">Lvl {bp.level}</span>
|
||||
<ConditionBadge condition={bp.conditionLabel} size="xs" />
|
||||
{bp.ability && (
|
||||
<span className="text-[10px] text-text-muted">{bp.ability.name}</span>
|
||||
)}
|
||||
{bp.heldItem && (
|
||||
<span className="text-[10px] text-yellow-500/80">{bp.heldItem}</span>
|
||||
)}
|
||||
{moves.length > 0 && (
|
||||
<div className="text-[9px] text-text-muted leading-tight">
|
||||
{moves.map((m) => m!.name).join(', ')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -663,6 +677,28 @@ export function RunEncounters() {
|
||||
return set
|
||||
}, [bossResults])
|
||||
|
||||
// Map encounter ID to encounter detail for team display
|
||||
const encounterById = useMemo(() => {
|
||||
const map = new Map<number, EncounterDetail>()
|
||||
if (run) {
|
||||
for (const enc of run.encounters) {
|
||||
map.set(enc.id, enc)
|
||||
}
|
||||
}
|
||||
return map
|
||||
}, [run])
|
||||
|
||||
// Map boss battle ID to result for team snapshot
|
||||
const bossResultByBattleId = useMemo(() => {
|
||||
const map = new Map<number, (typeof bossResults)[number]>()
|
||||
if (bossResults) {
|
||||
for (const r of bossResults) {
|
||||
map.set(r.bossBattleId, r)
|
||||
}
|
||||
}
|
||||
return map
|
||||
}, [bossResults])
|
||||
|
||||
const sortedBosses = useMemo(() => {
|
||||
if (!bosses) return []
|
||||
return [...bosses].sort((a, b) => a.order - b.order)
|
||||
@@ -1174,238 +1210,258 @@ export function RunEncounters() {
|
||||
{activeTab === 'encounters' && (
|
||||
<>
|
||||
{/* Team Section */}
|
||||
{(alive.length > 0 || dead.length > 0) && (
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowTeam(!showTeam)}
|
||||
className="flex items-center gap-2 group"
|
||||
>
|
||||
<h2 className="text-lg font-semibold text-text-primary">
|
||||
{isActive ? 'Team' : 'Final Team'}
|
||||
</h2>
|
||||
<span className="text-xs text-text-muted">
|
||||
{alive.length} alive
|
||||
{dead.length > 0 ? `, ${dead.length} dead` : ''}
|
||||
</span>
|
||||
<svg
|
||||
className={`w-4 h-4 text-text-tertiary transition-transform ${showTeam ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{showTeam && alive.length > 1 && (
|
||||
<select
|
||||
value={teamSort}
|
||||
onChange={(e) => setTeamSort(e.target.value as TeamSortKey)}
|
||||
className="text-sm border border-border-default rounded-lg px-3 py-1.5 bg-surface-1 text-text-primary"
|
||||
>
|
||||
<option value="route">Route Order</option>
|
||||
<option value="level">Catch Level</option>
|
||||
<option value="species">Species Name</option>
|
||||
<option value="dex">National Dex</option>
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
{showTeam && (
|
||||
<>
|
||||
{alive.length > 0 && (
|
||||
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 gap-2 mb-3">
|
||||
{alive.map((enc) => (
|
||||
<PokemonCard
|
||||
key={enc.id}
|
||||
encounter={enc}
|
||||
onClick={isActive ? () => setSelectedTeamEncounter(enc) : undefined}
|
||||
{(alive.length > 0 || dead.length > 0) && (
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowTeam(!showTeam)}
|
||||
className="flex items-center gap-2 group"
|
||||
>
|
||||
<h2 className="text-lg font-semibold text-text-primary">
|
||||
{isActive ? 'Team' : 'Final Team'}
|
||||
</h2>
|
||||
<span className="text-xs text-text-muted">
|
||||
{alive.length} alive
|
||||
{dead.length > 0 ? `, ${dead.length} dead` : ''}
|
||||
</span>
|
||||
<svg
|
||||
className={`w-4 h-4 text-text-tertiary transition-transform ${showTeam ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{dead.length > 0 && (
|
||||
</svg>
|
||||
</button>
|
||||
{showTeam && alive.length > 1 && (
|
||||
<select
|
||||
value={teamSort}
|
||||
onChange={(e) => setTeamSort(e.target.value as TeamSortKey)}
|
||||
className="text-sm border border-border-default rounded-lg px-3 py-1.5 bg-surface-1 text-text-primary"
|
||||
>
|
||||
<option value="route">Route Order</option>
|
||||
<option value="level">Catch Level</option>
|
||||
<option value="species">Species Name</option>
|
||||
<option value="dex">National Dex</option>
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
{showTeam && (
|
||||
<>
|
||||
<h3 className="text-sm font-medium text-text-tertiary mb-2">Graveyard</h3>
|
||||
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 gap-2">
|
||||
{dead.map((enc) => (
|
||||
<PokemonCard
|
||||
key={enc.id}
|
||||
encounter={enc}
|
||||
showFaintLevel
|
||||
onClick={isActive ? () => setSelectedTeamEncounter(enc) : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{alive.length > 0 && (
|
||||
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 gap-2 mb-3">
|
||||
{alive.map((enc) => (
|
||||
<PokemonCard
|
||||
key={enc.id}
|
||||
encounter={enc}
|
||||
onClick={isActive ? () => setSelectedTeamEncounter(enc) : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{dead.length > 0 && (
|
||||
<>
|
||||
<h3 className="text-sm font-medium text-text-tertiary mb-2">Graveyard</h3>
|
||||
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 gap-2">
|
||||
{dead.map((enc) => (
|
||||
<PokemonCard
|
||||
key={enc.id}
|
||||
encounter={enc}
|
||||
showFaintLevel
|
||||
onClick={isActive ? () => setSelectedTeamEncounter(enc) : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Shiny Box */}
|
||||
{run.rules?.shinyClause && shinyEncounters.length > 0 && (
|
||||
<div className="mb-6">
|
||||
<ShinyBox
|
||||
encounters={shinyEncounters}
|
||||
onEncounterClick={isActive ? (enc) => setSelectedTeamEncounter(enc) : undefined}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Transfer Encounters */}
|
||||
{transferEncounters.length > 0 && (
|
||||
<div className="mb-6">
|
||||
<h2 className="text-sm font-medium text-indigo-400 mb-2">Transferred Pokemon</h2>
|
||||
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 gap-2">
|
||||
{transferEncounters.map((enc) => (
|
||||
<PokemonCard
|
||||
key={enc.id}
|
||||
encounter={enc}
|
||||
onClick={isActive ? () => setSelectedTeamEncounter(enc) : undefined}
|
||||
{/* Shiny Box */}
|
||||
{run.rules?.shinyClause && shinyEncounters.length > 0 && (
|
||||
<div className="mb-6">
|
||||
<ShinyBox
|
||||
encounters={shinyEncounters}
|
||||
onEncounterClick={isActive ? (enc) => setSelectedTeamEncounter(enc) : undefined}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Transfer Encounters */}
|
||||
{transferEncounters.length > 0 && (
|
||||
<div className="mb-6">
|
||||
<h2 className="text-sm font-medium text-indigo-400 mb-2">Transferred Pokemon</h2>
|
||||
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 gap-2">
|
||||
{transferEncounters.map((enc) => (
|
||||
<PokemonCard
|
||||
key={enc.id}
|
||||
encounter={enc}
|
||||
onClick={isActive ? () => setSelectedTeamEncounter(enc) : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="text-lg font-semibold text-text-primary">Encounters</h2>
|
||||
{isActive && completedCount < totalLocations && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={bulkRandomize.isPending}
|
||||
onClick={() => {
|
||||
const remaining = totalLocations - completedCount
|
||||
if (
|
||||
window.confirm(
|
||||
`Randomize encounters for all ${remaining} remaining locations?`
|
||||
)
|
||||
) {
|
||||
bulkRandomize.mutate()
|
||||
}
|
||||
}}
|
||||
className="px-2.5 py-1 text-xs font-medium rounded-lg border border-purple-600 text-purple-400 light:text-purple-700 light:border-purple-500 hover:bg-purple-900/20 light:hover:bg-purple-50 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{bulkRandomize.isPending ? 'Randomizing...' : 'Randomize All'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-sm text-text-tertiary">
|
||||
{completedCount} / {totalLocations} locations
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 bg-surface-3 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-blue-500 rounded-full transition-all"
|
||||
style={{
|
||||
width: `${totalLocations > 0 ? (completedCount / totalLocations) * 100 : 0}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter tabs */}
|
||||
<div className="flex gap-2 mb-4 flex-wrap">
|
||||
{(
|
||||
[
|
||||
{ key: 'all', label: 'All' },
|
||||
{ key: 'none', label: 'Unvisited' },
|
||||
{ key: 'caught', label: 'Caught' },
|
||||
{ key: 'fainted', label: 'Fainted' },
|
||||
{ key: 'missed', label: 'Missed' },
|
||||
] as const
|
||||
).map(({ key, label }) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setFilter(key)}
|
||||
className={`px-3 py-1 rounded-full text-sm font-medium transition-colors ${
|
||||
filter === key
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-surface-2 text-text-secondary hover:bg-surface-3'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="text-lg font-semibold text-text-primary">Encounters</h2>
|
||||
{isActive && completedCount < totalLocations && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={bulkRandomize.isPending}
|
||||
onClick={() => {
|
||||
const remaining = totalLocations - completedCount
|
||||
if (
|
||||
window.confirm(`Randomize encounters for all ${remaining} remaining locations?`)
|
||||
) {
|
||||
bulkRandomize.mutate()
|
||||
}
|
||||
}}
|
||||
className="px-2.5 py-1 text-xs font-medium rounded-lg border border-purple-600 text-purple-400 light:text-purple-700 light:border-purple-500 hover:bg-purple-900/20 light:hover:bg-purple-50 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{bulkRandomize.isPending ? 'Randomizing...' : 'Randomize All'}
|
||||
</button>
|
||||
{/* Route list */}
|
||||
<div className="space-y-1">
|
||||
{filteredRoutes.length === 0 && (
|
||||
<p className="text-text-tertiary text-sm py-4 text-center">
|
||||
{filter === 'all'
|
||||
? 'Click a route to log your first encounter'
|
||||
: 'No routes match this filter — try a different one'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-sm text-text-tertiary">
|
||||
{completedCount} / {totalLocations} locations
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 bg-surface-3 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-blue-500 rounded-full transition-all"
|
||||
style={{
|
||||
width: `${totalLocations > 0 ? (completedCount / totalLocations) * 100 : 0}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{filteredRoutes.map((route) => {
|
||||
// Collect all route IDs to check for boss cards after
|
||||
const routeIds: number[] =
|
||||
route.children.length > 0
|
||||
? [route.id, ...route.children.map((c) => c.id)]
|
||||
: [route.id]
|
||||
|
||||
{/* Filter tabs */}
|
||||
<div className="flex gap-2 mb-4 flex-wrap">
|
||||
{(
|
||||
[
|
||||
{ key: 'all', label: 'All' },
|
||||
{ key: 'none', label: 'Unvisited' },
|
||||
{ key: 'caught', label: 'Caught' },
|
||||
{ key: 'fainted', label: 'Fainted' },
|
||||
{ key: 'missed', label: 'Missed' },
|
||||
] as const
|
||||
).map(({ key, label }) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setFilter(key)}
|
||||
className={`px-3 py-1 rounded-full text-sm font-medium transition-colors ${
|
||||
filter === key
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-surface-2 text-text-secondary hover:bg-surface-3'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
// Find boss battles positioned after this route (or any of its children)
|
||||
const bossesHere: BossBattle[] = []
|
||||
for (const rid of routeIds) {
|
||||
const b = bossesAfterRoute.get(rid)
|
||||
if (b) bossesHere.push(...b)
|
||||
}
|
||||
|
||||
{/* Route list */}
|
||||
<div className="space-y-1">
|
||||
{filteredRoutes.length === 0 && (
|
||||
<p className="text-text-tertiary text-sm py-4 text-center">
|
||||
{filter === 'all'
|
||||
? 'Click a route to log your first encounter'
|
||||
: 'No routes match this filter — try a different one'}
|
||||
</p>
|
||||
)}
|
||||
{filteredRoutes.map((route) => {
|
||||
// Collect all route IDs to check for boss cards after
|
||||
const routeIds: number[] =
|
||||
route.children.length > 0 ? [route.id, ...route.children.map((c) => c.id)] : [route.id]
|
||||
|
||||
// Find boss battles positioned after this route (or any of its children)
|
||||
const bossesHere: BossBattle[] = []
|
||||
for (const rid of routeIds) {
|
||||
const b = bossesAfterRoute.get(rid)
|
||||
if (b) bossesHere.push(...b)
|
||||
}
|
||||
|
||||
const routeElement =
|
||||
route.children.length > 0 ? (
|
||||
<RouteGroup
|
||||
key={route.id}
|
||||
group={route}
|
||||
encounterByRoute={encounterByRoute}
|
||||
giftEncounterByRoute={giftEncounterByRoute}
|
||||
isExpanded={expandedGroups.has(route.id)}
|
||||
onToggleExpand={() => toggleGroup(route.id)}
|
||||
onRouteClick={handleRouteClick}
|
||||
filter={filter}
|
||||
pinwheelClause={pinwheelClause}
|
||||
/>
|
||||
) : (
|
||||
(() => {
|
||||
const encounter = encounterByRoute.get(route.id)
|
||||
const giftEncounter = giftEncounterByRoute.get(route.id)
|
||||
const displayEncounter = encounter ?? giftEncounter
|
||||
const rs = getRouteStatus(displayEncounter)
|
||||
const si = statusIndicator[rs]
|
||||
|
||||
return (
|
||||
<button
|
||||
const routeElement =
|
||||
route.children.length > 0 ? (
|
||||
<RouteGroup
|
||||
key={route.id}
|
||||
type="button"
|
||||
onClick={() => handleRouteClick(route)}
|
||||
className={`w-full flex items-center gap-3 px-4 py-3 rounded-lg text-left transition-colors hover:bg-surface-2/50 ${si.bg}`}
|
||||
>
|
||||
<span className={`w-2.5 h-2.5 rounded-full shrink-0 ${si.dot}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-text-primary">{route.name}</div>
|
||||
{encounter ? (
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
{encounter.pokemon.spriteUrl && (
|
||||
<img
|
||||
src={encounter.pokemon.spriteUrl}
|
||||
alt={encounter.pokemon.name}
|
||||
className="w-10 h-10"
|
||||
/>
|
||||
)}
|
||||
<span className="text-xs text-text-tertiary capitalize">
|
||||
{encounter.nickname ?? encounter.pokemon.name}
|
||||
{encounter.status === 'caught' &&
|
||||
encounter.faintLevel !== null &&
|
||||
(encounter.deathCause ? ` — ${encounter.deathCause}` : ' (dead)')}
|
||||
</span>
|
||||
{giftEncounter && (
|
||||
<>
|
||||
group={route}
|
||||
encounterByRoute={encounterByRoute}
|
||||
giftEncounterByRoute={giftEncounterByRoute}
|
||||
isExpanded={expandedGroups.has(route.id)}
|
||||
onToggleExpand={() => toggleGroup(route.id)}
|
||||
onRouteClick={handleRouteClick}
|
||||
filter={filter}
|
||||
pinwheelClause={pinwheelClause}
|
||||
/>
|
||||
) : (
|
||||
(() => {
|
||||
const encounter = encounterByRoute.get(route.id)
|
||||
const giftEncounter = giftEncounterByRoute.get(route.id)
|
||||
const displayEncounter = encounter ?? giftEncounter
|
||||
const rs = getRouteStatus(displayEncounter)
|
||||
const si = statusIndicator[rs]
|
||||
|
||||
return (
|
||||
<button
|
||||
key={route.id}
|
||||
type="button"
|
||||
onClick={() => handleRouteClick(route)}
|
||||
className={`w-full flex items-center gap-3 px-4 py-3 rounded-lg text-left transition-colors hover:bg-surface-2/50 ${si.bg}`}
|
||||
>
|
||||
<span className={`w-2.5 h-2.5 rounded-full shrink-0 ${si.dot}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-text-primary">{route.name}</div>
|
||||
{encounter ? (
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
{encounter.pokemon.spriteUrl && (
|
||||
<img
|
||||
src={encounter.pokemon.spriteUrl}
|
||||
alt={encounter.pokemon.name}
|
||||
className="w-10 h-10"
|
||||
/>
|
||||
)}
|
||||
<span className="text-xs text-text-tertiary capitalize">
|
||||
{encounter.nickname ?? encounter.pokemon.name}
|
||||
{encounter.status === 'caught' &&
|
||||
encounter.faintLevel !== null &&
|
||||
(encounter.deathCause ? ` — ${encounter.deathCause}` : ' (dead)')}
|
||||
</span>
|
||||
{giftEncounter && (
|
||||
<>
|
||||
{giftEncounter.pokemon.spriteUrl && (
|
||||
<img
|
||||
src={giftEncounter.pokemon.spriteUrl}
|
||||
alt={giftEncounter.pokemon.name}
|
||||
className="w-8 h-8 opacity-60"
|
||||
/>
|
||||
)}
|
||||
<span className="text-xs text-text-tertiary capitalize">
|
||||
{giftEncounter.nickname ?? giftEncounter.pokemon.name}
|
||||
<span className="text-text-muted ml-1">(gift)</span>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : giftEncounter ? (
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
{giftEncounter.pokemon.spriteUrl && (
|
||||
<img
|
||||
src={giftEncounter.pokemon.spriteUrl}
|
||||
@@ -1417,176 +1473,194 @@ export function RunEncounters() {
|
||||
{giftEncounter.nickname ?? giftEncounter.pokemon.name}
|
||||
<span className="text-text-muted ml-1">(gift)</span>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : giftEncounter ? (
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
{giftEncounter.pokemon.spriteUrl && (
|
||||
<img
|
||||
src={giftEncounter.pokemon.spriteUrl}
|
||||
alt={giftEncounter.pokemon.name}
|
||||
className="w-8 h-8 opacity-60"
|
||||
/>
|
||||
)}
|
||||
<span className="text-xs text-text-tertiary capitalize">
|
||||
{giftEncounter.nickname ?? giftEncounter.pokemon.name}
|
||||
<span className="text-text-muted ml-1">(gift)</span>
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
route.encounterMethods.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-0.5">
|
||||
{route.encounterMethods.map((m) => (
|
||||
<EncounterMethodBadge key={m} method={m} size="xs" />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-text-muted shrink-0">{si.label}</span>
|
||||
</button>
|
||||
)
|
||||
})()
|
||||
)
|
||||
|
||||
return (
|
||||
<div key={route.id}>
|
||||
{routeElement}
|
||||
{/* Boss battle cards after this route */}
|
||||
{bossesHere.map((boss) => {
|
||||
const isDefeated = defeatedBossIds.has(boss.id)
|
||||
const sectionAfter = sectionDividerAfterBoss.get(boss.id)
|
||||
const bossTypeLabel: Record<string, string> = {
|
||||
gym_leader: 'Gym Leader',
|
||||
elite_four: 'Elite Four',
|
||||
champion: 'Champion',
|
||||
rival: 'Rival',
|
||||
evil_team: 'Evil Team',
|
||||
kahuna: 'Kahuna',
|
||||
totem: 'Totem',
|
||||
other: 'Boss',
|
||||
}
|
||||
const bossTypeColors: Record<string, string> = {
|
||||
gym_leader: 'border-yellow-600',
|
||||
elite_four: 'border-purple-600',
|
||||
champion: 'border-red-600',
|
||||
rival: 'border-blue-600',
|
||||
evil_team: 'border-gray-400',
|
||||
kahuna: 'border-orange-600',
|
||||
totem: 'border-teal-600',
|
||||
other: 'border-gray-500',
|
||||
}
|
||||
|
||||
const isBossExpanded = expandedBosses.has(boss.id)
|
||||
const toggleBoss = () => {
|
||||
setExpandedBosses((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(boss.id)) next.delete(boss.id)
|
||||
else next.add(boss.id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={`boss-${boss.id}`}>
|
||||
<div
|
||||
className={`my-2 rounded-lg border-2 ${bossTypeColors[boss.bossType] ?? bossTypeColors['other']} ${
|
||||
isDefeated ? 'bg-green-900/10' : 'bg-surface-1'
|
||||
} px-4 py-3`}
|
||||
>
|
||||
<div
|
||||
className="flex items-start justify-between cursor-pointer select-none"
|
||||
onClick={toggleBoss}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<svg
|
||||
className={`w-4 h-4 text-text-tertiary transition-transform ${isBossExpanded ? 'rotate-90' : ''}`}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
{boss.spriteUrl && (
|
||||
<img src={boss.spriteUrl} alt={boss.name} className="h-10 w-auto" />
|
||||
)}
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-text-primary">
|
||||
{boss.name}
|
||||
</span>
|
||||
<span className="px-2 py-0.5 text-xs font-medium rounded-full bg-surface-2 text-text-secondary">
|
||||
{bossTypeLabel[boss.bossType] ?? boss.bossType}
|
||||
</span>
|
||||
{boss.specialtyType && <TypeBadge type={boss.specialtyType} />}
|
||||
</div>
|
||||
<p className="text-xs text-text-tertiary">
|
||||
{boss.location} · Level Cap: {boss.levelCap}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
route.encounterMethods.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-0.5">
|
||||
{route.encounterMethods.map((m) => (
|
||||
<EncounterMethodBadge key={m} method={m} size="xs" />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
{isDefeated ? (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium rounded-full bg-green-900/40 text-green-300 light:bg-green-100 light:text-green-800">
|
||||
Defeated ✓
|
||||
</span>
|
||||
) : isActive ? (
|
||||
<button
|
||||
onClick={() => setSelectedBoss(boss)}
|
||||
className="px-3 py-1 text-xs font-medium rounded-full bg-accent-600 text-white hover:bg-accent-500 transition-colors"
|
||||
>
|
||||
Battle
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{/* Boss pokemon team */}
|
||||
{isBossExpanded && boss.pokemon.length > 0 && (
|
||||
<BossTeamPreview pokemon={boss.pokemon} starterName={starterName} />
|
||||
)}
|
||||
</div>
|
||||
{sectionAfter && (
|
||||
<div className="flex items-center gap-3 my-4">
|
||||
<div className="flex-1 h-px bg-surface-3" />
|
||||
<span className="text-sm font-semibold text-text-tertiary uppercase tracking-wider">
|
||||
{sectionAfter}
|
||||
</span>
|
||||
<div className="flex-1 h-px bg-surface-3" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-text-muted shrink-0">{si.label}</span>
|
||||
</button>
|
||||
)
|
||||
})()
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Encounter Modal */}
|
||||
{selectedRoute && (
|
||||
<EncounterModal
|
||||
route={selectedRoute}
|
||||
gameId={run!.gameId}
|
||||
runId={runIdNum}
|
||||
namingScheme={run!.namingScheme}
|
||||
isGenlocke={!!run!.genlocke}
|
||||
existing={editingEncounter ?? undefined}
|
||||
dupedPokemonIds={dupedPokemonIds}
|
||||
retiredPokemonIds={retiredPokemonIds}
|
||||
onSubmit={handleCreate}
|
||||
onUpdate={handleUpdate}
|
||||
onClose={() => {
|
||||
setSelectedRoute(null)
|
||||
setEditingEncounter(null)
|
||||
}}
|
||||
isPending={createEncounter.isPending || updateEncounter.isPending}
|
||||
useAllPokemon={useAllPokemon}
|
||||
staticClause={run?.rules?.staticClause ?? true}
|
||||
allowedTypes={rulesAllowedTypes.length ? rulesAllowedTypes : undefined}
|
||||
/>
|
||||
)}
|
||||
return (
|
||||
<div key={route.id}>
|
||||
{routeElement}
|
||||
{/* Boss battle cards after this route */}
|
||||
{bossesHere.map((boss) => {
|
||||
const isDefeated = defeatedBossIds.has(boss.id)
|
||||
const sectionAfter = sectionDividerAfterBoss.get(boss.id)
|
||||
const bossTypeLabel: Record<string, string> = {
|
||||
gym_leader: 'Gym Leader',
|
||||
elite_four: 'Elite Four',
|
||||
champion: 'Champion',
|
||||
rival: 'Rival',
|
||||
evil_team: 'Evil Team',
|
||||
kahuna: 'Kahuna',
|
||||
totem: 'Totem',
|
||||
other: 'Boss',
|
||||
}
|
||||
const bossTypeColors: Record<string, string> = {
|
||||
gym_leader: 'border-yellow-600',
|
||||
elite_four: 'border-purple-600',
|
||||
champion: 'border-red-600',
|
||||
rival: 'border-blue-600',
|
||||
evil_team: 'border-gray-400',
|
||||
kahuna: 'border-orange-600',
|
||||
totem: 'border-teal-600',
|
||||
other: 'border-gray-500',
|
||||
}
|
||||
|
||||
const isBossExpanded = expandedBosses.has(boss.id)
|
||||
const toggleBoss = () => {
|
||||
setExpandedBosses((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(boss.id)) next.delete(boss.id)
|
||||
else next.add(boss.id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={`boss-${boss.id}`}>
|
||||
<div
|
||||
className={`my-2 rounded-lg border-2 ${bossTypeColors[boss.bossType] ?? bossTypeColors['other']} ${
|
||||
isDefeated ? 'bg-green-900/10' : 'bg-surface-1'
|
||||
} px-4 py-3`}
|
||||
>
|
||||
<div
|
||||
className="flex items-start justify-between cursor-pointer select-none"
|
||||
onClick={toggleBoss}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<svg
|
||||
className={`w-4 h-4 text-text-tertiary transition-transform ${isBossExpanded ? 'rotate-90' : ''}`}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
{boss.spriteUrl && (
|
||||
<img src={boss.spriteUrl} alt={boss.name} className="h-10 w-auto" />
|
||||
)}
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-text-primary">
|
||||
{boss.name}
|
||||
</span>
|
||||
<span className="px-2 py-0.5 text-xs font-medium rounded-full bg-surface-2 text-text-secondary">
|
||||
{bossTypeLabel[boss.bossType] ?? boss.bossType}
|
||||
</span>
|
||||
{boss.specialtyType && <TypeBadge type={boss.specialtyType} />}
|
||||
</div>
|
||||
<p className="text-xs text-text-tertiary">
|
||||
{boss.location} · Level Cap: {boss.levelCap}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
{isDefeated ? (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium rounded-full bg-green-900/40 text-green-300 light:bg-green-100 light:text-green-800">
|
||||
Defeated ✓
|
||||
</span>
|
||||
) : isActive ? (
|
||||
<button
|
||||
onClick={() => setSelectedBoss(boss)}
|
||||
className="px-3 py-1 text-xs font-medium rounded-full bg-accent-600 text-white hover:bg-accent-500 transition-colors"
|
||||
>
|
||||
Battle
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{/* Boss pokemon team */}
|
||||
{isBossExpanded && boss.pokemon.length > 0 && (
|
||||
<BossTeamPreview pokemon={boss.pokemon} starterName={starterName} />
|
||||
)}
|
||||
{/* Player team snapshot */}
|
||||
{isDefeated && (() => {
|
||||
const result = bossResultByBattleId.get(boss.id)
|
||||
if (!result || result.team.length === 0) return null
|
||||
return (
|
||||
<div className="mt-3 pt-3 border-t border-border-default">
|
||||
<p className="text-xs font-medium text-text-secondary mb-2">Your Team</p>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{result.team.map((tm) => {
|
||||
const enc = encounterById.get(tm.encounterId)
|
||||
if (!enc) return null
|
||||
const dp = enc.currentPokemon ?? enc.pokemon
|
||||
return (
|
||||
<div key={tm.id} className="flex flex-col items-center">
|
||||
{dp.spriteUrl ? (
|
||||
<img src={dp.spriteUrl} alt={dp.name} className="w-10 h-10" />
|
||||
) : (
|
||||
<div className="w-10 h-10 bg-surface-3 rounded-full" />
|
||||
)}
|
||||
<span className="text-[10px] text-text-tertiary capitalize">
|
||||
{enc.nickname ?? dp.name}
|
||||
</span>
|
||||
<span className="text-[10px] text-text-muted">Lv.{tm.level}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
{sectionAfter && (
|
||||
<div className="flex items-center gap-3 my-4">
|
||||
<div className="flex-1 h-px bg-surface-3" />
|
||||
<span className="text-sm font-semibold text-text-tertiary uppercase tracking-wider">
|
||||
{sectionAfter}
|
||||
</span>
|
||||
<div className="flex-1 h-px bg-surface-3" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Encounter Modal */}
|
||||
{selectedRoute && (
|
||||
<EncounterModal
|
||||
route={selectedRoute}
|
||||
gameId={run!.gameId}
|
||||
runId={runIdNum}
|
||||
namingScheme={run!.namingScheme}
|
||||
isGenlocke={!!run!.genlocke}
|
||||
existing={editingEncounter ?? undefined}
|
||||
dupedPokemonIds={dupedPokemonIds}
|
||||
retiredPokemonIds={retiredPokemonIds}
|
||||
onSubmit={handleCreate}
|
||||
onUpdate={handleUpdate}
|
||||
onClose={() => {
|
||||
setSelectedRoute(null)
|
||||
setEditingEncounter(null)
|
||||
}}
|
||||
isPending={createEncounter.isPending || updateEncounter.isPending}
|
||||
useAllPokemon={useAllPokemon}
|
||||
staticClause={run?.rules?.staticClause ?? true}
|
||||
allowedTypes={rulesAllowedTypes.length ? rulesAllowedTypes : undefined}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1633,6 +1707,7 @@ export function RunEncounters() {
|
||||
{selectedBoss && (
|
||||
<BossDefeatModal
|
||||
boss={selectedBoss}
|
||||
aliveEncounters={alive}
|
||||
onSubmit={(data) => {
|
||||
createBossResult.mutate(data, {
|
||||
onSuccess: () => setSelectedBoss(null),
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useMemo } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { useRuns } from '../hooks/useRuns'
|
||||
import type { RunStatus } from '../types'
|
||||
import type { NuzlockeRun, RunStatus } from '../types'
|
||||
|
||||
const statusStyles: Record<RunStatus, string> = {
|
||||
active: 'bg-status-active-bg text-status-active border border-status-active/20',
|
||||
@@ -8,22 +10,95 @@ const statusStyles: Record<RunStatus, string> = {
|
||||
failed: 'bg-status-failed-bg text-status-failed border border-status-failed/20',
|
||||
}
|
||||
|
||||
function VisibilityBadge({ visibility }: { visibility: 'public' | 'private' }) {
|
||||
if (visibility === 'private') {
|
||||
return (
|
||||
<span className="px-2 py-0.5 rounded-full text-xs font-medium bg-surface-3 text-text-tertiary border border-border-default">
|
||||
Private
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function RunCard({ run, isOwned }: { run: NuzlockeRun; isOwned: boolean }) {
|
||||
return (
|
||||
<Link
|
||||
to={`/runs/${run.id}`}
|
||||
className="block bg-surface-1 rounded-xl border border-border-default hover:border-border-accent transition-all hover:-translate-y-0.5 p-4"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-lg font-semibold text-text-primary truncate">{run.name}</h2>
|
||||
{isOwned && <VisibilityBadge visibility={run.visibility} />}
|
||||
</div>
|
||||
<p className="text-sm text-text-secondary">
|
||||
Started{' '}
|
||||
{new Date(run.startedAt).toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})}
|
||||
{!isOwned && run.owner?.displayName && (
|
||||
<span className="text-text-tertiary"> · by {run.owner.displayName}</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
className={`px-2.5 py-0.5 rounded-full text-xs font-medium capitalize flex-shrink-0 ml-2 ${statusStyles[run.status]}`}
|
||||
>
|
||||
{run.status}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
export function RunList() {
|
||||
const { data: runs, isLoading, error } = useRuns()
|
||||
const { user, loading: authLoading } = useAuth()
|
||||
|
||||
const { myRuns, publicRuns } = useMemo(() => {
|
||||
if (!runs) return { myRuns: [], publicRuns: [] }
|
||||
|
||||
if (!user) {
|
||||
return { myRuns: [], publicRuns: runs }
|
||||
}
|
||||
|
||||
const owned: NuzlockeRun[] = []
|
||||
const others: NuzlockeRun[] = []
|
||||
|
||||
for (const run of runs) {
|
||||
if (run.owner?.id === user.id) {
|
||||
owned.push(run)
|
||||
} else {
|
||||
others.push(run)
|
||||
}
|
||||
}
|
||||
|
||||
return { myRuns: owned, publicRuns: others }
|
||||
}, [runs, user])
|
||||
|
||||
const showLoading = isLoading || authLoading
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto p-8">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-3xl font-bold text-text-primary">Your Runs</h1>
|
||||
<Link
|
||||
to="/runs/new"
|
||||
className="px-4 py-2 bg-accent-600 text-white rounded-lg font-medium hover:bg-accent-500 focus:outline-none focus:ring-2 focus:ring-accent-400 focus:ring-offset-2 focus:ring-offset-surface-0 transition-all active:scale-[0.98]"
|
||||
>
|
||||
Start New Run
|
||||
</Link>
|
||||
<h1 className="text-3xl font-bold text-text-primary">
|
||||
{user ? 'Nuzlocke Runs' : 'Public Runs'}
|
||||
</h1>
|
||||
{user && (
|
||||
<Link
|
||||
to="/runs/new"
|
||||
className="px-4 py-2 bg-accent-600 text-white rounded-lg font-medium hover:bg-accent-500 focus:outline-none focus:ring-2 focus:ring-accent-400 focus:ring-offset-2 focus:ring-offset-surface-0 transition-all active:scale-[0.98]"
|
||||
>
|
||||
Start New Run
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
{showLoading && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="w-8 h-8 border-4 border-accent-400 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
@@ -35,49 +110,56 @@ export function RunList() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{runs && runs.length === 0 && (
|
||||
{!showLoading && runs && runs.length === 0 && (
|
||||
<div className="text-center py-16">
|
||||
<p className="text-lg text-text-secondary mb-4">
|
||||
No runs yet. Start your first Nuzlocke!
|
||||
{user ? 'No runs yet. Start your first Nuzlocke!' : 'No public runs available.'}
|
||||
</p>
|
||||
<Link
|
||||
to="/runs/new"
|
||||
className="inline-block px-6 py-2 bg-accent-600 text-white rounded-lg font-medium hover:bg-accent-500 transition-all active:scale-[0.98]"
|
||||
>
|
||||
Start New Run
|
||||
</Link>
|
||||
{user && (
|
||||
<Link
|
||||
to="/runs/new"
|
||||
className="inline-block px-6 py-2 bg-accent-600 text-white rounded-lg font-medium hover:bg-accent-500 transition-all active:scale-[0.98]"
|
||||
>
|
||||
Start New Run
|
||||
</Link>
|
||||
)}
|
||||
{!user && (
|
||||
<Link
|
||||
to="/login"
|
||||
className="inline-block px-6 py-2 bg-accent-600 text-white rounded-lg font-medium hover:bg-accent-500 transition-all active:scale-[0.98]"
|
||||
>
|
||||
Sign In to Create Runs
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{runs && runs.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{runs.map((run) => (
|
||||
<Link
|
||||
key={run.id}
|
||||
to={`/runs/${run.id}`}
|
||||
className="block bg-surface-1 rounded-xl border border-border-default hover:border-border-accent transition-all hover:-translate-y-0.5 p-4"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-text-primary">{run.name}</h2>
|
||||
<p className="text-sm text-text-secondary">
|
||||
Started{' '}
|
||||
{new Date(run.startedAt).toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
className={`px-2.5 py-0.5 rounded-full text-xs font-medium capitalize ${statusStyles[run.status]}`}
|
||||
>
|
||||
{run.status}
|
||||
</span>
|
||||
{!showLoading && runs && runs.length > 0 && (
|
||||
<>
|
||||
{user && myRuns.length > 0 && (
|
||||
<div className="mb-8">
|
||||
<h2 className="text-lg font-semibold text-text-primary mb-3">My Runs</h2>
|
||||
<div className="space-y-2">
|
||||
{myRuns.map((run) => (
|
||||
<RunCard key={run.id} run={run} isOwned />
|
||||
))}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{publicRuns.length > 0 && (
|
||||
<div>
|
||||
{user && myRuns.length > 0 && (
|
||||
<h2 className="text-lg font-semibold text-text-primary mb-3">Public Runs</h2>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
{publicRuns.map((run) => (
|
||||
<RunCard key={run.id} run={run} isOwned={false} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
218
frontend/src/pages/Signup.tsx
Normal file
218
frontend/src/pages/Signup.tsx
Normal file
@@ -0,0 +1,218 @@
|
||||
import { useState } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
|
||||
export function Signup() {
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [success, setSuccess] = useState(false)
|
||||
const { signUpWithEmail, signInWithGoogle, signInWithDiscord } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setError('Passwords do not match')
|
||||
return
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
setError('Password must be at least 6 characters')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
const { error } = await signUpWithEmail(email, password)
|
||||
setLoading(false)
|
||||
|
||||
if (error) {
|
||||
setError(error.message)
|
||||
} else {
|
||||
setSuccess(true)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleGoogleSignup() {
|
||||
setError(null)
|
||||
const { error } = await signInWithGoogle()
|
||||
if (error) setError(error.message)
|
||||
}
|
||||
|
||||
async function handleDiscordSignup() {
|
||||
setError(null)
|
||||
const { error } = await signInWithDiscord()
|
||||
if (error) setError(error.message)
|
||||
}
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
<div className="min-h-[80vh] flex items-center justify-center px-4">
|
||||
<div className="w-full max-w-sm text-center space-y-4">
|
||||
<div className="w-16 h-16 mx-auto bg-green-500/10 rounded-full flex items-center justify-center">
|
||||
<svg
|
||||
className="w-8 h-8 text-green-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold">Check your email</h1>
|
||||
<p className="text-text-secondary">
|
||||
We've sent a confirmation link to <strong>{email}</strong>. Click the link to
|
||||
activate your account.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/login')}
|
||||
className="text-accent-400 hover:text-accent-300"
|
||||
>
|
||||
Back to login
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-[80vh] flex items-center justify-center px-4">
|
||||
<div className="w-full max-w-sm space-y-6">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-bold">Create an account</h1>
|
||||
<p className="text-text-secondary mt-1">Start tracking your Nuzlocke runs</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-500/10 border border-red-500/20 text-red-400 px-4 py-3 rounded-lg text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-text-secondary mb-1">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
className="w-full px-3 py-2 bg-surface-2 border border-border-default rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="block text-sm font-medium text-text-secondary mb-1"
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={6}
|
||||
className="w-full px-3 py-2 bg-surface-2 border border-border-default rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="confirmPassword"
|
||||
className="block text-sm font-medium text-text-secondary mb-1"
|
||||
>
|
||||
Confirm password
|
||||
</label>
|
||||
<input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
minLength={6}
|
||||
className="w-full px-3 py-2 bg-surface-2 border border-border-default rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-2 px-4 bg-accent-600 hover:bg-accent-700 disabled:opacity-50 disabled:cursor-not-allowed text-white font-medium rounded-lg transition-colors"
|
||||
>
|
||||
{loading ? 'Creating account...' : 'Create account'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-border-default" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-sm">
|
||||
<span className="px-2 bg-surface-0 text-text-tertiary">Or continue with</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleGoogleSignup}
|
||||
className="flex items-center justify-center gap-2 py-2 px-4 bg-surface-2 hover:bg-surface-3 border border-border-default rounded-lg transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5" viewBox="0 0 24 24">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||
/>
|
||||
</svg>
|
||||
Google
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDiscordSignup}
|
||||
className="flex items-center justify-center gap-2 py-2 px-4 bg-surface-2 hover:bg-surface-3 border border-border-default rounded-lg transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z" />
|
||||
</svg>
|
||||
Discord
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-sm text-text-secondary">
|
||||
Already have an account?{' '}
|
||||
<Link to="/login" className="text-accent-400 hover:text-accent-300">
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
export { AuthCallback } from './AuthCallback'
|
||||
export { GenlockeDetail } from './GenlockeDetail'
|
||||
export { GenlockeList } from './GenlockeList'
|
||||
export { Home } from './Home'
|
||||
export { JournalEntryPage } from './JournalEntryPage'
|
||||
export { Login } from './Login'
|
||||
export { NewGenlocke } from './NewGenlocke'
|
||||
export { NewRun } from './NewRun'
|
||||
export { RunList } from './RunList'
|
||||
export { RunEncounters } from './RunEncounters'
|
||||
export { Signup } from './Signup'
|
||||
export { Stats } from './Stats'
|
||||
|
||||
@@ -2,6 +2,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { render, type RenderOptions } from '@testing-library/react'
|
||||
import { type ReactElement } from 'react'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import { AuthProvider } from '../contexts/AuthContext'
|
||||
|
||||
export function createTestQueryClient(): QueryClient {
|
||||
return new QueryClient({
|
||||
@@ -16,7 +17,9 @@ function AllProviders({ children }: { children: React.ReactNode }) {
|
||||
const queryClient = createTestQueryClient()
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>{children}</MemoryRouter>
|
||||
<MemoryRouter>
|
||||
<AuthProvider>{children}</AuthProvider>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -182,6 +182,14 @@ export interface BossPokemonInput {
|
||||
level: number
|
||||
order: number
|
||||
conditionLabel?: string | null
|
||||
// Detail fields
|
||||
abilityId?: number | null
|
||||
heldItem?: string | null
|
||||
nature?: string | null
|
||||
move1Id?: number | null
|
||||
move2Id?: number | null
|
||||
move3Id?: number | null
|
||||
move4Id?: number | null
|
||||
}
|
||||
|
||||
// Genlocke admin
|
||||
|
||||
@@ -84,6 +84,12 @@ export interface Encounter {
|
||||
}
|
||||
|
||||
export type RunStatus = 'active' | 'completed' | 'failed'
|
||||
export type RunVisibility = 'public' | 'private'
|
||||
|
||||
export interface RunOwner {
|
||||
id: string
|
||||
displayName: string | null
|
||||
}
|
||||
|
||||
export interface NuzlockeRun {
|
||||
id: number
|
||||
@@ -93,6 +99,8 @@ export interface NuzlockeRun {
|
||||
rules: NuzlockeRules
|
||||
hofEncounterIds: number[] | null
|
||||
namingScheme: string | null
|
||||
visibility: RunVisibility
|
||||
owner: RunOwner | null
|
||||
startedAt: string
|
||||
completedAt: string | null
|
||||
}
|
||||
@@ -136,6 +144,7 @@ export interface CreateRunInput {
|
||||
name: string
|
||||
rules?: NuzlockeRules
|
||||
namingScheme?: string | null
|
||||
visibility?: RunVisibility
|
||||
}
|
||||
|
||||
export interface UpdateRunInput {
|
||||
@@ -144,6 +153,7 @@ export interface UpdateRunInput {
|
||||
rules?: NuzlockeRules
|
||||
hofEncounterIds?: number[]
|
||||
namingScheme?: string | null
|
||||
visibility?: RunVisibility
|
||||
}
|
||||
|
||||
export interface CreateEncounterInput {
|
||||
@@ -175,6 +185,16 @@ export type BossType =
|
||||
| 'totem'
|
||||
| 'other'
|
||||
|
||||
export interface MoveRef {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface AbilityRef {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface BossPokemon {
|
||||
id: number
|
||||
pokemonId: number
|
||||
@@ -182,6 +202,19 @@ export interface BossPokemon {
|
||||
order: number
|
||||
conditionLabel: string | null
|
||||
pokemon: Pokemon
|
||||
// Detail fields
|
||||
abilityId: number | null
|
||||
ability: AbilityRef | null
|
||||
heldItem: string | null
|
||||
nature: string | null
|
||||
move1Id: number | null
|
||||
move2Id: number | null
|
||||
move3Id: number | null
|
||||
move4Id: number | null
|
||||
move1: MoveRef | null
|
||||
move2: MoveRef | null
|
||||
move3: MoveRef | null
|
||||
move4: MoveRef | null
|
||||
}
|
||||
|
||||
export interface BossBattle {
|
||||
@@ -202,6 +235,12 @@ export interface BossBattle {
|
||||
pokemon: BossPokemon[]
|
||||
}
|
||||
|
||||
export interface BossResultTeamMember {
|
||||
id: number
|
||||
encounterId: number
|
||||
level: number
|
||||
}
|
||||
|
||||
export interface BossResult {
|
||||
id: number
|
||||
runId: number
|
||||
@@ -209,12 +248,19 @@ export interface BossResult {
|
||||
result: 'won' | 'lost'
|
||||
attempts: number
|
||||
completedAt: string | null
|
||||
team: BossResultTeamMember[]
|
||||
}
|
||||
|
||||
export interface BossResultTeamMemberInput {
|
||||
encounterId: number
|
||||
level: number
|
||||
}
|
||||
|
||||
export interface CreateBossResultInput {
|
||||
bossBattleId: number
|
||||
result: 'won' | 'lost'
|
||||
attempts?: number
|
||||
team?: BossResultTeamMemberInput[]
|
||||
}
|
||||
|
||||
// Re-export for convenience
|
||||
|
||||
Reference in New Issue
Block a user