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