feat: add auth system, boss pokemon details, moves/abilities API, and run ownership
Some checks failed
CI / backend-tests (push) Failing after 1m16s
CI / frontend-tests (push) Successful in 57s

Add user authentication with login/signup/protected routes, boss pokemon
detail fields and result team tracking, moves and abilities selector
components and API, run ownership and visibility controls, and various
UI improvements across encounters, run list, and journal pages.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-20 21:41:38 +01:00
parent a6cb309b8b
commit 0a519e356e
69 changed files with 3574 additions and 693 deletions

View File

@@ -2,5 +2,12 @@
DEBUG=true DEBUG=true
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/nuzlocke 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) # Frontend settings (used by Vite)
VITE_API_URL=http://localhost:8000 VITE_API_URL=http://localhost:8000
VITE_SUPABASE_URL=https://your-project.supabase.co
VITE_SUPABASE_ANON_KEY=your-anon-key

View File

@@ -7,3 +7,8 @@ API_V1_PREFIX="/api/v1"
# Database settings # Database settings
DATABASE_URL="sqlite:///./nuzlocke.db" 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

View File

@@ -13,6 +13,7 @@ dependencies = [
"sqlalchemy[asyncio]==2.0.48", "sqlalchemy[asyncio]==2.0.48",
"asyncpg==0.31.0", "asyncpg==0.31.0",
"alembic==1.18.4", "alembic==1.18.4",
"PyJWT==2.10.1",
] ]
[project.optional-dependencies] [project.optional-dependencies]

View 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]))

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,10 +5,13 @@ from sqlalchemy import or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from app.core.auth import AuthUser, require_auth
from app.core.database import get_session from app.core.database import get_session
from app.models.boss_battle import BossBattle from app.models.boss_battle import BossBattle
from app.models.boss_pokemon import BossPokemon from app.models.boss_pokemon import BossPokemon
from app.models.boss_result import BossResult 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.game import Game
from app.models.nuzlocke_run import NuzlockeRun from app.models.nuzlocke_run import NuzlockeRun
from app.models.pokemon import Pokemon from app.models.pokemon import Pokemon
@@ -28,6 +31,18 @@ from app.seeds.loader import upsert_bosses
router = APIRouter() 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: async def _get_version_group_id(session: AsyncSession, game_id: int) -> int:
game = await session.get(Game, game_id) game = await session.get(Game, game_id)
if game is None: if game is None:
@@ -53,7 +68,7 @@ async def list_bosses(
query = ( query = (
select(BossBattle) select(BossBattle)
.where(BossBattle.version_group_id == vg_id) .where(BossBattle.version_group_id == vg_id)
.options(selectinload(BossBattle.pokemon).selectinload(BossPokemon.pokemon)) .options(*_boss_pokemon_load_options())
.order_by(BossBattle.order) .order_by(BossBattle.order)
) )
@@ -71,6 +86,7 @@ async def reorder_bosses(
game_id: int, game_id: int,
data: BossReorderRequest, data: BossReorderRequest,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_auth),
): ):
vg_id = await _get_version_group_id(session, game_id) vg_id = await _get_version_group_id(session, game_id)
@@ -101,7 +117,7 @@ async def reorder_bosses(
result = await session.execute( result = await session.execute(
select(BossBattle) select(BossBattle)
.where(BossBattle.version_group_id == vg_id) .where(BossBattle.version_group_id == vg_id)
.options(selectinload(BossBattle.pokemon).selectinload(BossPokemon.pokemon)) .options(*_boss_pokemon_load_options())
.order_by(BossBattle.order) .order_by(BossBattle.order)
) )
return result.scalars().all() return result.scalars().all()
@@ -114,6 +130,7 @@ async def create_boss(
game_id: int, game_id: int,
data: BossBattleCreate, data: BossBattleCreate,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_auth),
): ):
vg_id = await _get_version_group_id(session, game_id) vg_id = await _get_version_group_id(session, game_id)
@@ -133,7 +150,7 @@ async def create_boss(
result = await session.execute( result = await session.execute(
select(BossBattle) select(BossBattle)
.where(BossBattle.id == boss.id) .where(BossBattle.id == boss.id)
.options(selectinload(BossBattle.pokemon).selectinload(BossPokemon.pokemon)) .options(*_boss_pokemon_load_options())
) )
return result.scalar_one() return result.scalar_one()
@@ -144,6 +161,7 @@ async def update_boss(
boss_id: int, boss_id: int,
data: BossBattleUpdate, data: BossBattleUpdate,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_auth),
): ):
vg_id = await _get_version_group_id(session, game_id) vg_id = await _get_version_group_id(session, game_id)
@@ -158,7 +176,7 @@ async def update_boss(
result = await session.execute( result = await session.execute(
select(BossBattle) select(BossBattle)
.where(BossBattle.id == boss_id, BossBattle.version_group_id == vg_id) .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() boss = result.scalar_one_or_none()
if boss is None: if boss is None:
@@ -174,7 +192,7 @@ async def update_boss(
result = await session.execute( result = await session.execute(
select(BossBattle) select(BossBattle)
.where(BossBattle.id == boss.id) .where(BossBattle.id == boss.id)
.options(selectinload(BossBattle.pokemon).selectinload(BossPokemon.pokemon)) .options(*_boss_pokemon_load_options())
) )
return result.scalar_one() return result.scalar_one()
@@ -184,6 +202,7 @@ async def delete_boss(
game_id: int, game_id: int,
boss_id: int, boss_id: int,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_auth),
): ):
vg_id = await _get_version_group_id(session, game_id) vg_id = await _get_version_group_id(session, game_id)
@@ -206,6 +225,7 @@ async def bulk_import_bosses(
game_id: int, game_id: int,
items: list[BulkBossItem], items: list[BulkBossItem],
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_auth),
): ):
vg_id = await _get_version_group_id(session, game_id) vg_id = await _get_version_group_id(session, game_id)
@@ -248,6 +268,7 @@ async def set_boss_team(
boss_id: int, boss_id: int,
team: list[BossPokemonInput], team: list[BossPokemonInput],
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_auth),
): ):
vg_id = await _get_version_group_id(session, game_id) vg_id = await _get_version_group_id(session, game_id)
@@ -272,6 +293,13 @@ async def set_boss_team(
level=item.level, level=item.level,
order=item.order, order=item.order,
condition_label=item.condition_label, 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) session.add(bp)
@@ -286,7 +314,7 @@ async def set_boss_team(
result = await session.execute( result = await session.execute(
select(BossBattle) select(BossBattle)
.where(BossBattle.id == boss.id) .where(BossBattle.id == boss.id)
.options(selectinload(BossBattle.pokemon).selectinload(BossPokemon.pokemon)) .options(*_boss_pokemon_load_options())
) )
return result.scalar_one() 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") raise HTTPException(status_code=404, detail="Run not found")
result = await session.execute( 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() return result.scalars().all()
@@ -313,6 +344,7 @@ async def create_boss_result(
run_id: int, run_id: int,
data: BossResultCreate, data: BossResultCreate,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_auth),
): ):
run = await session.get(NuzlockeRun, run_id) run = await session.get(NuzlockeRun, run_id)
if run is None: if run is None:
@@ -322,12 +354,30 @@ async def create_boss_result(
if boss is None: if boss is None:
raise HTTPException(status_code=404, detail="Boss battle not found") 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) # Check for existing result (upsert)
existing = await session.execute( existing = await session.execute(
select(BossResult).where( select(BossResult)
.where(
BossResult.run_id == run_id, BossResult.run_id == run_id,
BossResult.boss_battle_id == data.boss_battle_id, BossResult.boss_battle_id == data.boss_battle_id,
) )
.options(selectinload(BossResult.team))
) )
result = existing.scalar_one_or_none() result = existing.scalar_one_or_none()
@@ -335,6 +385,10 @@ async def create_boss_result(
result.result = data.result result.result = data.result
result.attempts = data.attempts result.attempts = data.attempts
result.completed_at = datetime.now(UTC) if data.result == "won" else None 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: else:
result = BossResult( result = BossResult(
run_id=run_id, run_id=run_id,
@@ -344,10 +398,26 @@ async def create_boss_result(
completed_at=datetime.now(UTC) if data.result == "won" else None, completed_at=datetime.now(UTC) if data.result == "won" else None,
) )
session.add(result) 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.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) @router.delete("/runs/{run_id}/boss-results/{result_id}", status_code=204)
@@ -355,6 +425,7 @@ async def delete_boss_result(
run_id: int, run_id: int,
result_id: int, result_id: int,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_auth),
): ):
result = await session.execute( result = await session.execute(
select(BossResult).where( select(BossResult).where(

View File

@@ -5,6 +5,7 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import joinedload, selectinload from sqlalchemy.orm import joinedload, selectinload
from app.core.auth import AuthUser, require_auth
from app.core.database import get_session from app.core.database import get_session
from app.models.encounter import Encounter from app.models.encounter import Encounter
from app.models.evolution import Evolution from app.models.evolution import Evolution
@@ -35,6 +36,7 @@ async def create_encounter(
run_id: int, run_id: int,
data: EncounterCreate, data: EncounterCreate,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_auth),
): ):
# Validate run exists # Validate run exists
run = await session.get(NuzlockeRun, run_id) run = await session.get(NuzlockeRun, run_id)
@@ -137,6 +139,7 @@ async def update_encounter(
encounter_id: int, encounter_id: int,
data: EncounterUpdate, data: EncounterUpdate,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_auth),
): ):
encounter = await session.get(Encounter, encounter_id) encounter = await session.get(Encounter, encounter_id)
if encounter is None: if encounter is None:
@@ -163,7 +166,9 @@ async def update_encounter(
@router.delete("/encounters/{encounter_id}", status_code=204) @router.delete("/encounters/{encounter_id}", status_code=204)
async def delete_encounter( 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) encounter = await session.get(Encounter, encounter_id)
if encounter is None: if encounter is None:
@@ -195,6 +200,7 @@ async def delete_encounter(
async def bulk_randomize_encounters( async def bulk_randomize_encounters(
run_id: int, run_id: int,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_auth),
): ):
# 1. Validate run # 1. Validate run
run = await session.get(NuzlockeRun, run_id) run = await session.get(NuzlockeRun, run_id)

View File

@@ -6,6 +6,7 @@ from sqlalchemy import delete, select, update
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from app.core.auth import AuthUser, require_auth
from app.core.database import get_session from app.core.database import get_session
from app.models.boss_battle import BossBattle from app.models.boss_battle import BossBattle
from app.models.game import Game from app.models.game import Game
@@ -228,7 +229,11 @@ async def list_game_routes(
@router.post("", response_model=GameResponse, status_code=201) @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)) existing = await session.execute(select(Game).where(Game.slug == data.slug))
if existing.scalar_one_or_none() is not None: if existing.scalar_one_or_none() is not None:
raise HTTPException( raise HTTPException(
@@ -244,7 +249,10 @@ async def create_game(data: GameCreate, session: AsyncSession = Depends(get_sess
@router.put("/{game_id}", response_model=GameResponse) @router.put("/{game_id}", response_model=GameResponse)
async def update_game( 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) game = await session.get(Game, game_id)
if game is None: if game is None:
@@ -269,7 +277,11 @@ async def update_game(
@router.delete("/{game_id}", status_code=204) @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( result = await session.execute(
select(Game).where(Game.id == game_id).options(selectinload(Game.runs)) 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) @router.post("/{game_id}/routes", response_model=RouteResponse, status_code=201)
async def create_route( 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) vg_id = await _get_version_group_id(session, game_id)
@@ -339,6 +354,7 @@ async def reorder_routes(
game_id: int, game_id: int,
data: RouteReorderRequest, data: RouteReorderRequest,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_auth),
): ):
vg_id = await _get_version_group_id(session, game_id) vg_id = await _get_version_group_id(session, game_id)
@@ -365,6 +381,7 @@ async def update_route(
route_id: int, route_id: int,
data: RouteUpdate, data: RouteUpdate,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_auth),
): ):
vg_id = await _get_version_group_id(session, game_id) vg_id = await _get_version_group_id(session, game_id)
@@ -385,6 +402,7 @@ async def delete_route(
game_id: int, game_id: int,
route_id: int, route_id: int,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_auth),
): ):
vg_id = await _get_version_group_id(session, game_id) vg_id = await _get_version_group_id(session, game_id)
@@ -419,6 +437,7 @@ async def bulk_import_routes(
game_id: int, game_id: int,
items: list[BulkRouteItem], items: list[BulkRouteItem],
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_auth),
): ):
vg_id = await _get_version_group_id(session, game_id) vg_id = await _get_version_group_id(session, game_id)

View File

@@ -6,6 +6,7 @@ from sqlalchemy import update as sa_update
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from app.core.auth import AuthUser, require_auth
from app.core.database import get_session from app.core.database import get_session
from app.models.encounter import Encounter from app.models.encounter import Encounter
from app.models.evolution import Evolution from app.models.evolution import Evolution
@@ -437,7 +438,9 @@ async def get_genlocke_lineages(
@router.post("", response_model=GenlockeResponse, status_code=201) @router.post("", response_model=GenlockeResponse, status_code=201)
async def create_genlocke( 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: if not data.game_ids:
raise HTTPException(status_code=400, detail="At least one game is required") raise HTTPException(status_code=400, detail="At least one game is required")
@@ -568,6 +571,7 @@ async def advance_leg(
leg_order: int, leg_order: int,
data: AdvanceLegRequest | None = None, data: AdvanceLegRequest | None = None,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_auth),
): ):
# Load genlocke with legs # Load genlocke with legs
result = await session.execute( result = await session.execute(
@@ -822,6 +826,7 @@ async def update_genlocke(
genlocke_id: int, genlocke_id: int,
data: GenlockeUpdate, data: GenlockeUpdate,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_auth),
): ):
result = await session.execute( result = await session.execute(
select(Genlocke) select(Genlocke)
@@ -858,6 +863,7 @@ async def update_genlocke(
async def delete_genlocke( async def delete_genlocke(
genlocke_id: int, genlocke_id: int,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_auth),
): ):
genlocke = await session.get(Genlocke, genlocke_id) genlocke = await session.get(Genlocke, genlocke_id)
if genlocke is None: if genlocke is None:
@@ -889,6 +895,7 @@ async def add_leg(
genlocke_id: int, genlocke_id: int,
data: AddLegRequest, data: AddLegRequest,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_auth),
): ):
genlocke = await session.get(Genlocke, genlocke_id) genlocke = await session.get(Genlocke, genlocke_id)
if genlocke is None: if genlocke is None:
@@ -931,6 +938,7 @@ async def remove_leg(
genlocke_id: int, genlocke_id: int,
leg_id: int, leg_id: int,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_auth),
): ):
result = await session.execute( result = await session.execute(
select(GenlockeLeg).where( select(GenlockeLeg).where(

View File

@@ -5,6 +5,7 @@ from fastapi import APIRouter, Depends, HTTPException, Response
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.auth import AuthUser, require_auth
from app.core.database import get_session from app.core.database import get_session
from app.models.boss_result import BossResult from app.models.boss_result import BossResult
from app.models.journal_entry import JournalEntry from app.models.journal_entry import JournalEntry
@@ -45,6 +46,7 @@ async def create_journal_entry(
run_id: int, run_id: int,
data: JournalEntryCreate, data: JournalEntryCreate,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_auth),
): ):
# Validate run exists # Validate run exists
run = await session.get(NuzlockeRun, run_id) run = await session.get(NuzlockeRun, run_id)
@@ -97,6 +99,7 @@ async def update_journal_entry(
entry_id: UUID, entry_id: UUID,
data: JournalEntryUpdate, data: JournalEntryUpdate,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_auth),
): ):
result = await session.execute( result = await session.execute(
select(JournalEntry).where( select(JournalEntry).where(
@@ -135,6 +138,7 @@ async def delete_journal_entry(
run_id: int, run_id: int,
entry_id: UUID, entry_id: UUID,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_auth),
): ):
result = await session.execute( result = await session.execute(
select(JournalEntry).where( select(JournalEntry).where(

View 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

View File

@@ -9,13 +9,16 @@ from app.api import (
genlockes, genlockes,
health, health,
journal_entries, journal_entries,
moves_abilities,
pokemon, pokemon,
runs, runs,
stats, stats,
users,
) )
api_router = APIRouter() api_router = APIRouter()
api_router.include_router(health.router) 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(games.router, prefix="/games", tags=["games"])
api_router.include_router(pokemon.router, tags=["pokemon"]) api_router.include_router(pokemon.router, tags=["pokemon"])
api_router.include_router(evolutions.router, tags=["evolutions"]) 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(encounters.router, tags=["encounters"])
api_router.include_router(stats.router, prefix="/stats", tags=["stats"]) api_router.include_router(stats.router, prefix="/stats", tags=["stats"])
api_router.include_router(bosses.router, tags=["bosses"]) 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"]) api_router.include_router(export.router, prefix="/export", tags=["export"])

View File

@@ -1,10 +1,12 @@
from datetime import UTC, datetime 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 import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import joinedload, selectinload 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.core.database import get_session
from app.models.boss_result import BossResult from app.models.boss_result import BossResult
from app.models.encounter import Encounter 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.game import Game
from app.models.genlocke import GenlockeLeg from app.models.genlocke import GenlockeLeg
from app.models.genlocke_transfer import GenlockeTransfer 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 ( from app.schemas.run import (
OwnerResponse,
RunCreate, RunCreate,
RunDetailResponse, RunDetailResponse,
RunGenlockeContext, RunGenlockeContext,
@@ -157,41 +161,136 @@ async def _compute_lineage_suggestion(
return f"{base_name} {numeral}" 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) @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 # Validate game exists
game = await session.get(Game, data.game_id) game = await session.get(Game, data.game_id)
if game is None: if game is None:
raise HTTPException(status_code=404, detail="Game not found") 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( run = NuzlockeRun(
game_id=data.game_id, game_id=data.game_id,
owner_id=user_id,
name=data.name, name=data.name,
status="active", status="active",
visibility=data.visibility,
rules=data.rules, rules=data.rules,
naming_scheme=data.naming_scheme, naming_scheme=data.naming_scheme,
) )
session.add(run) session.add(run)
await session.commit() 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]) @router.get("", response_model=list[RunResponse])
async def list_runs(session: AsyncSession = Depends(get_session)): async def list_runs(
result = await session.execute( request: Request,
select(NuzlockeRun).order_by(NuzlockeRun.started_at.desc()) 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)
) )
return result.scalars().all() 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) @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( result = await session.execute(
select(NuzlockeRun) select(NuzlockeRun)
.where(NuzlockeRun.id == run_id) .where(NuzlockeRun.id == run_id)
.options( .options(
joinedload(NuzlockeRun.game), joinedload(NuzlockeRun.game),
joinedload(NuzlockeRun.owner),
selectinload(NuzlockeRun.encounters).joinedload(Encounter.pokemon), selectinload(NuzlockeRun.encounters).joinedload(Encounter.pokemon),
selectinload(NuzlockeRun.encounters).joinedload(Encounter.current_pokemon), selectinload(NuzlockeRun.encounters).joinedload(Encounter.current_pokemon),
selectinload(NuzlockeRun.encounters).joinedload(Encounter.route), 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: if run is None:
raise HTTPException(status_code=404, detail="Run not found") 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 # Check if this run belongs to a genlocke
genlocke_context = None genlocke_context = None
leg_result = await session.execute( leg_result = await session.execute(
@@ -262,11 +364,20 @@ async def update_run(
run_id: int, run_id: int,
data: RunUpdate, data: RunUpdate,
session: AsyncSession = Depends(get_session), 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: if run is None:
raise HTTPException(status_code=404, detail="Run not found") 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) update_data = data.model_dump(exclude_unset=True)
# Validate hof_encounter_ids if provided # Validate hof_encounter_ids if provided
@@ -352,16 +463,30 @@ async def update_run(
genlocke.status = "completed" genlocke.status = "completed"
await session.commit() 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) @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) run = await session.get(NuzlockeRun, run_id)
if run is None: if run is None:
raise HTTPException(status_code=404, detail="Run not found") 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 # Block deletion if run is linked to a genlocke leg
leg_result = await session.execute( leg_result = await session.execute(
select(GenlockeLeg).where(GenlockeLeg.run_id == run_id) select(GenlockeLeg).where(GenlockeLeg.run_id == run_id)

View 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

View 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

View File

@@ -17,5 +17,10 @@ class Settings(BaseSettings):
# Database settings # Database settings
database_url: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/nuzlocke" 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() settings = Settings()

View File

@@ -2,6 +2,7 @@ from app.models.ability import Ability
from app.models.boss_battle import BossBattle from app.models.boss_battle import BossBattle
from app.models.boss_pokemon import BossPokemon from app.models.boss_pokemon import BossPokemon
from app.models.boss_result import BossResult from app.models.boss_result import BossResult
from app.models.boss_result_team import BossResultTeam
from app.models.encounter import Encounter from app.models.encounter import Encounter
from app.models.evolution import Evolution from app.models.evolution import Evolution
from app.models.game import Game 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.pokemon import Pokemon
from app.models.route import Route from app.models.route import Route
from app.models.route_encounter import RouteEncounter from app.models.route_encounter import RouteEncounter
from app.models.user import User
from app.models.version_group import VersionGroup from app.models.version_group import VersionGroup
__all__ = [ __all__ = [
@@ -20,6 +22,7 @@ __all__ = [
"BossBattle", "BossBattle",
"BossPokemon", "BossPokemon",
"BossResult", "BossResult",
"BossResultTeam",
"Encounter", "Encounter",
"Evolution", "Evolution",
"Game", "Game",
@@ -32,5 +35,6 @@ __all__ = [
"Pokemon", "Pokemon",
"Route", "Route",
"RouteEncounter", "RouteEncounter",
"User",
"VersionGroup", "VersionGroup",
] ]

View File

@@ -1,8 +1,18 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from sqlalchemy import ForeignKey, SmallInteger, String from sqlalchemy import ForeignKey, SmallInteger, String
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base 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): class BossPokemon(Base):
__tablename__ = "boss_pokemon" __tablename__ = "boss_pokemon"
@@ -16,8 +26,24 @@ class BossPokemon(Base):
order: Mapped[int] = mapped_column(SmallInteger) order: Mapped[int] = mapped_column(SmallInteger)
condition_label: Mapped[str | None] = mapped_column(String(100)) 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") boss_battle: Mapped[BossBattle] = relationship(back_populates="pokemon")
pokemon: Mapped[Pokemon] = relationship() 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: def __repr__(self) -> str:
return f"<BossPokemon(id={self.id}, boss_battle_id={self.boss_battle_id}, pokemon_id={self.pokemon_id})>" return f"<BossPokemon(id={self.id}, boss_battle_id={self.boss_battle_id}, pokemon_id={self.pokemon_id})>"

View File

@@ -25,6 +25,12 @@ class BossResult(Base):
run: Mapped[NuzlockeRun] = relationship(back_populates="boss_results") run: Mapped[NuzlockeRun] = relationship(back_populates="boss_results")
boss_battle: Mapped[BossBattle] = relationship() boss_battle: Mapped[BossBattle] = relationship()
team: Mapped[list[BossResultTeam]] = relationship(
back_populates="boss_result", cascade="all, delete-orphan"
)
def __repr__(self) -> str: 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}')>"
)

View 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})>"
)

View File

@@ -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.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base 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): class NuzlockeRun(Base):
__tablename__ = "nuzlocke_runs" __tablename__ = "nuzlocke_runs"
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
game_id: Mapped[int] = mapped_column(ForeignKey("games.id"), index=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)) name: Mapped[str] = mapped_column(String(100))
status: Mapped[str] = mapped_column( status: Mapped[str] = mapped_column(
String(20), index=True String(20), index=True
) # active, completed, failed ) # 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) rules: Mapped[dict] = mapped_column(JSONB, default=dict)
started_at: Mapped[datetime] = mapped_column( started_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now() 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) naming_scheme: Mapped[str | None] = mapped_column(String(50), nullable=True)
game: Mapped[Game] = relationship(back_populates="runs") game: Mapped[Game] = relationship(back_populates="runs")
owner: Mapped[User | None] = relationship(back_populates="runs")
encounters: Mapped[list[Encounter]] = relationship(back_populates="run") encounters: Mapped[list[Encounter]] = relationship(back_populates="run")
boss_results: Mapped[list[BossResult]] = relationship(back_populates="run") boss_results: Mapped[list[BossResult]] = relationship(back_populates="run")
journal_entries: Mapped[list[JournalEntry]] = relationship(back_populates="run") journal_entries: Mapped[list[JournalEntry]] = relationship(back_populates="run")

View 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}')>"

View File

@@ -4,6 +4,16 @@ from app.schemas.base import CamelModel
from app.schemas.pokemon import PokemonResponse from app.schemas.pokemon import PokemonResponse
class MoveRef(CamelModel):
id: int
name: str
class AbilityRef(CamelModel):
id: int
name: str
class BossPokemonResponse(CamelModel): class BossPokemonResponse(CamelModel):
id: int id: int
pokemon_id: int pokemon_id: int
@@ -11,6 +21,19 @@ class BossPokemonResponse(CamelModel):
order: int order: int
condition_label: str | None condition_label: str | None
pokemon: PokemonResponse 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): class BossBattleResponse(CamelModel):
@@ -31,6 +54,12 @@ class BossBattleResponse(CamelModel):
pokemon: list[BossPokemonResponse] = [] pokemon: list[BossPokemonResponse] = []
class BossResultTeamMemberResponse(CamelModel):
id: int
encounter_id: int
level: int
class BossResultResponse(CamelModel): class BossResultResponse(CamelModel):
id: int id: int
run_id: int run_id: int
@@ -38,6 +67,7 @@ class BossResultResponse(CamelModel):
result: str result: str
attempts: int attempts: int
completed_at: datetime | None completed_at: datetime | None
team: list[BossResultTeamMemberResponse] = []
# --- Input schemas --- # --- Input schemas ---
@@ -78,12 +108,26 @@ class BossPokemonInput(CamelModel):
level: int level: int
order: int order: int
condition_label: str | None = None 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): class BossResultCreate(CamelModel):
boss_battle_id: int boss_battle_id: int
result: str result: str
attempts: int = 1 attempts: int = 1
team: list[BossResultTeamMemberInput] = []
class BossReorderItem(CamelModel): class BossReorderItem(CamelModel):

View File

@@ -1,15 +1,23 @@
from datetime import datetime from datetime import datetime
from uuid import UUID
from app.models.nuzlocke_run import RunVisibility
from app.schemas.base import CamelModel from app.schemas.base import CamelModel
from app.schemas.encounter import EncounterDetailResponse from app.schemas.encounter import EncounterDetailResponse
from app.schemas.game import GameResponse from app.schemas.game import GameResponse
class OwnerResponse(CamelModel):
id: UUID
display_name: str | None = None
class RunCreate(CamelModel): class RunCreate(CamelModel):
game_id: int game_id: int
name: str name: str
rules: dict = {} rules: dict = {}
naming_scheme: str | None = None naming_scheme: str | None = None
visibility: RunVisibility = RunVisibility.PUBLIC
class RunUpdate(CamelModel): class RunUpdate(CamelModel):
@@ -18,6 +26,7 @@ class RunUpdate(CamelModel):
rules: dict | None = None rules: dict | None = None
hof_encounter_ids: list[int] | None = None hof_encounter_ids: list[int] | None = None
naming_scheme: str | None = None naming_scheme: str | None = None
visibility: RunVisibility | None = None
class RunResponse(CamelModel): class RunResponse(CamelModel):
@@ -28,6 +37,8 @@ class RunResponse(CamelModel):
rules: dict rules: dict
hof_encounter_ids: list[int] | None = None hof_encounter_ids: list[int] | None = None
naming_scheme: str | None = None naming_scheme: str | None = None
visibility: RunVisibility
owner: OwnerResponse | None = None
started_at: datetime started_at: datetime
completed_at: datetime | None completed_at: datetime | None

View File

@@ -87,7 +87,9 @@ RUN_DEFS = [
"name": "Kanto Heartbreak", "name": "Kanto Heartbreak",
"status": "failed", "status": "failed",
"progress": 0.45, "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, "started_days_ago": 30,
"ended_days_ago": 20, "ended_days_ago": 20,
}, },

View File

@@ -1,13 +1,18 @@
import os import os
import time
import jwt
import pytest import pytest
from httpx import ASGITransport, AsyncClient from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
import app.models # noqa: F401 — ensures all models register with Base.metadata 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.core.database import Base, get_session
from app.main import app from app.main import app
TEST_JWT_SECRET = "test-jwt-secret-for-testing-only"
TEST_DATABASE_URL = os.getenv( TEST_DATABASE_URL = os.getenv(
"TEST_DATABASE_URL", "TEST_DATABASE_URL",
"postgresql+asyncpg://postgres:postgres@localhost:5433/nuzlocke_test", "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" transport=ASGITransport(app=app), base_url="http://test"
) as ac: ) as ac:
yield 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
View 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

View File

@@ -17,9 +17,9 @@ GAME_PAYLOAD = {
@pytest.fixture @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).""" """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 assert response.status_code == 201
return response.json() return response.json()
@@ -68,22 +68,24 @@ class TestListGames:
class TestCreateGame: class TestCreateGame:
async def test_creates_and_returns_game(self, client: AsyncClient): async def test_creates_and_returns_game(self, auth_client: AsyncClient):
response = await client.post(BASE, json=GAME_PAYLOAD) response = await auth_client.post(BASE, json=GAME_PAYLOAD)
assert response.status_code == 201 assert response.status_code == 201
data = response.json() data = response.json()
assert data["name"] == "Pokemon Red" assert data["name"] == "Pokemon Red"
assert data["slug"] == "red" assert data["slug"] == "red"
assert isinstance(data["id"], int) assert isinstance(data["id"], int)
async def test_duplicate_slug_returns_409(self, client: AsyncClient, game: dict): async def test_duplicate_slug_returns_409(
response = await client.post( self, auth_client: AsyncClient, game: dict
):
response = await auth_client.post(
BASE, json={**GAME_PAYLOAD, "name": "Pokemon Red v2"} BASE, json={**GAME_PAYLOAD, "name": "Pokemon Red v2"}
) )
assert response.status_code == 409 assert response.status_code == 409
async def test_missing_required_field_returns_422(self, client: AsyncClient): async def test_missing_required_field_returns_422(self, auth_client: AsyncClient):
response = await client.post(BASE, json={"name": "Pokemon Red"}) response = await auth_client.post(BASE, json={"name": "Pokemon Red"})
assert response.status_code == 422 assert response.status_code == 422
@@ -113,29 +115,35 @@ class TestGetGame:
class TestUpdateGame: class TestUpdateGame:
async def test_updates_name(self, client: AsyncClient, game: dict): async def test_updates_name(self, auth_client: AsyncClient, game: dict):
response = await client.put( response = await auth_client.put(
f"{BASE}/{game['id']}", json={"name": "Pokemon Blue"} f"{BASE}/{game['id']}", json={"name": "Pokemon Blue"}
) )
assert response.status_code == 200 assert response.status_code == 200
assert response.json()["name"] == "Pokemon Blue" assert response.json()["name"] == "Pokemon Blue"
async def test_slug_unchanged_on_partial_update( 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" assert response.json()["slug"] == "red"
async def test_not_found_returns_404(self, client: AsyncClient): async def test_not_found_returns_404(self, auth_client: AsyncClient):
assert (await client.put(f"{BASE}/9999", json={"name": "x"})).status_code == 404 assert (
await auth_client.put(f"{BASE}/9999", json={"name": "x"})
).status_code == 404
async def test_duplicate_slug_returns_409(self, client: AsyncClient): async def test_duplicate_slug_returns_409(self, auth_client: AsyncClient):
await client.post(BASE, json={**GAME_PAYLOAD, "slug": "blue", "name": "Blue"}) await auth_client.post(
r1 = await client.post( BASE, json={**GAME_PAYLOAD, "slug": "blue", "name": "Blue"}
)
r1 = await auth_client.post(
BASE, json={**GAME_PAYLOAD, "slug": "red", "name": "Red"} BASE, json={**GAME_PAYLOAD, "slug": "red", "name": "Red"}
) )
game_id = r1.json()["id"] 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 assert response.status_code == 409
@@ -145,13 +153,13 @@ class TestUpdateGame:
class TestDeleteGame: class TestDeleteGame:
async def test_deletes_game(self, client: AsyncClient, game: dict): async def test_deletes_game(self, auth_client: AsyncClient, game: dict):
response = await client.delete(f"{BASE}/{game['id']}") response = await auth_client.delete(f"{BASE}/{game['id']}")
assert response.status_code == 204 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): async def test_not_found_returns_404(self, auth_client: AsyncClient):
assert (await client.delete(f"{BASE}/9999")).status_code == 404 assert (await auth_client.delete(f"{BASE}/9999")).status_code == 404
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -187,9 +195,9 @@ class TestListByRegion:
class TestCreateRoute: 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 game_id, _ = game_with_vg
response = await client.post( response = await auth_client.post(
f"{BASE}/{game_id}/routes", f"{BASE}/{game_id}/routes",
json={"name": "Pallet Town", "order": 1}, json={"name": "Pallet Town", "order": 1},
) )
@@ -200,35 +208,35 @@ class TestCreateRoute:
assert isinstance(data["id"], int) assert isinstance(data["id"], int)
async def test_game_detail_includes_route( 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 game_id, _ = game_with_vg
await client.post( await auth_client.post(
f"{BASE}/{game_id}/routes", json={"name": "Route 1", "order": 1} 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"] routes = response.json()["routes"]
assert len(routes) == 1 assert len(routes) == 1
assert routes[0]["name"] == "Route 1" assert routes[0]["name"] == "Route 1"
async def test_game_without_version_group_returns_400( 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", f"{BASE}/{game['id']}/routes",
json={"name": "Route 1", "order": 1}, json={"name": "Route 1", "order": 1},
) )
assert response.status_code == 400 assert response.status_code == 400
async def test_list_routes_excludes_routes_without_encounters( 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.""" """list_game_routes only returns routes that have Pokemon encounters."""
game_id, _ = game_with_vg game_id, _ = game_with_vg
await client.post( await auth_client.post(
f"{BASE}/{game_id}/routes", json={"name": "Route 1", "order": 1} 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.status_code == 200
assert response.json() == [] assert response.json() == []
@@ -239,14 +247,16 @@ class TestCreateRoute:
class TestUpdateRoute: 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 game_id, _ = game_with_vg
r = ( r = (
await client.post( await auth_client.post(
f"{BASE}/{game_id}/routes", json={"name": "Old Name", "order": 1} f"{BASE}/{game_id}/routes", json={"name": "Old Name", "order": 1}
) )
).json() ).json()
response = await client.put( response = await auth_client.put(
f"{BASE}/{game_id}/routes/{r['id']}", f"{BASE}/{game_id}/routes/{r['id']}",
json={"name": "New Name"}, json={"name": "New Name"},
) )
@@ -254,11 +264,11 @@ class TestUpdateRoute:
assert response.json()["name"] == "New Name" assert response.json()["name"] == "New Name"
async def test_route_not_found_returns_404( 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 game_id, _ = game_with_vg
assert ( 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 ).status_code == 404
@@ -268,25 +278,27 @@ class TestUpdateRoute:
class TestDeleteRoute: 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 game_id, _ = game_with_vg
r = ( r = (
await client.post( await auth_client.post(
f"{BASE}/{game_id}/routes", json={"name": "Route 1", "order": 1} f"{BASE}/{game_id}/routes", json={"name": "Route 1", "order": 1}
) )
).json() ).json()
assert ( 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 ).status_code == 204
# No longer in game detail # 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"]) assert all(route["id"] != r["id"] for route in detail["routes"])
async def test_route_not_found_returns_404( 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 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: 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 game_id, _ = game_with_vg
r1 = ( r1 = (
await client.post( await auth_client.post(
f"{BASE}/{game_id}/routes", json={"name": "A", "order": 1} f"{BASE}/{game_id}/routes", json={"name": "A", "order": 1}
) )
).json() ).json()
r2 = ( r2 = (
await client.post( await auth_client.post(
f"{BASE}/{game_id}/routes", json={"name": "B", "order": 2} f"{BASE}/{game_id}/routes", json={"name": "B", "order": 2}
) )
).json() ).json()
response = await client.put( response = await auth_client.put(
f"{BASE}/{game_id}/routes/reorder", f"{BASE}/{game_id}/routes/reorder",
json={ json={
"routes": [{"id": r1["id"], "order": 2}, {"id": r2["id"], "order": 1}] "routes": [{"id": r1["id"], "order": 2}, {"id": r2["id"], "order": 1}]

View File

@@ -30,9 +30,11 @@ async def game_id(db_session: AsyncSession) -> int:
@pytest.fixture @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.""" """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 assert response.status_code == 201
return response.json() return response.json()
@@ -127,8 +129,8 @@ class TestListRuns:
class TestCreateRun: class TestCreateRun:
async def test_creates_active_run(self, client: AsyncClient, game_id: int): async def test_creates_active_run(self, auth_client: AsyncClient, game_id: int):
response = await client.post( response = await auth_client.post(
RUNS_BASE, json={"gameId": game_id, "name": "New Run"} RUNS_BASE, json={"gameId": game_id, "name": "New Run"}
) )
assert response.status_code == 201 assert response.status_code == 201
@@ -138,20 +140,22 @@ class TestCreateRun:
assert data["gameId"] == game_id assert data["gameId"] == game_id
assert isinstance(data["id"], int) 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} rules = {"duplicatesClause": True, "shinyClause": False}
response = await client.post( response = await auth_client.post(
RUNS_BASE, json={"gameId": game_id, "name": "Run", "rules": rules} RUNS_BASE, json={"gameId": game_id, "name": "Run", "rules": rules}
) )
assert response.status_code == 201 assert response.status_code == 201
assert response.json()["rules"]["duplicatesClause"] is True assert response.json()["rules"]["duplicatesClause"] is True
async def test_invalid_game_returns_404(self, client: AsyncClient): async def test_invalid_game_returns_404(self, auth_client: AsyncClient):
response = await client.post(RUNS_BASE, json={"gameId": 9999, "name": "Run"}) response = await auth_client.post(
RUNS_BASE, json={"gameId": 9999, "name": "Run"}
)
assert response.status_code == 404 assert response.status_code == 404
async def test_missing_required_returns_422(self, client: AsyncClient): async def test_missing_required_returns_422(self, auth_client: AsyncClient):
response = await client.post(RUNS_BASE, json={"name": "Run"}) response = await auth_client.post(RUNS_BASE, json={"name": "Run"})
assert response.status_code == 422 assert response.status_code == 422
@@ -181,15 +185,17 @@ class TestGetRun:
class TestUpdateRun: class TestUpdateRun:
async def test_updates_name(self, client: AsyncClient, run: dict): async def test_updates_name(self, auth_client: AsyncClient, run: dict):
response = await client.patch( response = await auth_client.patch(
f"{RUNS_BASE}/{run['id']}", json={"name": "Renamed"} f"{RUNS_BASE}/{run['id']}", json={"name": "Renamed"}
) )
assert response.status_code == 200 assert response.status_code == 200
assert response.json()["name"] == "Renamed" assert response.json()["name"] == "Renamed"
async def test_complete_run_sets_completed_at(self, client: AsyncClient, run: dict): async def test_complete_run_sets_completed_at(
response = await client.patch( self, auth_client: AsyncClient, run: dict
):
response = await auth_client.patch(
f"{RUNS_BASE}/{run['id']}", json={"status": "completed"} f"{RUNS_BASE}/{run['id']}", json={"status": "completed"}
) )
assert response.status_code == 200 assert response.status_code == 200
@@ -197,25 +203,27 @@ class TestUpdateRun:
assert data["status"] == "completed" assert data["status"] == "completed"
assert data["completedAt"] is not None assert data["completedAt"] is not None
async def test_fail_run(self, client: AsyncClient, run: dict): async def test_fail_run(self, auth_client: AsyncClient, run: dict):
response = await client.patch( response = await auth_client.patch(
f"{RUNS_BASE}/{run['id']}", json={"status": "failed"} f"{RUNS_BASE}/{run['id']}", json={"status": "failed"}
) )
assert response.status_code == 200 assert response.status_code == 200
assert response.json()["status"] == "failed" assert response.json()["status"] == "failed"
async def test_ending_already_ended_run_returns_400( 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"}) await auth_client.patch(
response = await client.patch( f"{RUNS_BASE}/{run['id']}", json={"status": "completed"}
)
response = await auth_client.patch(
f"{RUNS_BASE}/{run['id']}", json={"status": "failed"} f"{RUNS_BASE}/{run['id']}", json={"status": "failed"}
) )
assert response.status_code == 400 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 ( 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 ).status_code == 404
@@ -225,12 +233,12 @@ class TestUpdateRun:
class TestDeleteRun: class TestDeleteRun:
async def test_deletes_run(self, client: AsyncClient, run: dict): async def test_deletes_run(self, auth_client: AsyncClient, run: dict):
assert (await client.delete(f"{RUNS_BASE}/{run['id']}")).status_code == 204 assert (await auth_client.delete(f"{RUNS_BASE}/{run['id']}")).status_code == 204
assert (await client.get(f"{RUNS_BASE}/{run['id']}")).status_code == 404 assert (await auth_client.get(f"{RUNS_BASE}/{run['id']}")).status_code == 404
async def test_not_found_returns_404(self, client: AsyncClient): async def test_not_found_returns_404(self, auth_client: AsyncClient):
assert (await client.delete(f"{RUNS_BASE}/9999")).status_code == 404 assert (await auth_client.delete(f"{RUNS_BASE}/9999")).status_code == 404
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -239,8 +247,8 @@ class TestDeleteRun:
class TestCreateEncounter: class TestCreateEncounter:
async def test_creates_encounter(self, client: AsyncClient, enc_ctx: dict): async def test_creates_encounter(self, auth_client: AsyncClient, enc_ctx: dict):
response = await client.post( response = await auth_client.post(
f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters", f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters",
json={ json={
"routeId": enc_ctx["standalone_id"], "routeId": enc_ctx["standalone_id"],
@@ -255,8 +263,10 @@ class TestCreateEncounter:
assert data["status"] == "caught" assert data["status"] == "caught"
assert data["isShiny"] is False assert data["isShiny"] is False
async def test_invalid_run_returns_404(self, client: AsyncClient, enc_ctx: dict): async def test_invalid_run_returns_404(
response = await client.post( self, auth_client: AsyncClient, enc_ctx: dict
):
response = await auth_client.post(
f"{RUNS_BASE}/9999/encounters", f"{RUNS_BASE}/9999/encounters",
json={ json={
"routeId": enc_ctx["standalone_id"], "routeId": enc_ctx["standalone_id"],
@@ -266,8 +276,10 @@ class TestCreateEncounter:
) )
assert response.status_code == 404 assert response.status_code == 404
async def test_invalid_route_returns_404(self, client: AsyncClient, enc_ctx: dict): async def test_invalid_route_returns_404(
response = await client.post( self, auth_client: AsyncClient, enc_ctx: dict
):
response = await auth_client.post(
f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters", f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters",
json={ json={
"routeId": 9999, "routeId": 9999,
@@ -278,9 +290,9 @@ class TestCreateEncounter:
assert response.status_code == 404 assert response.status_code == 404
async def test_invalid_pokemon_returns_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", f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters",
json={ json={
"routeId": enc_ctx["standalone_id"], "routeId": enc_ctx["standalone_id"],
@@ -290,9 +302,11 @@ class TestCreateEncounter:
) )
assert response.status_code == 404 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).""" """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", f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters",
json={ json={
"routeId": enc_ctx["parent_id"], "routeId": enc_ctx["parent_id"],
@@ -303,10 +317,10 @@ class TestCreateEncounter:
assert response.status_code == 400 assert response.status_code == 400
async def test_route_lock_prevents_second_sibling_encounter( 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.""" """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", f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters",
json={ json={
"routeId": enc_ctx["child1_id"], "routeId": enc_ctx["child1_id"],
@@ -314,7 +328,7 @@ class TestCreateEncounter:
"status": "caught", "status": "caught",
}, },
) )
response = await client.post( response = await auth_client.post(
f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters", f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters",
json={ json={
"routeId": enc_ctx["child2_id"], "routeId": enc_ctx["child2_id"],
@@ -325,11 +339,11 @@ class TestCreateEncounter:
assert response.status_code == 409 assert response.status_code == 409
async def test_shiny_bypasses_route_lock( 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.""" """A shiny encounter bypasses the route-lock when shinyClause is enabled."""
# First encounter occupies the group # First encounter occupies the group
await client.post( await auth_client.post(
f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters", f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters",
json={ json={
"routeId": enc_ctx["child1_id"], "routeId": enc_ctx["child1_id"],
@@ -338,7 +352,7 @@ class TestCreateEncounter:
}, },
) )
# Shiny encounter on sibling should succeed # Shiny encounter on sibling should succeed
response = await client.post( response = await auth_client.post(
f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters", f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters",
json={ json={
"routeId": enc_ctx["child2_id"], "routeId": enc_ctx["child2_id"],
@@ -351,7 +365,7 @@ class TestCreateEncounter:
assert response.json()["isShiny"] is True assert response.json()["isShiny"] is True
async def test_gift_bypasses_route_lock_when_clause_on( 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.""" """A gift encounter bypasses route-lock when giftClause is enabled."""
# Enable giftClause on the run # Enable giftClause on the run
@@ -359,7 +373,7 @@ class TestCreateEncounter:
run.rules = {"shinyClause": True, "giftClause": True} run.rules = {"shinyClause": True, "giftClause": True}
await db_session.commit() await db_session.commit()
await client.post( await auth_client.post(
f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters", f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters",
json={ json={
"routeId": enc_ctx["child1_id"], "routeId": enc_ctx["child1_id"],
@@ -367,7 +381,7 @@ class TestCreateEncounter:
"status": "caught", "status": "caught",
}, },
) )
response = await client.post( response = await auth_client.post(
f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters", f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters",
json={ json={
"routeId": enc_ctx["child2_id"], "routeId": enc_ctx["child2_id"],
@@ -387,8 +401,8 @@ class TestCreateEncounter:
class TestUpdateEncounter: class TestUpdateEncounter:
@pytest.fixture @pytest.fixture
async def encounter(self, client: AsyncClient, enc_ctx: dict) -> dict: async def encounter(self, auth_client: AsyncClient, enc_ctx: dict) -> dict:
response = await client.post( response = await auth_client.post(
f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters", f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters",
json={ json={
"routeId": enc_ctx["standalone_id"], "routeId": enc_ctx["standalone_id"],
@@ -398,17 +412,17 @@ class TestUpdateEncounter:
) )
return response.json() return response.json()
async def test_updates_nickname(self, client: AsyncClient, encounter: dict): async def test_updates_nickname(self, auth_client: AsyncClient, encounter: dict):
response = await client.patch( response = await auth_client.patch(
f"{ENC_BASE}/{encounter['id']}", json={"nickname": "Sparky"} f"{ENC_BASE}/{encounter['id']}", json={"nickname": "Sparky"}
) )
assert response.status_code == 200 assert response.status_code == 200
assert response.json()["nickname"] == "Sparky" assert response.json()["nickname"] == "Sparky"
async def test_updates_status_to_fainted( 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']}", f"{ENC_BASE}/{encounter['id']}",
json={"status": "fainted", "faintLevel": 12, "deathCause": "wild battle"}, json={"status": "fainted", "faintLevel": 12, "deathCause": "wild battle"},
) )
@@ -418,9 +432,9 @@ class TestUpdateEncounter:
assert data["faintLevel"] == 12 assert data["faintLevel"] == 12
assert data["deathCause"] == "wild battle" 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 ( 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 ).status_code == 404
@@ -431,8 +445,8 @@ class TestUpdateEncounter:
class TestDeleteEncounter: class TestDeleteEncounter:
@pytest.fixture @pytest.fixture
async def encounter(self, client: AsyncClient, enc_ctx: dict) -> dict: async def encounter(self, auth_client: AsyncClient, enc_ctx: dict) -> dict:
response = await client.post( response = await auth_client.post(
f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters", f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters",
json={ json={
"routeId": enc_ctx["standalone_id"], "routeId": enc_ctx["standalone_id"],
@@ -443,12 +457,14 @@ class TestDeleteEncounter:
return response.json() return response.json()
async def test_deletes_encounter( 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 # 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"]) assert all(e["id"] != encounter["id"] for e in detail["encounters"])
async def test_not_found_returns_404(self, client: AsyncClient): async def test_not_found_returns_404(self, auth_client: AsyncClient):
assert (await client.delete(f"{ENC_BASE}/9999")).status_code == 404 assert (await auth_client.delete(f"{ENC_BASE}/9999")).status_code == 404

128
docs/supabase-auth-setup.md Normal file
View 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

View File

@@ -11,6 +11,7 @@
"@dnd-kit/core": "6.3.1", "@dnd-kit/core": "6.3.1",
"@dnd-kit/sortable": "10.0.0", "@dnd-kit/sortable": "10.0.0",
"@dnd-kit/utilities": "3.2.2", "@dnd-kit/utilities": "3.2.2",
"@supabase/supabase-js": "^2.99.3",
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/typography": "^0.5.19",
"@tanstack/react-query": "5.91.3", "@tanstack/react-query": "5.91.3",
"react": "19.2.4", "react": "19.2.4",
@@ -2148,6 +2149,86 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@tailwindcss/node": {
"version": "4.2.2", "version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz",
@@ -2735,12 +2816,17 @@
"version": "24.12.0", "version": "24.12.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.0.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.0.tgz",
"integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==", "integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"undici-types": "~7.16.0" "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": { "node_modules/@types/react": {
"version": "19.2.14", "version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
@@ -2766,6 +2852,15 @@
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
"license": "MIT" "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": { "node_modules/@ungap/structured-clone": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz",
@@ -3584,6 +3679,15 @@
"node": ">= 14" "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": { "node_modules/indent-string": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
@@ -5778,7 +5882,6 @@
"version": "7.16.0", "version": "7.16.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/unified": { "node_modules/unified": {
@@ -6155,6 +6258,27 @@
"node": ">=8" "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": { "node_modules/xml-name-validator": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",

View File

@@ -19,6 +19,7 @@
"@dnd-kit/core": "6.3.1", "@dnd-kit/core": "6.3.1",
"@dnd-kit/sortable": "10.0.0", "@dnd-kit/sortable": "10.0.0",
"@dnd-kit/utilities": "3.2.2", "@dnd-kit/utilities": "3.2.2",
"@supabase/supabase-js": "^2.99.3",
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/typography": "^0.5.19",
"@tanstack/react-query": "5.91.3", "@tanstack/react-query": "5.91.3",
"react": "19.2.4", "react": "19.2.4",

View File

@@ -2,14 +2,17 @@ import { Routes, Route, Navigate } from 'react-router-dom'
import { Layout } from './components' import { Layout } from './components'
import { AdminLayout } from './components/admin' import { AdminLayout } from './components/admin'
import { import {
AuthCallback,
GenlockeDetail, GenlockeDetail,
GenlockeList, GenlockeList,
Home, Home,
JournalEntryPage, JournalEntryPage,
Login,
NewGenlocke, NewGenlocke,
NewRun, NewRun,
RunList, RunList,
RunEncounters, RunEncounters,
Signup,
Stats, Stats,
} from './pages' } from './pages'
import { import {
@@ -28,6 +31,9 @@ function App() {
<Routes> <Routes>
<Route path="/" element={<Layout />}> <Route path="/" element={<Layout />}>
<Route index element={<Home />} /> <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" element={<RunList />} />
<Route path="runs/new" element={<NewRun />} /> <Route path="runs/new" element={<NewRun />} />
<Route path="runs/:runId" element={<RunEncounters />} /> <Route path="runs/:runId" element={<RunEncounters />} />

View File

@@ -1,3 +1,5 @@
import { supabase } from '../lib/supabase'
const API_BASE = import.meta.env['VITE_API_URL'] ?? '' const API_BASE = import.meta.env['VITE_API_URL'] ?? ''
export class ApiError extends Error { 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> { async function request<T>(path: string, options?: RequestInit): Promise<T> {
const authHeaders = await getAuthHeaders()
const res = await fetch(`${API_BASE}/api/v1${path}`, { const res = await fetch(`${API_BASE}/api/v1${path}`, {
...options, ...options,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
...authHeaders,
...options?.headers, ...options?.headers,
}, },
}) })

View File

@@ -5,10 +5,7 @@ import type {
UpdateJournalEntryInput, UpdateJournalEntryInput,
} from '../types/journal' } from '../types/journal'
export function getJournalEntries( export function getJournalEntries(runId: number, bossResultId?: number): Promise<JournalEntry[]> {
runId: number,
bossResultId?: number
): Promise<JournalEntry[]> {
const params = bossResultId != null ? `?boss_result_id=${bossResultId}` : '' const params = bossResultId != null ? `?boss_result_id=${bossResultId}` : ''
return api.get(`/runs/${runId}/journal${params}`) return api.get(`/runs/${runId}/journal${params}`)
} }

30
frontend/src/api/moves.ts Normal file
View 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}`)
}

View File

@@ -1,9 +1,15 @@
import { type FormEvent, useMemo, useState } from 'react' 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' import { ConditionBadge } from './ConditionBadge'
interface BossDefeatModalProps { interface BossDefeatModalProps {
boss: BossBattle boss: BossBattle
aliveEncounters: EncounterDetail[]
onSubmit: (data: CreateBossResultInput) => void onSubmit: (data: CreateBossResultInput) => void
onClose: () => void onClose: () => void
isPending?: boolean isPending?: boolean
@@ -17,14 +23,43 @@ function matchVariant(labels: string[], starterName?: string | null): string | n
return matches.length === 1 ? (matches[0] ?? null) : null return matches.length === 1 ? (matches[0] ?? null) : null
} }
interface TeamSelection {
encounterId: number
level: number
}
export function BossDefeatModal({ export function BossDefeatModal({
boss, boss,
aliveEncounters,
onSubmit, onSubmit,
onClose, onClose,
isPending, isPending,
starterName, starterName,
}: BossDefeatModalProps) { }: 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 variantLabels = useMemo(() => {
const labels = new Set<string>() const labels = new Set<string>()
for (const bp of boss.pokemon) { for (const bp of boss.pokemon) {
@@ -52,10 +87,12 @@ export function BossDefeatModal({
const handleSubmit = (e: FormEvent) => { const handleSubmit = (e: FormEvent) => {
e.preventDefault() e.preventDefault()
const team: BossResultTeamMemberInput[] = Array.from(selectedTeam.values())
onSubmit({ onSubmit({
bossBattleId: boss.id, bossBattleId: boss.id,
result: 'won', result: 'won',
attempts: 1, attempts: 1,
team,
}) })
} }
@@ -92,7 +129,9 @@ export function BossDefeatModal({
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-3">
{[...displayedPokemon] {[...displayedPokemon]
.sort((a, b) => a.order - b.order) .sort((a, b) => a.order - b.order)
.map((bp) => ( .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"> <div key={bp.id} className="flex flex-col items-center">
{bp.pokemon.spriteUrl ? ( {bp.pokemon.spriteUrl ? (
<img src={bp.pokemon.spriteUrl} alt={bp.pokemon.name} className="w-10 h-10" /> <img src={bp.pokemon.spriteUrl} alt={bp.pokemon.name} className="w-10 h-10" />
@@ -102,8 +141,81 @@ export function BossDefeatModal({
<span className="text-xs text-text-tertiary capitalize">{bp.pokemon.name}</span> <span className="text-xs text-text-tertiary capitalize">{bp.pokemon.name}</span>
<span className="text-xs font-medium text-text-secondary">Lv.{bp.level}</span> <span className="text-xs font-medium text-text-secondary">Lv.{bp.level}</span>
<ConditionBadge condition={bp.conditionLabel} size="xs" /> <ConditionBadge condition={bp.conditionLabel} size="xs" />
{bp.ability && (
<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>
</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-8 h-8 bg-surface-3 rounded-full" />
)}
<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>
</div> </div>
)} )}

View File

@@ -2,6 +2,7 @@ import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event' import userEvent from '@testing-library/user-event'
import { MemoryRouter } from 'react-router-dom' import { MemoryRouter } from 'react-router-dom'
import { Layout } from './Layout' import { Layout } from './Layout'
import { AuthProvider } from '../contexts/AuthContext'
vi.mock('../hooks/useTheme', () => ({ vi.mock('../hooks/useTheme', () => ({
useTheme: () => ({ theme: 'dark' as const, toggle: vi.fn() }), useTheme: () => ({ theme: 'dark' as const, toggle: vi.fn() }),
@@ -10,7 +11,9 @@ vi.mock('../hooks/useTheme', () => ({
function renderLayout(initialPath = '/') { function renderLayout(initialPath = '/') {
return render( return render(
<MemoryRouter initialEntries={[initialPath]}> <MemoryRouter initialEntries={[initialPath]}>
<AuthProvider>
<Layout /> <Layout />
</AuthProvider>
</MemoryRouter> </MemoryRouter>
) )
} }

View File

@@ -1,6 +1,7 @@
import { useState } from 'react' import { useState } from 'react'
import { Link, Outlet, useLocation } from 'react-router-dom' import { Link, Outlet, useLocation } from 'react-router-dom'
import { useTheme } from '../hooks/useTheme' import { useTheme } from '../hooks/useTheme'
import { useAuth } from '../contexts/AuthContext'
const navLinks = [ const navLinks = [
{ to: '/runs/new', label: 'New Run' }, { 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() { export function Layout() {
const [menuOpen, setMenuOpen] = useState(false) const [menuOpen, setMenuOpen] = useState(false)
const location = useLocation() const location = useLocation()
@@ -103,6 +165,7 @@ export function Layout() {
</NavLink> </NavLink>
))} ))}
<ThemeToggle /> <ThemeToggle />
<UserMenu />
</div> </div>
{/* Mobile hamburger */} {/* Mobile hamburger */}
<div className="flex items-center gap-1 sm:hidden"> <div className="flex items-center gap-1 sm:hidden">
@@ -149,6 +212,9 @@ export function Layout() {
{link.label} {link.label}
</NavLink> </NavLink>
))} ))}
<div className="pt-2 border-t border-border-default mt-2">
<UserMenu onAction={() => setMenuOpen(false)} />
</div>
</div> </div>
</div> </div>
)} )}

View 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}</>
}

View 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>
)
}

View File

@@ -1,8 +1,38 @@
import { type FormEvent, useState } from 'react' import { type FormEvent, useState } from 'react'
import { PokemonSelector } from './PokemonSelector' import { PokemonSelector } from './PokemonSelector'
import { MoveSelector } from './MoveSelector'
import { AbilitySelector } from './AbilitySelector'
import type { BossBattle } from '../../types/game' import type { BossBattle } from '../../types/game'
import type { BossPokemonInput } from '../../types/admin' 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 { interface BossTeamEditorProps {
boss: BossBattle boss: BossBattle
onSave: (team: BossPokemonInput[]) => void onSave: (team: BossPokemonInput[]) => void
@@ -15,6 +45,19 @@ interface PokemonSlot {
pokemonName: string pokemonName: string
level: string level: string
order: number 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 { interface Variant {
@@ -22,6 +65,27 @@ interface Variant {
pokemon: PokemonSlot[] 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[] { function groupByVariant(boss: BossBattle): Variant[] {
const sorted = [...boss.pokemon].sort((a, b) => a.order - b.order) const sorted = [...boss.pokemon].sort((a, b) => a.order - b.order)
const map = new Map<string | null, PokemonSlot[]>() const map = new Map<string | null, PokemonSlot[]>()
@@ -34,25 +98,30 @@ function groupByVariant(boss: BossBattle): Variant[] {
pokemonName: bp.pokemon.name, pokemonName: bp.pokemon.name,
level: String(bp.level), level: String(bp.level),
order: bp.order, 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) { if (map.size === 0) {
return [ return [{ label: null, pokemon: [createEmptySlot(1)] }]
{
label: null,
pokemon: [{ pokemonId: null, pokemonName: '', level: '', order: 1 }],
},
]
} }
const variants: Variant[] = [] const variants: Variant[] = []
// null (default) first
if (map.has(null)) { if (map.has(null)) {
variants.push({ label: null, pokemon: map.get(null)! }) variants.push({ label: null, pokemon: map.get(null)! })
map.delete(null) map.delete(null)
} }
// Then alphabetical
const remaining = [...map.entries()].sort((a, b) => (a[0] ?? '').localeCompare(b[0] ?? '')) const remaining = [...map.entries()].sort((a, b) => (a[0] ?? '').localeCompare(b[0] ?? ''))
for (const [label, pokemon] of remaining) { for (const [label, pokemon] of remaining) {
variants.push({ label, pokemon }) variants.push({ label, pokemon })
@@ -65,9 +134,19 @@ export function BossTeamEditor({ boss, onSave, onClose, isSaving }: BossTeamEdit
const [activeTab, setActiveTab] = useState(0) const [activeTab, setActiveTab] = useState(0)
const [newVariantName, setNewVariantName] = useState('') const [newVariantName, setNewVariantName] = useState('')
const [showAddVariant, setShowAddVariant] = useState(false) const [showAddVariant, setShowAddVariant] = useState(false)
const [expandedSlots, setExpandedSlots] = useState<Set<string>>(new Set())
const activeVariant = variants[activeTab] ?? variants[0] 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) => { const updateVariant = (tabIndex: number, updater: (v: Variant) => Variant) => {
setVariants((prev) => prev.map((v, i) => (i === tabIndex ? updater(v) : v))) 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 = () => { const addSlot = () => {
updateVariant(activeTab, (v) => ({ updateVariant(activeTab, (v) => ({
...v, ...v,
pokemon: [ pokemon: [...v.pokemon, createEmptySlot(v.pokemon.length + 1)],
...v.pokemon,
{
pokemonId: null,
pokemonName: '',
level: '',
order: 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) => ({ updateVariant(activeTab, (v) => ({
...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() const name = newVariantName.trim()
if (!name) return if (!name) return
if (variants.some((v) => v.label === name)) return if (variants.some((v) => v.label === name)) return
setVariants((prev) => [ setVariants((prev) => [...prev, { label: name, pokemon: [createEmptySlot(1)] }])
...prev,
{
label: name,
pokemon: [{ pokemonId: null, pokemonName: '', level: '', order: 1 }],
},
])
setActiveTab(variants.length) setActiveTab(variants.length)
setNewVariantName('') setNewVariantName('')
setShowAddVariant(false) setShowAddVariant(false)
@@ -141,6 +206,13 @@ export function BossTeamEditor({ boss, onSave, onClose, isSaving }: BossTeamEdit
level: Number(p.level), level: Number(p.level),
order: i + 1, order: i + 1,
conditionLabel, 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 ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center"> <div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="fixed inset-0 bg-black/50" onClick={onClose} /> <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"> <div className="px-6 py-4 border-b border-border-default">
<h2 className="text-lg font-semibold">{boss.name}'s Team</h2> <h2 className="text-lg font-semibold">{boss.name}'s Team</h2>
</div> </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" className="px-2 py-1 text-sm border rounded bg-surface-2 border-border-default w-40"
autoFocus autoFocus
/> />
<button <button type="button" onClick={addVariant} className="px-2 py-1 text-sm text-text-link">
type="button"
onClick={addVariant}
className="px-2 py-1 text-sm text-text-link"
>
Add Add
</button> </button>
<button <button
@@ -228,15 +296,32 @@ export function BossTeamEditor({ boss, onSave, onClose, isSaving }: BossTeamEdit
</div> </div>
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<div className="px-6 py-4 space-y-3"> <div className="px-6 py-4 space-y-4">
{activeVariant?.pokemon.map((slot, index) => ( {activeVariant?.pokemon.map((slot, index) => {
<div key={`${activeTab}-${index}`} className="flex items-end gap-2"> 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"
>
{/* Main row: Pokemon + Level */}
<div className="flex items-end gap-2">
<div className="flex-1"> <div className="flex-1">
<PokemonSelector <PokemonSelector
label={`Pokemon ${index + 1}`} label={`Pokemon ${index + 1}`}
selectedId={slot.pokemonId} selectedId={slot.pokemonId}
initialName={slot.pokemonName} initialName={slot.pokemonName}
onChange={(id) => updateSlot(index, 'pokemonId', id)} onChange={(id) => updateSlot(index, { pokemonId: id })}
/> />
</div> </div>
<div className="w-20"> <div className="w-20">
@@ -246,10 +331,20 @@ export function BossTeamEditor({ boss, onSave, onClose, isSaving }: BossTeamEdit
min={1} min={1}
max={100} max={100}
value={slot.level} value={slot.level}
onChange={(e) => updateSlot(index, 'level', e.target.value)} onChange={(e) => updateSlot(index, { level: e.target.value })}
className="w-full px-3 py-2 border rounded-md bg-surface-2 border-border-default" className="w-full px-3 py-2 border rounded-md bg-surface-2 border-border-default"
/> />
</div> </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 <button
type="button" type="button"
onClick={() => removeSlot(index)} onClick={() => removeSlot(index)}
@@ -259,7 +354,91 @@ export function BossTeamEditor({ boss, onSave, onClose, isSaving }: BossTeamEdit
&#10005; &#10005;
</button> </button>
</div> </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 && ( {activeVariant && activeVariant.pokemon.length < 6 && (
<button <button

View 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>
)
}

View File

@@ -1,4 +1,5 @@
export { CustomRulesDisplay } from './CustomRulesDisplay' export { CustomRulesDisplay } from './CustomRulesDisplay'
export { ProtectedRoute } from './ProtectedRoute'
export { EggEncounterModal } from './EggEncounterModal' export { EggEncounterModal } from './EggEncounterModal'
export { EncounterMethodBadge } from './EncounterMethodBadge' export { EncounterMethodBadge } from './EncounterMethodBadge'
export { EncounterModal } from './EncounterModal' export { EncounterModal } from './EncounterModal'

View File

@@ -6,8 +6,8 @@ import type { BossResult, BossBattle } from '../../types/game'
interface JournalEditorProps { interface JournalEditorProps {
entry?: JournalEntry | null entry?: JournalEntry | null
bossResults?: BossResult[] bossResults?: BossResult[] | undefined
bosses?: BossBattle[] bosses?: BossBattle[] | undefined
onSave: (data: { title: string; body: string; bossResultId: number | null }) => void onSave: (data: { title: string; body: string; bossResultId: number | null }) => void
onDelete?: () => void onDelete?: () => void
onCancel: () => void onCancel: () => void
@@ -67,7 +67,10 @@ export function JournalEditor({
return ( return (
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div> <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 Title
</label> </label>
<input <input
@@ -82,7 +85,10 @@ export function JournalEditor({
</div> </div>
<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) Linked Boss Battle (optional)
</label> </label>
<select <select

View File

@@ -5,8 +5,8 @@ import type { BossResult, BossBattle } from '../../types/game'
interface JournalEntryViewProps { interface JournalEntryViewProps {
entry: JournalEntry entry: JournalEntry
bossResult?: BossResult | null bossResult?: BossResult | null | undefined
boss?: BossBattle | null boss?: BossBattle | null | undefined
onEdit?: () => void onEdit?: () => void
onBack?: () => 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" 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"> <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> </svg>
Back to Journal Back to Journal
</button> </button>

View File

@@ -19,7 +19,10 @@ function formatDate(dateString: string): string {
} }
function getPreviewSnippet(body: string, maxLength = 120): 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 if (stripped.length <= maxLength) return stripped
return stripped.slice(0, maxLength).trim() + '...' return stripped.slice(0, maxLength).trim() + '...'
} }

View File

@@ -6,8 +6,8 @@ import type { BossResult, BossBattle } from '../../types/game'
interface JournalSectionProps { interface JournalSectionProps {
runId: number runId: number
bossResults?: BossResult[] bossResults?: BossResult[] | undefined
bosses?: BossBattle[] bosses?: BossBattle[] | undefined
} }
type Mode = 'list' | 'new' type Mode = 'list' | 'new'

View 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
}

View 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,
})
}

View 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()

View File

@@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom' import { BrowserRouter } from 'react-router-dom'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { Toaster } from 'sonner' import { Toaster } from 'sonner'
import { AuthProvider } from './contexts/AuthContext'
import './index.css' import './index.css'
import App from './App.tsx' import App from './App.tsx'
@@ -19,8 +20,10 @@ createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<BrowserRouter> <BrowserRouter>
<AuthProvider>
<App /> <App />
<Toaster position="bottom-right" richColors /> <Toaster position="bottom-right" richColors />
</AuthProvider>
</BrowserRouter> </BrowserRouter>
</QueryClientProvider> </QueryClientProvider>
</StrictMode> </StrictMode>

View 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>
)
}

View File

@@ -1,7 +1,13 @@
import { Link, useParams } from 'react-router-dom' import { Link, useParams } from 'react-router-dom'
import { useGenlocke } from '../hooks/useGenlockes' import { useGenlocke } from '../hooks/useGenlockes'
import { usePokemonFamilies } from '../hooks/usePokemon' 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 type { GenlockeLegDetail, RetiredPokemon, RunStatus } from '../types'
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'

View File

@@ -2,11 +2,7 @@ import { useState } from 'react'
import { useParams, useNavigate } from 'react-router-dom' import { useParams, useNavigate } from 'react-router-dom'
import { useRun } from '../hooks/useRuns' import { useRun } from '../hooks/useRuns'
import { useBossResults, useGameBosses } from '../hooks/useBosses' import { useBossResults, useGameBosses } from '../hooks/useBosses'
import { import { useJournalEntry, useUpdateJournalEntry, useDeleteJournalEntry } from '../hooks/useJournal'
useJournalEntry,
useUpdateJournalEntry,
useDeleteJournalEntry,
} from '../hooks/useJournal'
import { JournalEntryView } from '../components/journal/JournalEntryView' import { JournalEntryView } from '../components/journal/JournalEntryView'
import { JournalEditor } from '../components/journal/JournalEditor' import { JournalEditor } from '../components/journal/JournalEditor'

View 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&apos;t have an account?{' '}
<Link to="/signup" className="text-accent-400 hover:text-accent-300">
Sign up
</Link>
</p>
</div>
</div>
)
}

View File

@@ -115,8 +115,8 @@ export function NewGenlocke() {
// In preset modes, filter out regions already used. // In preset modes, filter out regions already used.
const availableRegions = const availableRegions =
preset === 'custom' preset === 'custom'
? regions ?? [] ? (regions ?? [])
: regions?.filter((r) => !legs.some((l) => l.region === r.name)) ?? [] : (regions?.filter((r) => !legs.some((l) => l.region === r.name)) ?? [])
const usedRegionNames = new Set(legs.map((l) => l.region)) const usedRegionNames = new Set(legs.map((l) => l.region))

View File

@@ -3,7 +3,7 @@ import { useNavigate } from 'react-router-dom'
import { GameGrid, RulesConfiguration, StepIndicator } from '../components' import { GameGrid, RulesConfiguration, StepIndicator } from '../components'
import { useGames, useGameRoutes } from '../hooks/useGames' import { useGames, useGameRoutes } from '../hooks/useGames'
import { useCreateRun, useRuns, useNamingCategories } from '../hooks/useRuns' 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 { DEFAULT_RULES } from '../types'
import { RULE_DEFINITIONS } from '../types/rules' import { RULE_DEFINITIONS } from '../types/rules'
@@ -21,6 +21,7 @@ export function NewRun() {
const [rules, setRules] = useState<NuzlockeRules>(DEFAULT_RULES) const [rules, setRules] = useState<NuzlockeRules>(DEFAULT_RULES)
const [runName, setRunName] = useState('') const [runName, setRunName] = useState('')
const [namingScheme, setNamingScheme] = useState<string | null>(null) const [namingScheme, setNamingScheme] = useState<string | null>(null)
const [visibility, setVisibility] = useState<RunVisibility>('public')
const { data: routes } = useGameRoutes(selectedGame?.id ?? null) const { data: routes } = useGameRoutes(selectedGame?.id ?? null)
const hiddenRules = useMemo(() => { const hiddenRules = useMemo(() => {
@@ -46,7 +47,7 @@ export function NewRun() {
const handleCreate = () => { const handleCreate = () => {
if (!selectedGame) return if (!selectedGame) return
createRun.mutate( createRun.mutate(
{ gameId: selectedGame.id, name: runName, rules, namingScheme }, { gameId: selectedGame.id, name: runName, rules, namingScheme, visibility },
{ onSuccess: (data) => navigate(`/runs/${data.id}`) } { onSuccess: (data) => navigate(`/runs/${data.id}`) }
) )
} }
@@ -195,6 +196,29 @@ export function NewRun() {
</div> </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"> <div className="border-t border-border-default pt-4">
<h3 className="text-sm font-medium text-text-tertiary mb-2">Summary</h3> <h3 className="text-sm font-medium text-text-tertiary mb-2">Summary</h3>
<dl className="space-y-1 text-sm"> <dl className="space-y-1 text-sm">
@@ -223,6 +247,10 @@ export function NewRun() {
: 'None'} : 'None'}
</dd> </dd>
</div> </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> </dl>
</div> </div>
</div> </div>

View File

@@ -1,10 +1,18 @@
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { useParams, Link } from 'react-router-dom' import { useParams, Link } from 'react-router-dom'
import { useAuth } from '../contexts/AuthContext'
import { useRun, useUpdateRun, useNamingCategories } from '../hooks/useRuns' import { useRun, useUpdateRun, useNamingCategories } from '../hooks/useRuns'
import { useGameRoutes } from '../hooks/useGames' import { useGameRoutes } from '../hooks/useGames'
import { useCreateEncounter, useUpdateEncounter } from '../hooks/useEncounters' import { useCreateEncounter, useUpdateEncounter } from '../hooks/useEncounters'
import { CustomRulesDisplay, StatCard, PokemonCard, RuleBadges, StatusChangeModal, EndRunModal } from '../components' import {
import type { RunStatus, EncounterDetail } from '../types' CustomRulesDisplay,
StatCard,
PokemonCard,
RuleBadges,
StatusChangeModal,
EndRunModal,
} from '../components'
import type { RunStatus, EncounterDetail, RunVisibility } from '../types'
type TeamSortKey = 'route' | 'level' | 'species' | 'dex' type TeamSortKey = 'route' | 'level' | 'species' | 'dex'
@@ -49,6 +57,7 @@ export function RunDashboard() {
const runIdNum = Number(runId) const runIdNum = Number(runId)
const { data: run, isLoading, error } = useRun(runIdNum) const { data: run, isLoading, error } = useRun(runIdNum)
const { data: routes } = useGameRoutes(run?.gameId ?? null) const { data: routes } = useGameRoutes(run?.gameId ?? null)
const { user } = useAuth()
const createEncounter = useCreateEncounter(runIdNum) const createEncounter = useCreateEncounter(runIdNum)
const updateEncounter = useUpdateEncounter(runIdNum) const updateEncounter = useUpdateEncounter(runIdNum)
const updateRun = useUpdateRun(runIdNum) const updateRun = useUpdateRun(runIdNum)
@@ -57,6 +66,9 @@ export function RunDashboard() {
const [showEndRun, setShowEndRun] = useState(false) const [showEndRun, setShowEndRun] = useState(false)
const [teamSort, setTeamSort] = useState<TeamSortKey>('route') const [teamSort, setTeamSort] = useState<TeamSortKey>('route')
const isOwner = user && run?.owner?.id === user.id
const canEdit = isOwner || !run?.owner
const encounters = run?.encounters ?? [] const encounters = run?.encounters ?? []
const alive = useMemo( const alive = useMemo(
() => () =>
@@ -190,11 +202,31 @@ export function RunDashboard() {
<CustomRulesDisplay customRules={run.rules?.customRules ?? ''} /> <CustomRulesDisplay customRules={run.rules?.customRules ?? ''} />
</div> </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 */} {/* Naming Scheme */}
{namingCategories && namingCategories.length > 0 && ( {namingCategories && namingCategories.length > 0 && (
<div className="mb-6"> <div className="mb-6">
<h2 className="text-sm font-medium text-text-tertiary mb-2">Naming Scheme</h2> <h2 className="text-sm font-medium text-text-tertiary mb-2">Naming Scheme</h2>
{isActive ? ( {isActive && canEdit ? (
<select <select
value={run.namingScheme ?? ''} value={run.namingScheme ?? ''}
onChange={(e) => updateRun.mutate({ namingScheme: e.target.value || null })} onChange={(e) => updateRun.mutate({ namingScheme: e.target.value || null })}
@@ -246,7 +278,7 @@ export function RunDashboard() {
<PokemonCard <PokemonCard
key={enc.id} key={enc.id}
encounter={enc} encounter={enc}
onClick={isActive ? () => setSelectedEncounter(enc) : undefined} onClick={isActive && canEdit ? () => setSelectedEncounter(enc) : undefined}
/> />
))} ))}
</div> </div>
@@ -263,7 +295,7 @@ export function RunDashboard() {
key={enc.id} key={enc.id}
encounter={enc} encounter={enc}
showFaintLevel showFaintLevel
onClick={isActive ? () => setSelectedEncounter(enc) : undefined} onClick={isActive && canEdit ? () => setSelectedEncounter(enc) : undefined}
/> />
))} ))}
</div> </div>
@@ -272,7 +304,7 @@ export function RunDashboard() {
{/* Quick Actions */} {/* Quick Actions */}
<div className="mt-8 flex gap-3"> <div className="mt-8 flex gap-3">
{isActive && ( {isActive && canEdit && (
<> <>
<Link <Link
to={`/runs/${runId}/encounters`} to={`/runs/${runId}/encounters`}

View File

@@ -246,7 +246,9 @@ function BossTeamPreview({
<div className="flex gap-2 flex-wrap"> <div className="flex gap-2 flex-wrap">
{[...displayed] {[...displayed]
.sort((a, b) => a.order - b.order) .sort((a, b) => a.order - b.order)
.map((bp) => ( .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"> <div key={bp.id} className="flex items-center gap-1">
{bp.pokemon.spriteUrl ? ( {bp.pokemon.spriteUrl ? (
<img src={bp.pokemon.spriteUrl} alt={bp.pokemon.name} className="w-20 h-20" /> <img src={bp.pokemon.spriteUrl} alt={bp.pokemon.name} className="w-20 h-20" />
@@ -256,9 +258,21 @@ function BossTeamPreview({
<div className="flex flex-col items-start gap-0.5"> <div className="flex flex-col items-start gap-0.5">
<span className="text-xs text-text-tertiary">Lvl {bp.level}</span> <span className="text-xs text-text-tertiary">Lvl {bp.level}</span>
<ConditionBadge condition={bp.conditionLabel} size="xs" /> <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> </div>
</div> </div>
) )
@@ -663,6 +677,28 @@ export function RunEncounters() {
return set return set
}, [bossResults]) }, [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(() => { const sortedBosses = useMemo(() => {
if (!bosses) return [] if (!bosses) return []
return [...bosses].sort((a, b) => a.order - b.order) return [...bosses].sort((a, b) => a.order - b.order)
@@ -1287,7 +1323,9 @@ export function RunEncounters() {
onClick={() => { onClick={() => {
const remaining = totalLocations - completedCount const remaining = totalLocations - completedCount
if ( if (
window.confirm(`Randomize encounters for all ${remaining} remaining locations?`) window.confirm(
`Randomize encounters for all ${remaining} remaining locations?`
)
) { ) {
bulkRandomize.mutate() bulkRandomize.mutate()
} }
@@ -1349,7 +1387,9 @@ export function RunEncounters() {
{filteredRoutes.map((route) => { {filteredRoutes.map((route) => {
// Collect all route IDs to check for boss cards after // Collect all route IDs to check for boss cards after
const routeIds: number[] = const routeIds: number[] =
route.children.length > 0 ? [route.id, ...route.children.map((c) => c.id)] : [route.id] 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) // Find boss battles positioned after this route (or any of its children)
const bossesHere: BossBattle[] = [] const bossesHere: BossBattle[] = []
@@ -1507,7 +1547,11 @@ export function RunEncounters() {
stroke="currentColor" stroke="currentColor"
strokeWidth={2} strokeWidth={2}
> >
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" /> <path
strokeLinecap="round"
strokeLinejoin="round"
d="M9 5l7 7-7 7"
/>
</svg> </svg>
{boss.spriteUrl && ( {boss.spriteUrl && (
<img src={boss.spriteUrl} alt={boss.name} className="h-10 w-auto" /> <img src={boss.spriteUrl} alt={boss.name} className="h-10 w-auto" />
@@ -1546,6 +1590,36 @@ export function RunEncounters() {
{isBossExpanded && boss.pokemon.length > 0 && ( {isBossExpanded && boss.pokemon.length > 0 && (
<BossTeamPreview pokemon={boss.pokemon} starterName={starterName} /> <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> </div>
{sectionAfter && ( {sectionAfter && (
<div className="flex items-center gap-3 my-4"> <div className="flex items-center gap-3 my-4">
@@ -1633,6 +1707,7 @@ export function RunEncounters() {
{selectedBoss && ( {selectedBoss && (
<BossDefeatModal <BossDefeatModal
boss={selectedBoss} boss={selectedBoss}
aliveEncounters={alive}
onSubmit={(data) => { onSubmit={(data) => {
createBossResult.mutate(data, { createBossResult.mutate(data, {
onSuccess: () => setSelectedBoss(null), onSuccess: () => setSelectedBoss(null),

View File

@@ -1,6 +1,8 @@
import { useMemo } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { useAuth } from '../contexts/AuthContext'
import { useRuns } from '../hooks/useRuns' import { useRuns } from '../hooks/useRuns'
import type { RunStatus } from '../types' import type { NuzlockeRun, RunStatus } from '../types'
const statusStyles: Record<RunStatus, string> = { const statusStyles: Record<RunStatus, string> = {
active: 'bg-status-active-bg text-status-active border border-status-active/20', 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', 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"> &middot; 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() { export function RunList() {
const { data: runs, isLoading, error } = useRuns() 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 ( return (
<div className="max-w-4xl mx-auto p-8"> <div className="max-w-4xl mx-auto p-8">
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<h1 className="text-3xl font-bold text-text-primary">Your Runs</h1> <h1 className="text-3xl font-bold text-text-primary">
{user ? 'Nuzlocke Runs' : 'Public Runs'}
</h1>
{user && (
<Link <Link
to="/runs/new" 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]" 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 Start New Run
</Link> </Link>
)}
</div> </div>
{isLoading && ( {showLoading && (
<div className="flex items-center justify-center py-12"> <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 className="w-8 h-8 border-4 border-accent-400 border-t-transparent rounded-full animate-spin" />
</div> </div>
@@ -35,49 +110,56 @@ export function RunList() {
</div> </div>
)} )}
{runs && runs.length === 0 && ( {!showLoading && runs && runs.length === 0 && (
<div className="text-center py-16"> <div className="text-center py-16">
<p className="text-lg text-text-secondary mb-4"> <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> </p>
{user && (
<Link <Link
to="/runs/new" 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]" 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 Start New Run
</Link> </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> </div>
)} )}
{runs && runs.length > 0 && ( {!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"> <div className="space-y-2">
{runs.map((run) => ( {myRuns.map((run) => (
<Link <RunCard key={run.id} run={run} isOwned />
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>
</div>
</Link>
))} ))}
</div> </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> </div>
) )

View 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&apos;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>
)
}

View File

@@ -1,9 +1,12 @@
export { AuthCallback } from './AuthCallback'
export { GenlockeDetail } from './GenlockeDetail' export { GenlockeDetail } from './GenlockeDetail'
export { GenlockeList } from './GenlockeList' export { GenlockeList } from './GenlockeList'
export { Home } from './Home' export { Home } from './Home'
export { JournalEntryPage } from './JournalEntryPage' export { JournalEntryPage } from './JournalEntryPage'
export { Login } from './Login'
export { NewGenlocke } from './NewGenlocke' export { NewGenlocke } from './NewGenlocke'
export { NewRun } from './NewRun' export { NewRun } from './NewRun'
export { RunList } from './RunList' export { RunList } from './RunList'
export { RunEncounters } from './RunEncounters' export { RunEncounters } from './RunEncounters'
export { Signup } from './Signup'
export { Stats } from './Stats' export { Stats } from './Stats'

View File

@@ -2,6 +2,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render, type RenderOptions } from '@testing-library/react' import { render, type RenderOptions } from '@testing-library/react'
import { type ReactElement } from 'react' import { type ReactElement } from 'react'
import { MemoryRouter } from 'react-router-dom' import { MemoryRouter } from 'react-router-dom'
import { AuthProvider } from '../contexts/AuthContext'
export function createTestQueryClient(): QueryClient { export function createTestQueryClient(): QueryClient {
return new QueryClient({ return new QueryClient({
@@ -16,7 +17,9 @@ function AllProviders({ children }: { children: React.ReactNode }) {
const queryClient = createTestQueryClient() const queryClient = createTestQueryClient()
return ( return (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<MemoryRouter>{children}</MemoryRouter> <MemoryRouter>
<AuthProvider>{children}</AuthProvider>
</MemoryRouter>
</QueryClientProvider> </QueryClientProvider>
) )
} }

View File

@@ -182,6 +182,14 @@ export interface BossPokemonInput {
level: number level: number
order: number order: number
conditionLabel?: string | null 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 // Genlocke admin

View File

@@ -84,6 +84,12 @@ export interface Encounter {
} }
export type RunStatus = 'active' | 'completed' | 'failed' export type RunStatus = 'active' | 'completed' | 'failed'
export type RunVisibility = 'public' | 'private'
export interface RunOwner {
id: string
displayName: string | null
}
export interface NuzlockeRun { export interface NuzlockeRun {
id: number id: number
@@ -93,6 +99,8 @@ export interface NuzlockeRun {
rules: NuzlockeRules rules: NuzlockeRules
hofEncounterIds: number[] | null hofEncounterIds: number[] | null
namingScheme: string | null namingScheme: string | null
visibility: RunVisibility
owner: RunOwner | null
startedAt: string startedAt: string
completedAt: string | null completedAt: string | null
} }
@@ -136,6 +144,7 @@ export interface CreateRunInput {
name: string name: string
rules?: NuzlockeRules rules?: NuzlockeRules
namingScheme?: string | null namingScheme?: string | null
visibility?: RunVisibility
} }
export interface UpdateRunInput { export interface UpdateRunInput {
@@ -144,6 +153,7 @@ export interface UpdateRunInput {
rules?: NuzlockeRules rules?: NuzlockeRules
hofEncounterIds?: number[] hofEncounterIds?: number[]
namingScheme?: string | null namingScheme?: string | null
visibility?: RunVisibility
} }
export interface CreateEncounterInput { export interface CreateEncounterInput {
@@ -175,6 +185,16 @@ export type BossType =
| 'totem' | 'totem'
| 'other' | 'other'
export interface MoveRef {
id: number
name: string
}
export interface AbilityRef {
id: number
name: string
}
export interface BossPokemon { export interface BossPokemon {
id: number id: number
pokemonId: number pokemonId: number
@@ -182,6 +202,19 @@ export interface BossPokemon {
order: number order: number
conditionLabel: string | null conditionLabel: string | null
pokemon: Pokemon 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 { export interface BossBattle {
@@ -202,6 +235,12 @@ export interface BossBattle {
pokemon: BossPokemon[] pokemon: BossPokemon[]
} }
export interface BossResultTeamMember {
id: number
encounterId: number
level: number
}
export interface BossResult { export interface BossResult {
id: number id: number
runId: number runId: number
@@ -209,12 +248,19 @@ export interface BossResult {
result: 'won' | 'lost' result: 'won' | 'lost'
attempts: number attempts: number
completedAt: string | null completedAt: string | null
team: BossResultTeamMember[]
}
export interface BossResultTeamMemberInput {
encounterId: number
level: number
} }
export interface CreateBossResultInput { export interface CreateBossResultInput {
bossBattleId: number bossBattleId: number
result: 'won' | 'lost' result: 'won' | 'lost'
attempts?: number attempts?: number
team?: BossResultTeamMemberInput[]
} }
// Re-export for convenience // Re-export for convenience