Release: auth system, admin RBAC, and production Supabase setup #70
@@ -2,5 +2,12 @@
|
|||||||
DEBUG=true
|
DEBUG=true
|
||||||
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/nuzlocke
|
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/nuzlocke
|
||||||
|
|
||||||
|
# Supabase Auth (backend)
|
||||||
|
SUPABASE_URL=https://your-project.supabase.co
|
||||||
|
SUPABASE_ANON_KEY=your-anon-key
|
||||||
|
SUPABASE_JWT_SECRET=your-jwt-secret
|
||||||
|
|
||||||
# Frontend settings (used by Vite)
|
# Frontend settings (used by Vite)
|
||||||
VITE_API_URL=http://localhost:8000
|
VITE_API_URL=http://localhost:8000
|
||||||
|
VITE_SUPABASE_URL=https://your-project.supabase.co
|
||||||
|
VITE_SUPABASE_ANON_KEY=your-anon-key
|
||||||
|
|||||||
@@ -7,3 +7,8 @@ API_V1_PREFIX="/api/v1"
|
|||||||
|
|
||||||
# Database settings
|
# Database settings
|
||||||
DATABASE_URL="sqlite:///./nuzlocke.db"
|
DATABASE_URL="sqlite:///./nuzlocke.db"
|
||||||
|
|
||||||
|
# Supabase Auth
|
||||||
|
SUPABASE_URL=https://your-project.supabase.co
|
||||||
|
SUPABASE_ANON_KEY=your-anon-key
|
||||||
|
SUPABASE_JWT_SECRET=your-jwt-secret
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ dependencies = [
|
|||||||
"sqlalchemy[asyncio]==2.0.48",
|
"sqlalchemy[asyncio]==2.0.48",
|
||||||
"asyncpg==0.31.0",
|
"asyncpg==0.31.0",
|
||||||
"alembic==1.18.4",
|
"alembic==1.18.4",
|
||||||
|
"PyJWT==2.10.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
|||||||
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.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from app.core.auth import AuthUser, require_auth
|
||||||
from app.core.database import get_session
|
from app.core.database import get_session
|
||||||
from app.models.boss_battle import BossBattle
|
from app.models.boss_battle import BossBattle
|
||||||
from app.models.boss_pokemon import BossPokemon
|
from app.models.boss_pokemon import BossPokemon
|
||||||
from app.models.boss_result import BossResult
|
from app.models.boss_result import BossResult
|
||||||
|
from app.models.boss_result_team import BossResultTeam
|
||||||
|
from app.models.encounter import Encounter
|
||||||
from app.models.game import Game
|
from app.models.game import Game
|
||||||
from app.models.nuzlocke_run import NuzlockeRun
|
from app.models.nuzlocke_run import NuzlockeRun
|
||||||
from app.models.pokemon import Pokemon
|
from app.models.pokemon import Pokemon
|
||||||
@@ -28,6 +31,18 @@ from app.seeds.loader import upsert_bosses
|
|||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _boss_pokemon_load_options():
|
||||||
|
"""Standard eager-loading options for BossPokemon relationships."""
|
||||||
|
return (
|
||||||
|
selectinload(BossBattle.pokemon).selectinload(BossPokemon.pokemon),
|
||||||
|
selectinload(BossBattle.pokemon).selectinload(BossPokemon.ability),
|
||||||
|
selectinload(BossBattle.pokemon).selectinload(BossPokemon.move1),
|
||||||
|
selectinload(BossBattle.pokemon).selectinload(BossPokemon.move2),
|
||||||
|
selectinload(BossBattle.pokemon).selectinload(BossPokemon.move3),
|
||||||
|
selectinload(BossBattle.pokemon).selectinload(BossPokemon.move4),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _get_version_group_id(session: AsyncSession, game_id: int) -> int:
|
async def _get_version_group_id(session: AsyncSession, game_id: int) -> int:
|
||||||
game = await session.get(Game, game_id)
|
game = await session.get(Game, game_id)
|
||||||
if game is None:
|
if game is None:
|
||||||
@@ -53,7 +68,7 @@ async def list_bosses(
|
|||||||
query = (
|
query = (
|
||||||
select(BossBattle)
|
select(BossBattle)
|
||||||
.where(BossBattle.version_group_id == vg_id)
|
.where(BossBattle.version_group_id == vg_id)
|
||||||
.options(selectinload(BossBattle.pokemon).selectinload(BossPokemon.pokemon))
|
.options(*_boss_pokemon_load_options())
|
||||||
.order_by(BossBattle.order)
|
.order_by(BossBattle.order)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -71,6 +86,7 @@ async def reorder_bosses(
|
|||||||
game_id: int,
|
game_id: int,
|
||||||
data: BossReorderRequest,
|
data: BossReorderRequest,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
|
_user: AuthUser = Depends(require_auth),
|
||||||
):
|
):
|
||||||
vg_id = await _get_version_group_id(session, game_id)
|
vg_id = await _get_version_group_id(session, game_id)
|
||||||
|
|
||||||
@@ -101,7 +117,7 @@ async def reorder_bosses(
|
|||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(BossBattle)
|
select(BossBattle)
|
||||||
.where(BossBattle.version_group_id == vg_id)
|
.where(BossBattle.version_group_id == vg_id)
|
||||||
.options(selectinload(BossBattle.pokemon).selectinload(BossPokemon.pokemon))
|
.options(*_boss_pokemon_load_options())
|
||||||
.order_by(BossBattle.order)
|
.order_by(BossBattle.order)
|
||||||
)
|
)
|
||||||
return result.scalars().all()
|
return result.scalars().all()
|
||||||
@@ -114,6 +130,7 @@ async def create_boss(
|
|||||||
game_id: int,
|
game_id: int,
|
||||||
data: BossBattleCreate,
|
data: BossBattleCreate,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
|
_user: AuthUser = Depends(require_auth),
|
||||||
):
|
):
|
||||||
vg_id = await _get_version_group_id(session, game_id)
|
vg_id = await _get_version_group_id(session, game_id)
|
||||||
|
|
||||||
@@ -133,7 +150,7 @@ async def create_boss(
|
|||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(BossBattle)
|
select(BossBattle)
|
||||||
.where(BossBattle.id == boss.id)
|
.where(BossBattle.id == boss.id)
|
||||||
.options(selectinload(BossBattle.pokemon).selectinload(BossPokemon.pokemon))
|
.options(*_boss_pokemon_load_options())
|
||||||
)
|
)
|
||||||
return result.scalar_one()
|
return result.scalar_one()
|
||||||
|
|
||||||
@@ -144,6 +161,7 @@ async def update_boss(
|
|||||||
boss_id: int,
|
boss_id: int,
|
||||||
data: BossBattleUpdate,
|
data: BossBattleUpdate,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
|
_user: AuthUser = Depends(require_auth),
|
||||||
):
|
):
|
||||||
vg_id = await _get_version_group_id(session, game_id)
|
vg_id = await _get_version_group_id(session, game_id)
|
||||||
|
|
||||||
@@ -158,7 +176,7 @@ async def update_boss(
|
|||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(BossBattle)
|
select(BossBattle)
|
||||||
.where(BossBattle.id == boss_id, BossBattle.version_group_id == vg_id)
|
.where(BossBattle.id == boss_id, BossBattle.version_group_id == vg_id)
|
||||||
.options(selectinload(BossBattle.pokemon).selectinload(BossPokemon.pokemon))
|
.options(*_boss_pokemon_load_options())
|
||||||
)
|
)
|
||||||
boss = result.scalar_one_or_none()
|
boss = result.scalar_one_or_none()
|
||||||
if boss is None:
|
if boss is None:
|
||||||
@@ -174,7 +192,7 @@ async def update_boss(
|
|||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(BossBattle)
|
select(BossBattle)
|
||||||
.where(BossBattle.id == boss.id)
|
.where(BossBattle.id == boss.id)
|
||||||
.options(selectinload(BossBattle.pokemon).selectinload(BossPokemon.pokemon))
|
.options(*_boss_pokemon_load_options())
|
||||||
)
|
)
|
||||||
return result.scalar_one()
|
return result.scalar_one()
|
||||||
|
|
||||||
@@ -184,6 +202,7 @@ async def delete_boss(
|
|||||||
game_id: int,
|
game_id: int,
|
||||||
boss_id: int,
|
boss_id: int,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
|
_user: AuthUser = Depends(require_auth),
|
||||||
):
|
):
|
||||||
vg_id = await _get_version_group_id(session, game_id)
|
vg_id = await _get_version_group_id(session, game_id)
|
||||||
|
|
||||||
@@ -206,6 +225,7 @@ async def bulk_import_bosses(
|
|||||||
game_id: int,
|
game_id: int,
|
||||||
items: list[BulkBossItem],
|
items: list[BulkBossItem],
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
|
_user: AuthUser = Depends(require_auth),
|
||||||
):
|
):
|
||||||
vg_id = await _get_version_group_id(session, game_id)
|
vg_id = await _get_version_group_id(session, game_id)
|
||||||
|
|
||||||
@@ -248,6 +268,7 @@ async def set_boss_team(
|
|||||||
boss_id: int,
|
boss_id: int,
|
||||||
team: list[BossPokemonInput],
|
team: list[BossPokemonInput],
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
|
_user: AuthUser = Depends(require_auth),
|
||||||
):
|
):
|
||||||
vg_id = await _get_version_group_id(session, game_id)
|
vg_id = await _get_version_group_id(session, game_id)
|
||||||
|
|
||||||
@@ -272,6 +293,13 @@ async def set_boss_team(
|
|||||||
level=item.level,
|
level=item.level,
|
||||||
order=item.order,
|
order=item.order,
|
||||||
condition_label=item.condition_label,
|
condition_label=item.condition_label,
|
||||||
|
ability_id=item.ability_id,
|
||||||
|
held_item=item.held_item,
|
||||||
|
nature=item.nature,
|
||||||
|
move1_id=item.move1_id,
|
||||||
|
move2_id=item.move2_id,
|
||||||
|
move3_id=item.move3_id,
|
||||||
|
move4_id=item.move4_id,
|
||||||
)
|
)
|
||||||
session.add(bp)
|
session.add(bp)
|
||||||
|
|
||||||
@@ -286,7 +314,7 @@ async def set_boss_team(
|
|||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(BossBattle)
|
select(BossBattle)
|
||||||
.where(BossBattle.id == boss.id)
|
.where(BossBattle.id == boss.id)
|
||||||
.options(selectinload(BossBattle.pokemon).selectinload(BossPokemon.pokemon))
|
.options(*_boss_pokemon_load_options())
|
||||||
)
|
)
|
||||||
return result.scalar_one()
|
return result.scalar_one()
|
||||||
|
|
||||||
@@ -301,7 +329,10 @@ async def list_boss_results(run_id: int, session: AsyncSession = Depends(get_ses
|
|||||||
raise HTTPException(status_code=404, detail="Run not found")
|
raise HTTPException(status_code=404, detail="Run not found")
|
||||||
|
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(BossResult).where(BossResult.run_id == run_id).order_by(BossResult.id)
|
select(BossResult)
|
||||||
|
.where(BossResult.run_id == run_id)
|
||||||
|
.options(selectinload(BossResult.team))
|
||||||
|
.order_by(BossResult.id)
|
||||||
)
|
)
|
||||||
return result.scalars().all()
|
return result.scalars().all()
|
||||||
|
|
||||||
@@ -313,6 +344,7 @@ async def create_boss_result(
|
|||||||
run_id: int,
|
run_id: int,
|
||||||
data: BossResultCreate,
|
data: BossResultCreate,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
|
_user: AuthUser = Depends(require_auth),
|
||||||
):
|
):
|
||||||
run = await session.get(NuzlockeRun, run_id)
|
run = await session.get(NuzlockeRun, run_id)
|
||||||
if run is None:
|
if run is None:
|
||||||
@@ -322,12 +354,30 @@ async def create_boss_result(
|
|||||||
if boss is None:
|
if boss is None:
|
||||||
raise HTTPException(status_code=404, detail="Boss battle not found")
|
raise HTTPException(status_code=404, detail="Boss battle not found")
|
||||||
|
|
||||||
|
# Validate team encounter IDs belong to this run
|
||||||
|
if data.team:
|
||||||
|
encounter_ids = [t.encounter_id for t in data.team]
|
||||||
|
enc_result = await session.execute(
|
||||||
|
select(Encounter).where(
|
||||||
|
Encounter.id.in_(encounter_ids), Encounter.run_id == run_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
found_encounters = {e.id for e in enc_result.scalars().all()}
|
||||||
|
missing = [eid for eid in encounter_ids if eid not in found_encounters]
|
||||||
|
if missing:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Encounters not found in this run: {missing}",
|
||||||
|
)
|
||||||
|
|
||||||
# Check for existing result (upsert)
|
# Check for existing result (upsert)
|
||||||
existing = await session.execute(
|
existing = await session.execute(
|
||||||
select(BossResult).where(
|
select(BossResult)
|
||||||
|
.where(
|
||||||
BossResult.run_id == run_id,
|
BossResult.run_id == run_id,
|
||||||
BossResult.boss_battle_id == data.boss_battle_id,
|
BossResult.boss_battle_id == data.boss_battle_id,
|
||||||
)
|
)
|
||||||
|
.options(selectinload(BossResult.team))
|
||||||
)
|
)
|
||||||
result = existing.scalar_one_or_none()
|
result = existing.scalar_one_or_none()
|
||||||
|
|
||||||
@@ -335,6 +385,10 @@ async def create_boss_result(
|
|||||||
result.result = data.result
|
result.result = data.result
|
||||||
result.attempts = data.attempts
|
result.attempts = data.attempts
|
||||||
result.completed_at = datetime.now(UTC) if data.result == "won" else None
|
result.completed_at = datetime.now(UTC) if data.result == "won" else None
|
||||||
|
# Clear existing team and add new
|
||||||
|
for tm in result.team:
|
||||||
|
await session.delete(tm)
|
||||||
|
await session.flush()
|
||||||
else:
|
else:
|
||||||
result = BossResult(
|
result = BossResult(
|
||||||
run_id=run_id,
|
run_id=run_id,
|
||||||
@@ -344,10 +398,26 @@ async def create_boss_result(
|
|||||||
completed_at=datetime.now(UTC) if data.result == "won" else None,
|
completed_at=datetime.now(UTC) if data.result == "won" else None,
|
||||||
)
|
)
|
||||||
session.add(result)
|
session.add(result)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
# Add team members
|
||||||
|
for tm in data.team:
|
||||||
|
team_member = BossResultTeam(
|
||||||
|
boss_result_id=result.id,
|
||||||
|
encounter_id=tm.encounter_id,
|
||||||
|
level=tm.level,
|
||||||
|
)
|
||||||
|
session.add(team_member)
|
||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(result)
|
|
||||||
return result
|
# Re-fetch with team loaded
|
||||||
|
fresh = await session.execute(
|
||||||
|
select(BossResult)
|
||||||
|
.where(BossResult.id == result.id)
|
||||||
|
.options(selectinload(BossResult.team))
|
||||||
|
)
|
||||||
|
return fresh.scalar_one()
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/runs/{run_id}/boss-results/{result_id}", status_code=204)
|
@router.delete("/runs/{run_id}/boss-results/{result_id}", status_code=204)
|
||||||
@@ -355,6 +425,7 @@ async def delete_boss_result(
|
|||||||
run_id: int,
|
run_id: int,
|
||||||
result_id: int,
|
result_id: int,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
|
_user: AuthUser = Depends(require_auth),
|
||||||
):
|
):
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(BossResult).where(
|
select(BossResult).where(
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from sqlalchemy import select
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import joinedload, selectinload
|
from sqlalchemy.orm import joinedload, selectinload
|
||||||
|
|
||||||
|
from app.core.auth import AuthUser, require_auth
|
||||||
from app.core.database import get_session
|
from app.core.database import get_session
|
||||||
from app.models.encounter import Encounter
|
from app.models.encounter import Encounter
|
||||||
from app.models.evolution import Evolution
|
from app.models.evolution import Evolution
|
||||||
@@ -35,6 +36,7 @@ async def create_encounter(
|
|||||||
run_id: int,
|
run_id: int,
|
||||||
data: EncounterCreate,
|
data: EncounterCreate,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
|
_user: AuthUser = Depends(require_auth),
|
||||||
):
|
):
|
||||||
# Validate run exists
|
# Validate run exists
|
||||||
run = await session.get(NuzlockeRun, run_id)
|
run = await session.get(NuzlockeRun, run_id)
|
||||||
@@ -137,6 +139,7 @@ async def update_encounter(
|
|||||||
encounter_id: int,
|
encounter_id: int,
|
||||||
data: EncounterUpdate,
|
data: EncounterUpdate,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
|
_user: AuthUser = Depends(require_auth),
|
||||||
):
|
):
|
||||||
encounter = await session.get(Encounter, encounter_id)
|
encounter = await session.get(Encounter, encounter_id)
|
||||||
if encounter is None:
|
if encounter is None:
|
||||||
@@ -163,7 +166,9 @@ async def update_encounter(
|
|||||||
|
|
||||||
@router.delete("/encounters/{encounter_id}", status_code=204)
|
@router.delete("/encounters/{encounter_id}", status_code=204)
|
||||||
async def delete_encounter(
|
async def delete_encounter(
|
||||||
encounter_id: int, session: AsyncSession = Depends(get_session)
|
encounter_id: int,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
_user: AuthUser = Depends(require_auth),
|
||||||
):
|
):
|
||||||
encounter = await session.get(Encounter, encounter_id)
|
encounter = await session.get(Encounter, encounter_id)
|
||||||
if encounter is None:
|
if encounter is None:
|
||||||
@@ -195,6 +200,7 @@ async def delete_encounter(
|
|||||||
async def bulk_randomize_encounters(
|
async def bulk_randomize_encounters(
|
||||||
run_id: int,
|
run_id: int,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
|
_user: AuthUser = Depends(require_auth),
|
||||||
):
|
):
|
||||||
# 1. Validate run
|
# 1. Validate run
|
||||||
run = await session.get(NuzlockeRun, run_id)
|
run = await session.get(NuzlockeRun, run_id)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from sqlalchemy import delete, select, update
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from app.core.auth import AuthUser, require_auth
|
||||||
from app.core.database import get_session
|
from app.core.database import get_session
|
||||||
from app.models.boss_battle import BossBattle
|
from app.models.boss_battle import BossBattle
|
||||||
from app.models.game import Game
|
from app.models.game import Game
|
||||||
@@ -228,7 +229,11 @@ async def list_game_routes(
|
|||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=GameResponse, status_code=201)
|
@router.post("", response_model=GameResponse, status_code=201)
|
||||||
async def create_game(data: GameCreate, session: AsyncSession = Depends(get_session)):
|
async def create_game(
|
||||||
|
data: GameCreate,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
_user: AuthUser = Depends(require_auth),
|
||||||
|
):
|
||||||
existing = await session.execute(select(Game).where(Game.slug == data.slug))
|
existing = await session.execute(select(Game).where(Game.slug == data.slug))
|
||||||
if existing.scalar_one_or_none() is not None:
|
if existing.scalar_one_or_none() is not None:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -244,7 +249,10 @@ async def create_game(data: GameCreate, session: AsyncSession = Depends(get_sess
|
|||||||
|
|
||||||
@router.put("/{game_id}", response_model=GameResponse)
|
@router.put("/{game_id}", response_model=GameResponse)
|
||||||
async def update_game(
|
async def update_game(
|
||||||
game_id: int, data: GameUpdate, session: AsyncSession = Depends(get_session)
|
game_id: int,
|
||||||
|
data: GameUpdate,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
_user: AuthUser = Depends(require_auth),
|
||||||
):
|
):
|
||||||
game = await session.get(Game, game_id)
|
game = await session.get(Game, game_id)
|
||||||
if game is None:
|
if game is None:
|
||||||
@@ -269,7 +277,11 @@ async def update_game(
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/{game_id}", status_code=204)
|
@router.delete("/{game_id}", status_code=204)
|
||||||
async def delete_game(game_id: int, session: AsyncSession = Depends(get_session)):
|
async def delete_game(
|
||||||
|
game_id: int,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
_user: AuthUser = Depends(require_auth),
|
||||||
|
):
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(Game).where(Game.id == game_id).options(selectinload(Game.runs))
|
select(Game).where(Game.id == game_id).options(selectinload(Game.runs))
|
||||||
)
|
)
|
||||||
@@ -323,7 +335,10 @@ async def delete_game(game_id: int, session: AsyncSession = Depends(get_session)
|
|||||||
|
|
||||||
@router.post("/{game_id}/routes", response_model=RouteResponse, status_code=201)
|
@router.post("/{game_id}/routes", response_model=RouteResponse, status_code=201)
|
||||||
async def create_route(
|
async def create_route(
|
||||||
game_id: int, data: RouteCreate, session: AsyncSession = Depends(get_session)
|
game_id: int,
|
||||||
|
data: RouteCreate,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
_user: AuthUser = Depends(require_auth),
|
||||||
):
|
):
|
||||||
vg_id = await _get_version_group_id(session, game_id)
|
vg_id = await _get_version_group_id(session, game_id)
|
||||||
|
|
||||||
@@ -339,6 +354,7 @@ async def reorder_routes(
|
|||||||
game_id: int,
|
game_id: int,
|
||||||
data: RouteReorderRequest,
|
data: RouteReorderRequest,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
|
_user: AuthUser = Depends(require_auth),
|
||||||
):
|
):
|
||||||
vg_id = await _get_version_group_id(session, game_id)
|
vg_id = await _get_version_group_id(session, game_id)
|
||||||
|
|
||||||
@@ -365,6 +381,7 @@ async def update_route(
|
|||||||
route_id: int,
|
route_id: int,
|
||||||
data: RouteUpdate,
|
data: RouteUpdate,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
|
_user: AuthUser = Depends(require_auth),
|
||||||
):
|
):
|
||||||
vg_id = await _get_version_group_id(session, game_id)
|
vg_id = await _get_version_group_id(session, game_id)
|
||||||
|
|
||||||
@@ -385,6 +402,7 @@ async def delete_route(
|
|||||||
game_id: int,
|
game_id: int,
|
||||||
route_id: int,
|
route_id: int,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
|
_user: AuthUser = Depends(require_auth),
|
||||||
):
|
):
|
||||||
vg_id = await _get_version_group_id(session, game_id)
|
vg_id = await _get_version_group_id(session, game_id)
|
||||||
|
|
||||||
@@ -419,6 +437,7 @@ async def bulk_import_routes(
|
|||||||
game_id: int,
|
game_id: int,
|
||||||
items: list[BulkRouteItem],
|
items: list[BulkRouteItem],
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
|
_user: AuthUser = Depends(require_auth),
|
||||||
):
|
):
|
||||||
vg_id = await _get_version_group_id(session, game_id)
|
vg_id = await _get_version_group_id(session, game_id)
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from sqlalchemy import update as sa_update
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from app.core.auth import AuthUser, require_auth
|
||||||
from app.core.database import get_session
|
from app.core.database import get_session
|
||||||
from app.models.encounter import Encounter
|
from app.models.encounter import Encounter
|
||||||
from app.models.evolution import Evolution
|
from app.models.evolution import Evolution
|
||||||
@@ -437,7 +438,9 @@ async def get_genlocke_lineages(
|
|||||||
|
|
||||||
@router.post("", response_model=GenlockeResponse, status_code=201)
|
@router.post("", response_model=GenlockeResponse, status_code=201)
|
||||||
async def create_genlocke(
|
async def create_genlocke(
|
||||||
data: GenlockeCreate, session: AsyncSession = Depends(get_session)
|
data: GenlockeCreate,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
_user: AuthUser = Depends(require_auth),
|
||||||
):
|
):
|
||||||
if not data.game_ids:
|
if not data.game_ids:
|
||||||
raise HTTPException(status_code=400, detail="At least one game is required")
|
raise HTTPException(status_code=400, detail="At least one game is required")
|
||||||
@@ -568,6 +571,7 @@ async def advance_leg(
|
|||||||
leg_order: int,
|
leg_order: int,
|
||||||
data: AdvanceLegRequest | None = None,
|
data: AdvanceLegRequest | None = None,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
|
_user: AuthUser = Depends(require_auth),
|
||||||
):
|
):
|
||||||
# Load genlocke with legs
|
# Load genlocke with legs
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
@@ -822,6 +826,7 @@ async def update_genlocke(
|
|||||||
genlocke_id: int,
|
genlocke_id: int,
|
||||||
data: GenlockeUpdate,
|
data: GenlockeUpdate,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
|
_user: AuthUser = Depends(require_auth),
|
||||||
):
|
):
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(Genlocke)
|
select(Genlocke)
|
||||||
@@ -858,6 +863,7 @@ async def update_genlocke(
|
|||||||
async def delete_genlocke(
|
async def delete_genlocke(
|
||||||
genlocke_id: int,
|
genlocke_id: int,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
|
_user: AuthUser = Depends(require_auth),
|
||||||
):
|
):
|
||||||
genlocke = await session.get(Genlocke, genlocke_id)
|
genlocke = await session.get(Genlocke, genlocke_id)
|
||||||
if genlocke is None:
|
if genlocke is None:
|
||||||
@@ -889,6 +895,7 @@ async def add_leg(
|
|||||||
genlocke_id: int,
|
genlocke_id: int,
|
||||||
data: AddLegRequest,
|
data: AddLegRequest,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
|
_user: AuthUser = Depends(require_auth),
|
||||||
):
|
):
|
||||||
genlocke = await session.get(Genlocke, genlocke_id)
|
genlocke = await session.get(Genlocke, genlocke_id)
|
||||||
if genlocke is None:
|
if genlocke is None:
|
||||||
@@ -931,6 +938,7 @@ async def remove_leg(
|
|||||||
genlocke_id: int,
|
genlocke_id: int,
|
||||||
leg_id: int,
|
leg_id: int,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
|
_user: AuthUser = Depends(require_auth),
|
||||||
):
|
):
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(GenlockeLeg).where(
|
select(GenlockeLeg).where(
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from fastapi import APIRouter, Depends, HTTPException, Response
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.auth import AuthUser, require_auth
|
||||||
from app.core.database import get_session
|
from app.core.database import get_session
|
||||||
from app.models.boss_result import BossResult
|
from app.models.boss_result import BossResult
|
||||||
from app.models.journal_entry import JournalEntry
|
from app.models.journal_entry import JournalEntry
|
||||||
@@ -45,6 +46,7 @@ async def create_journal_entry(
|
|||||||
run_id: int,
|
run_id: int,
|
||||||
data: JournalEntryCreate,
|
data: JournalEntryCreate,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
|
_user: AuthUser = Depends(require_auth),
|
||||||
):
|
):
|
||||||
# Validate run exists
|
# Validate run exists
|
||||||
run = await session.get(NuzlockeRun, run_id)
|
run = await session.get(NuzlockeRun, run_id)
|
||||||
@@ -97,6 +99,7 @@ async def update_journal_entry(
|
|||||||
entry_id: UUID,
|
entry_id: UUID,
|
||||||
data: JournalEntryUpdate,
|
data: JournalEntryUpdate,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
|
_user: AuthUser = Depends(require_auth),
|
||||||
):
|
):
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(JournalEntry).where(
|
select(JournalEntry).where(
|
||||||
@@ -135,6 +138,7 @@ async def delete_journal_entry(
|
|||||||
run_id: int,
|
run_id: int,
|
||||||
entry_id: UUID,
|
entry_id: UUID,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
|
_user: AuthUser = Depends(require_auth),
|
||||||
):
|
):
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(JournalEntry).where(
|
select(JournalEntry).where(
|
||||||
|
|||||||
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,
|
genlockes,
|
||||||
health,
|
health,
|
||||||
journal_entries,
|
journal_entries,
|
||||||
|
moves_abilities,
|
||||||
pokemon,
|
pokemon,
|
||||||
runs,
|
runs,
|
||||||
stats,
|
stats,
|
||||||
|
users,
|
||||||
)
|
)
|
||||||
|
|
||||||
api_router = APIRouter()
|
api_router = APIRouter()
|
||||||
api_router.include_router(health.router)
|
api_router.include_router(health.router)
|
||||||
|
api_router.include_router(users.router, prefix="/users", tags=["users"])
|
||||||
api_router.include_router(games.router, prefix="/games", tags=["games"])
|
api_router.include_router(games.router, prefix="/games", tags=["games"])
|
||||||
api_router.include_router(pokemon.router, tags=["pokemon"])
|
api_router.include_router(pokemon.router, tags=["pokemon"])
|
||||||
api_router.include_router(evolutions.router, tags=["evolutions"])
|
api_router.include_router(evolutions.router, tags=["evolutions"])
|
||||||
@@ -25,4 +28,5 @@ api_router.include_router(genlockes.router, prefix="/genlockes", tags=["genlocke
|
|||||||
api_router.include_router(encounters.router, tags=["encounters"])
|
api_router.include_router(encounters.router, tags=["encounters"])
|
||||||
api_router.include_router(stats.router, prefix="/stats", tags=["stats"])
|
api_router.include_router(stats.router, prefix="/stats", tags=["stats"])
|
||||||
api_router.include_router(bosses.router, tags=["bosses"])
|
api_router.include_router(bosses.router, tags=["bosses"])
|
||||||
|
api_router.include_router(moves_abilities.router, tags=["moves", "abilities"])
|
||||||
api_router.include_router(export.router, prefix="/export", tags=["export"])
|
api_router.include_router(export.router, prefix="/export", tags=["export"])
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Response
|
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import joinedload, selectinload
|
from sqlalchemy.orm import joinedload, selectinload
|
||||||
|
|
||||||
|
from app.core.auth import AuthUser, get_current_user, require_auth
|
||||||
from app.core.database import get_session
|
from app.core.database import get_session
|
||||||
from app.models.boss_result import BossResult
|
from app.models.boss_result import BossResult
|
||||||
from app.models.encounter import Encounter
|
from app.models.encounter import Encounter
|
||||||
@@ -12,8 +14,10 @@ from app.models.evolution import Evolution
|
|||||||
from app.models.game import Game
|
from app.models.game import Game
|
||||||
from app.models.genlocke import GenlockeLeg
|
from app.models.genlocke import GenlockeLeg
|
||||||
from app.models.genlocke_transfer import GenlockeTransfer
|
from app.models.genlocke_transfer import GenlockeTransfer
|
||||||
from app.models.nuzlocke_run import NuzlockeRun
|
from app.models.nuzlocke_run import NuzlockeRun, RunVisibility
|
||||||
|
from app.models.user import User
|
||||||
from app.schemas.run import (
|
from app.schemas.run import (
|
||||||
|
OwnerResponse,
|
||||||
RunCreate,
|
RunCreate,
|
||||||
RunDetailResponse,
|
RunDetailResponse,
|
||||||
RunGenlockeContext,
|
RunGenlockeContext,
|
||||||
@@ -157,41 +161,136 @@ async def _compute_lineage_suggestion(
|
|||||||
return f"{base_name} {numeral}"
|
return f"{base_name} {numeral}"
|
||||||
|
|
||||||
|
|
||||||
|
def _build_run_response(run: NuzlockeRun) -> RunResponse:
|
||||||
|
"""Build RunResponse with owner info if present."""
|
||||||
|
owner = None
|
||||||
|
if run.owner:
|
||||||
|
owner = OwnerResponse(id=run.owner.id, display_name=run.owner.display_name)
|
||||||
|
return RunResponse(
|
||||||
|
id=run.id,
|
||||||
|
game_id=run.game_id,
|
||||||
|
name=run.name,
|
||||||
|
status=run.status,
|
||||||
|
rules=run.rules,
|
||||||
|
hof_encounter_ids=run.hof_encounter_ids,
|
||||||
|
naming_scheme=run.naming_scheme,
|
||||||
|
visibility=run.visibility,
|
||||||
|
owner=owner,
|
||||||
|
started_at=run.started_at,
|
||||||
|
completed_at=run.completed_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _check_run_access(
|
||||||
|
run: NuzlockeRun, user: AuthUser | None, require_owner: bool = False
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Check if user can access the run.
|
||||||
|
Raises 403 for private runs if user is not owner.
|
||||||
|
If require_owner=True, always requires ownership (for mutations).
|
||||||
|
"""
|
||||||
|
if run.owner_id is None:
|
||||||
|
# Unowned runs are accessible by everyone (legacy)
|
||||||
|
if require_owner:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403, detail="Only the run owner can perform this action"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
user_id = UUID(user.id) if user else None
|
||||||
|
|
||||||
|
if require_owner:
|
||||||
|
if user_id != run.owner_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403, detail="Only the run owner can perform this action"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if run.visibility == RunVisibility.PRIVATE and user_id != run.owner_id:
|
||||||
|
raise HTTPException(status_code=403, detail="This run is private")
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=RunResponse, status_code=201)
|
@router.post("", response_model=RunResponse, status_code=201)
|
||||||
async def create_run(data: RunCreate, session: AsyncSession = Depends(get_session)):
|
async def create_run(
|
||||||
|
data: RunCreate,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
user: AuthUser = Depends(require_auth),
|
||||||
|
):
|
||||||
# Validate game exists
|
# Validate game exists
|
||||||
game = await session.get(Game, data.game_id)
|
game = await session.get(Game, data.game_id)
|
||||||
if game is None:
|
if game is None:
|
||||||
raise HTTPException(status_code=404, detail="Game not found")
|
raise HTTPException(status_code=404, detail="Game not found")
|
||||||
|
|
||||||
|
# Ensure user exists in local DB
|
||||||
|
user_id = UUID(user.id)
|
||||||
|
db_user = await session.get(User, user_id)
|
||||||
|
if db_user is None:
|
||||||
|
db_user = User(id=user_id, email=user.email or "")
|
||||||
|
session.add(db_user)
|
||||||
|
|
||||||
run = NuzlockeRun(
|
run = NuzlockeRun(
|
||||||
game_id=data.game_id,
|
game_id=data.game_id,
|
||||||
|
owner_id=user_id,
|
||||||
name=data.name,
|
name=data.name,
|
||||||
status="active",
|
status="active",
|
||||||
|
visibility=data.visibility,
|
||||||
rules=data.rules,
|
rules=data.rules,
|
||||||
naming_scheme=data.naming_scheme,
|
naming_scheme=data.naming_scheme,
|
||||||
)
|
)
|
||||||
session.add(run)
|
session.add(run)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(run)
|
|
||||||
return run
|
# Reload with owner relationship
|
||||||
|
result = await session.execute(
|
||||||
|
select(NuzlockeRun)
|
||||||
|
.where(NuzlockeRun.id == run.id)
|
||||||
|
.options(joinedload(NuzlockeRun.owner))
|
||||||
|
)
|
||||||
|
run = result.scalar_one()
|
||||||
|
return _build_run_response(run)
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=list[RunResponse])
|
@router.get("", response_model=list[RunResponse])
|
||||||
async def list_runs(session: AsyncSession = Depends(get_session)):
|
async def list_runs(
|
||||||
result = await session.execute(
|
request: Request,
|
||||||
select(NuzlockeRun).order_by(NuzlockeRun.started_at.desc())
|
session: AsyncSession = Depends(get_session),
|
||||||
|
user: AuthUser | None = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
List runs. Shows public runs and user's own private runs.
|
||||||
|
"""
|
||||||
|
query = select(NuzlockeRun).options(joinedload(NuzlockeRun.owner))
|
||||||
|
|
||||||
|
if user:
|
||||||
|
user_id = UUID(user.id)
|
||||||
|
# Show public runs OR runs owned by current user
|
||||||
|
query = query.where(
|
||||||
|
(NuzlockeRun.visibility == RunVisibility.PUBLIC)
|
||||||
|
| (NuzlockeRun.owner_id == user_id)
|
||||||
)
|
)
|
||||||
return result.scalars().all()
|
else:
|
||||||
|
# Anonymous: only public runs
|
||||||
|
query = query.where(NuzlockeRun.visibility == RunVisibility.PUBLIC)
|
||||||
|
|
||||||
|
query = query.order_by(NuzlockeRun.started_at.desc())
|
||||||
|
result = await session.execute(query)
|
||||||
|
runs = result.scalars().all()
|
||||||
|
return [_build_run_response(run) for run in runs]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{run_id}", response_model=RunDetailResponse)
|
@router.get("/{run_id}", response_model=RunDetailResponse)
|
||||||
async def get_run(run_id: int, session: AsyncSession = Depends(get_session)):
|
async def get_run(
|
||||||
|
run_id: int,
|
||||||
|
request: Request,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
user: AuthUser | None = Depends(get_current_user),
|
||||||
|
):
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(NuzlockeRun)
|
select(NuzlockeRun)
|
||||||
.where(NuzlockeRun.id == run_id)
|
.where(NuzlockeRun.id == run_id)
|
||||||
.options(
|
.options(
|
||||||
joinedload(NuzlockeRun.game),
|
joinedload(NuzlockeRun.game),
|
||||||
|
joinedload(NuzlockeRun.owner),
|
||||||
selectinload(NuzlockeRun.encounters).joinedload(Encounter.pokemon),
|
selectinload(NuzlockeRun.encounters).joinedload(Encounter.pokemon),
|
||||||
selectinload(NuzlockeRun.encounters).joinedload(Encounter.current_pokemon),
|
selectinload(NuzlockeRun.encounters).joinedload(Encounter.current_pokemon),
|
||||||
selectinload(NuzlockeRun.encounters).joinedload(Encounter.route),
|
selectinload(NuzlockeRun.encounters).joinedload(Encounter.route),
|
||||||
@@ -201,6 +300,9 @@ async def get_run(run_id: int, session: AsyncSession = Depends(get_session)):
|
|||||||
if run is None:
|
if run is None:
|
||||||
raise HTTPException(status_code=404, detail="Run not found")
|
raise HTTPException(status_code=404, detail="Run not found")
|
||||||
|
|
||||||
|
# Check visibility access
|
||||||
|
_check_run_access(run, user)
|
||||||
|
|
||||||
# Check if this run belongs to a genlocke
|
# Check if this run belongs to a genlocke
|
||||||
genlocke_context = None
|
genlocke_context = None
|
||||||
leg_result = await session.execute(
|
leg_result = await session.execute(
|
||||||
@@ -262,11 +364,20 @@ async def update_run(
|
|||||||
run_id: int,
|
run_id: int,
|
||||||
data: RunUpdate,
|
data: RunUpdate,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
|
user: AuthUser = Depends(require_auth),
|
||||||
):
|
):
|
||||||
run = await session.get(NuzlockeRun, run_id)
|
result = await session.execute(
|
||||||
|
select(NuzlockeRun)
|
||||||
|
.where(NuzlockeRun.id == run_id)
|
||||||
|
.options(joinedload(NuzlockeRun.owner))
|
||||||
|
)
|
||||||
|
run = result.scalar_one_or_none()
|
||||||
if run is None:
|
if run is None:
|
||||||
raise HTTPException(status_code=404, detail="Run not found")
|
raise HTTPException(status_code=404, detail="Run not found")
|
||||||
|
|
||||||
|
# Check ownership for mutations (unowned runs allow anyone for backwards compat)
|
||||||
|
_check_run_access(run, user, require_owner=run.owner_id is not None)
|
||||||
|
|
||||||
update_data = data.model_dump(exclude_unset=True)
|
update_data = data.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
# Validate hof_encounter_ids if provided
|
# Validate hof_encounter_ids if provided
|
||||||
@@ -352,16 +463,30 @@ async def update_run(
|
|||||||
genlocke.status = "completed"
|
genlocke.status = "completed"
|
||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(run)
|
|
||||||
return run
|
# Reload with owner relationship
|
||||||
|
result = await session.execute(
|
||||||
|
select(NuzlockeRun)
|
||||||
|
.where(NuzlockeRun.id == run.id)
|
||||||
|
.options(joinedload(NuzlockeRun.owner))
|
||||||
|
)
|
||||||
|
run = result.scalar_one()
|
||||||
|
return _build_run_response(run)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{run_id}", status_code=204)
|
@router.delete("/{run_id}", status_code=204)
|
||||||
async def delete_run(run_id: int, session: AsyncSession = Depends(get_session)):
|
async def delete_run(
|
||||||
|
run_id: int,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
user: AuthUser = Depends(require_auth),
|
||||||
|
):
|
||||||
run = await session.get(NuzlockeRun, run_id)
|
run = await session.get(NuzlockeRun, run_id)
|
||||||
if run is None:
|
if run is None:
|
||||||
raise HTTPException(status_code=404, detail="Run not found")
|
raise HTTPException(status_code=404, detail="Run not found")
|
||||||
|
|
||||||
|
# Check ownership for deletion (unowned runs allow anyone for backwards compat)
|
||||||
|
_check_run_access(run, user, require_owner=run.owner_id is not None)
|
||||||
|
|
||||||
# Block deletion if run is linked to a genlocke leg
|
# Block deletion if run is linked to a genlocke leg
|
||||||
leg_result = await session.execute(
|
leg_result = await session.execute(
|
||||||
select(GenlockeLeg).where(GenlockeLeg.run_id == run_id)
|
select(GenlockeLeg).where(GenlockeLeg.run_id == run_id)
|
||||||
|
|||||||
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 settings
|
||||||
database_url: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/nuzlocke"
|
database_url: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/nuzlocke"
|
||||||
|
|
||||||
|
# Supabase Auth
|
||||||
|
supabase_url: str | None = None
|
||||||
|
supabase_anon_key: str | None = None
|
||||||
|
supabase_jwt_secret: str | None = None
|
||||||
|
|
||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from app.models.ability import Ability
|
|||||||
from app.models.boss_battle import BossBattle
|
from app.models.boss_battle import BossBattle
|
||||||
from app.models.boss_pokemon import BossPokemon
|
from app.models.boss_pokemon import BossPokemon
|
||||||
from app.models.boss_result import BossResult
|
from app.models.boss_result import BossResult
|
||||||
|
from app.models.boss_result_team import BossResultTeam
|
||||||
from app.models.encounter import Encounter
|
from app.models.encounter import Encounter
|
||||||
from app.models.evolution import Evolution
|
from app.models.evolution import Evolution
|
||||||
from app.models.game import Game
|
from app.models.game import Game
|
||||||
@@ -13,6 +14,7 @@ from app.models.nuzlocke_run import NuzlockeRun
|
|||||||
from app.models.pokemon import Pokemon
|
from app.models.pokemon import Pokemon
|
||||||
from app.models.route import Route
|
from app.models.route import Route
|
||||||
from app.models.route_encounter import RouteEncounter
|
from app.models.route_encounter import RouteEncounter
|
||||||
|
from app.models.user import User
|
||||||
from app.models.version_group import VersionGroup
|
from app.models.version_group import VersionGroup
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
@@ -20,6 +22,7 @@ __all__ = [
|
|||||||
"BossBattle",
|
"BossBattle",
|
||||||
"BossPokemon",
|
"BossPokemon",
|
||||||
"BossResult",
|
"BossResult",
|
||||||
|
"BossResultTeam",
|
||||||
"Encounter",
|
"Encounter",
|
||||||
"Evolution",
|
"Evolution",
|
||||||
"Game",
|
"Game",
|
||||||
@@ -32,5 +35,6 @@ __all__ = [
|
|||||||
"Pokemon",
|
"Pokemon",
|
||||||
"Route",
|
"Route",
|
||||||
"RouteEncounter",
|
"RouteEncounter",
|
||||||
|
"User",
|
||||||
"VersionGroup",
|
"VersionGroup",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,8 +1,18 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from sqlalchemy import ForeignKey, SmallInteger, String
|
from sqlalchemy import ForeignKey, SmallInteger, String
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from app.core.database import Base
|
from app.core.database import Base
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.models.ability import Ability
|
||||||
|
from app.models.boss_battle import BossBattle
|
||||||
|
from app.models.move import Move
|
||||||
|
from app.models.pokemon import Pokemon
|
||||||
|
|
||||||
|
|
||||||
class BossPokemon(Base):
|
class BossPokemon(Base):
|
||||||
__tablename__ = "boss_pokemon"
|
__tablename__ = "boss_pokemon"
|
||||||
@@ -16,8 +26,24 @@ class BossPokemon(Base):
|
|||||||
order: Mapped[int] = mapped_column(SmallInteger)
|
order: Mapped[int] = mapped_column(SmallInteger)
|
||||||
condition_label: Mapped[str | None] = mapped_column(String(100))
|
condition_label: Mapped[str | None] = mapped_column(String(100))
|
||||||
|
|
||||||
|
# Detail fields
|
||||||
|
ability_id: Mapped[int | None] = mapped_column(
|
||||||
|
ForeignKey("abilities.id"), index=True
|
||||||
|
)
|
||||||
|
held_item: Mapped[str | None] = mapped_column(String(50))
|
||||||
|
nature: Mapped[str | None] = mapped_column(String(20))
|
||||||
|
move1_id: Mapped[int | None] = mapped_column(ForeignKey("moves.id"), index=True)
|
||||||
|
move2_id: Mapped[int | None] = mapped_column(ForeignKey("moves.id"), index=True)
|
||||||
|
move3_id: Mapped[int | None] = mapped_column(ForeignKey("moves.id"), index=True)
|
||||||
|
move4_id: Mapped[int | None] = mapped_column(ForeignKey("moves.id"), index=True)
|
||||||
|
|
||||||
boss_battle: Mapped[BossBattle] = relationship(back_populates="pokemon")
|
boss_battle: Mapped[BossBattle] = relationship(back_populates="pokemon")
|
||||||
pokemon: Mapped[Pokemon] = relationship()
|
pokemon: Mapped[Pokemon] = relationship()
|
||||||
|
ability: Mapped[Ability | None] = relationship()
|
||||||
|
move1: Mapped[Move | None] = relationship(foreign_keys=[move1_id])
|
||||||
|
move2: Mapped[Move | None] = relationship(foreign_keys=[move2_id])
|
||||||
|
move3: Mapped[Move | None] = relationship(foreign_keys=[move3_id])
|
||||||
|
move4: Mapped[Move | None] = relationship(foreign_keys=[move4_id])
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
return f"<BossPokemon(id={self.id}, boss_battle_id={self.boss_battle_id}, pokemon_id={self.pokemon_id})>"
|
return f"<BossPokemon(id={self.id}, boss_battle_id={self.boss_battle_id}, pokemon_id={self.pokemon_id})>"
|
||||||
|
|||||||
@@ -25,6 +25,12 @@ class BossResult(Base):
|
|||||||
|
|
||||||
run: Mapped[NuzlockeRun] = relationship(back_populates="boss_results")
|
run: Mapped[NuzlockeRun] = relationship(back_populates="boss_results")
|
||||||
boss_battle: Mapped[BossBattle] = relationship()
|
boss_battle: Mapped[BossBattle] = relationship()
|
||||||
|
team: Mapped[list[BossResultTeam]] = relationship(
|
||||||
|
back_populates="boss_result", cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
return f"<BossResult(id={self.id}, run_id={self.run_id}, boss_battle_id={self.boss_battle_id}, result='{self.result}')>"
|
return (
|
||||||
|
f"<BossResult(id={self.id}, run_id={self.run_id}, "
|
||||||
|
f"boss_battle_id={self.boss_battle_id}, result='{self.result}')>"
|
||||||
|
)
|
||||||
|
|||||||
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.dialects.postgresql import JSONB
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from app.core.database import Base
|
from app.core.database import Base
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.models.boss_result import BossResult
|
||||||
|
from app.models.encounter import Encounter
|
||||||
|
from app.models.game import Game
|
||||||
|
from app.models.journal_entry import JournalEntry
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
|
||||||
|
class RunVisibility(StrEnum):
|
||||||
|
PUBLIC = "public"
|
||||||
|
PRIVATE = "private"
|
||||||
|
|
||||||
|
|
||||||
class NuzlockeRun(Base):
|
class NuzlockeRun(Base):
|
||||||
__tablename__ = "nuzlocke_runs"
|
__tablename__ = "nuzlocke_runs"
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
game_id: Mapped[int] = mapped_column(ForeignKey("games.id"), index=True)
|
game_id: Mapped[int] = mapped_column(ForeignKey("games.id"), index=True)
|
||||||
|
owner_id: Mapped[UUID | None] = mapped_column(
|
||||||
|
ForeignKey("users.id", ondelete="SET NULL"), index=True
|
||||||
|
)
|
||||||
name: Mapped[str] = mapped_column(String(100))
|
name: Mapped[str] = mapped_column(String(100))
|
||||||
status: Mapped[str] = mapped_column(
|
status: Mapped[str] = mapped_column(
|
||||||
String(20), index=True
|
String(20), index=True
|
||||||
) # active, completed, failed
|
) # active, completed, failed
|
||||||
|
visibility: Mapped[RunVisibility] = mapped_column(
|
||||||
|
Enum(RunVisibility, name="run_visibility", create_constraint=False),
|
||||||
|
default=RunVisibility.PUBLIC,
|
||||||
|
server_default="public",
|
||||||
|
)
|
||||||
rules: Mapped[dict] = mapped_column(JSONB, default=dict)
|
rules: Mapped[dict] = mapped_column(JSONB, default=dict)
|
||||||
started_at: Mapped[datetime] = mapped_column(
|
started_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True), server_default=func.now()
|
DateTime(timezone=True), server_default=func.now()
|
||||||
@@ -25,6 +50,7 @@ class NuzlockeRun(Base):
|
|||||||
naming_scheme: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
naming_scheme: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||||
|
|
||||||
game: Mapped[Game] = relationship(back_populates="runs")
|
game: Mapped[Game] = relationship(back_populates="runs")
|
||||||
|
owner: Mapped[User | None] = relationship(back_populates="runs")
|
||||||
encounters: Mapped[list[Encounter]] = relationship(back_populates="run")
|
encounters: Mapped[list[Encounter]] = relationship(back_populates="run")
|
||||||
boss_results: Mapped[list[BossResult]] = relationship(back_populates="run")
|
boss_results: Mapped[list[BossResult]] = relationship(back_populates="run")
|
||||||
journal_entries: Mapped[list[JournalEntry]] = relationship(back_populates="run")
|
journal_entries: Mapped[list[JournalEntry]] = relationship(back_populates="run")
|
||||||
|
|||||||
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
|
from app.schemas.pokemon import PokemonResponse
|
||||||
|
|
||||||
|
|
||||||
|
class MoveRef(CamelModel):
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
|
||||||
|
|
||||||
|
class AbilityRef(CamelModel):
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
|
||||||
|
|
||||||
class BossPokemonResponse(CamelModel):
|
class BossPokemonResponse(CamelModel):
|
||||||
id: int
|
id: int
|
||||||
pokemon_id: int
|
pokemon_id: int
|
||||||
@@ -11,6 +21,19 @@ class BossPokemonResponse(CamelModel):
|
|||||||
order: int
|
order: int
|
||||||
condition_label: str | None
|
condition_label: str | None
|
||||||
pokemon: PokemonResponse
|
pokemon: PokemonResponse
|
||||||
|
# Detail fields
|
||||||
|
ability_id: int | None = None
|
||||||
|
ability: AbilityRef | None = None
|
||||||
|
held_item: str | None = None
|
||||||
|
nature: str | None = None
|
||||||
|
move1_id: int | None = None
|
||||||
|
move2_id: int | None = None
|
||||||
|
move3_id: int | None = None
|
||||||
|
move4_id: int | None = None
|
||||||
|
move1: MoveRef | None = None
|
||||||
|
move2: MoveRef | None = None
|
||||||
|
move3: MoveRef | None = None
|
||||||
|
move4: MoveRef | None = None
|
||||||
|
|
||||||
|
|
||||||
class BossBattleResponse(CamelModel):
|
class BossBattleResponse(CamelModel):
|
||||||
@@ -31,6 +54,12 @@ class BossBattleResponse(CamelModel):
|
|||||||
pokemon: list[BossPokemonResponse] = []
|
pokemon: list[BossPokemonResponse] = []
|
||||||
|
|
||||||
|
|
||||||
|
class BossResultTeamMemberResponse(CamelModel):
|
||||||
|
id: int
|
||||||
|
encounter_id: int
|
||||||
|
level: int
|
||||||
|
|
||||||
|
|
||||||
class BossResultResponse(CamelModel):
|
class BossResultResponse(CamelModel):
|
||||||
id: int
|
id: int
|
||||||
run_id: int
|
run_id: int
|
||||||
@@ -38,6 +67,7 @@ class BossResultResponse(CamelModel):
|
|||||||
result: str
|
result: str
|
||||||
attempts: int
|
attempts: int
|
||||||
completed_at: datetime | None
|
completed_at: datetime | None
|
||||||
|
team: list[BossResultTeamMemberResponse] = []
|
||||||
|
|
||||||
|
|
||||||
# --- Input schemas ---
|
# --- Input schemas ---
|
||||||
@@ -78,12 +108,26 @@ class BossPokemonInput(CamelModel):
|
|||||||
level: int
|
level: int
|
||||||
order: int
|
order: int
|
||||||
condition_label: str | None = None
|
condition_label: str | None = None
|
||||||
|
# Detail fields
|
||||||
|
ability_id: int | None = None
|
||||||
|
held_item: str | None = None
|
||||||
|
nature: str | None = None
|
||||||
|
move1_id: int | None = None
|
||||||
|
move2_id: int | None = None
|
||||||
|
move3_id: int | None = None
|
||||||
|
move4_id: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class BossResultTeamMemberInput(CamelModel):
|
||||||
|
encounter_id: int
|
||||||
|
level: int
|
||||||
|
|
||||||
|
|
||||||
class BossResultCreate(CamelModel):
|
class BossResultCreate(CamelModel):
|
||||||
boss_battle_id: int
|
boss_battle_id: int
|
||||||
result: str
|
result: str
|
||||||
attempts: int = 1
|
attempts: int = 1
|
||||||
|
team: list[BossResultTeamMemberInput] = []
|
||||||
|
|
||||||
|
|
||||||
class BossReorderItem(CamelModel):
|
class BossReorderItem(CamelModel):
|
||||||
|
|||||||
@@ -1,15 +1,23 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from app.models.nuzlocke_run import RunVisibility
|
||||||
from app.schemas.base import CamelModel
|
from app.schemas.base import CamelModel
|
||||||
from app.schemas.encounter import EncounterDetailResponse
|
from app.schemas.encounter import EncounterDetailResponse
|
||||||
from app.schemas.game import GameResponse
|
from app.schemas.game import GameResponse
|
||||||
|
|
||||||
|
|
||||||
|
class OwnerResponse(CamelModel):
|
||||||
|
id: UUID
|
||||||
|
display_name: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class RunCreate(CamelModel):
|
class RunCreate(CamelModel):
|
||||||
game_id: int
|
game_id: int
|
||||||
name: str
|
name: str
|
||||||
rules: dict = {}
|
rules: dict = {}
|
||||||
naming_scheme: str | None = None
|
naming_scheme: str | None = None
|
||||||
|
visibility: RunVisibility = RunVisibility.PUBLIC
|
||||||
|
|
||||||
|
|
||||||
class RunUpdate(CamelModel):
|
class RunUpdate(CamelModel):
|
||||||
@@ -18,6 +26,7 @@ class RunUpdate(CamelModel):
|
|||||||
rules: dict | None = None
|
rules: dict | None = None
|
||||||
hof_encounter_ids: list[int] | None = None
|
hof_encounter_ids: list[int] | None = None
|
||||||
naming_scheme: str | None = None
|
naming_scheme: str | None = None
|
||||||
|
visibility: RunVisibility | None = None
|
||||||
|
|
||||||
|
|
||||||
class RunResponse(CamelModel):
|
class RunResponse(CamelModel):
|
||||||
@@ -28,6 +37,8 @@ class RunResponse(CamelModel):
|
|||||||
rules: dict
|
rules: dict
|
||||||
hof_encounter_ids: list[int] | None = None
|
hof_encounter_ids: list[int] | None = None
|
||||||
naming_scheme: str | None = None
|
naming_scheme: str | None = None
|
||||||
|
visibility: RunVisibility
|
||||||
|
owner: OwnerResponse | None = None
|
||||||
started_at: datetime
|
started_at: datetime
|
||||||
completed_at: datetime | None
|
completed_at: datetime | None
|
||||||
|
|
||||||
|
|||||||
@@ -87,7 +87,9 @@ RUN_DEFS = [
|
|||||||
"name": "Kanto Heartbreak",
|
"name": "Kanto Heartbreak",
|
||||||
"status": "failed",
|
"status": "failed",
|
||||||
"progress": 0.45,
|
"progress": 0.45,
|
||||||
"rules": {"customRules": "- Hardcore mode: no items in battle\n- Set mode only"},
|
"rules": {
|
||||||
|
"customRules": "- Hardcore mode: no items in battle\n- Set mode only"
|
||||||
|
},
|
||||||
"started_days_ago": 30,
|
"started_days_ago": 30,
|
||||||
"ended_days_ago": 20,
|
"ended_days_ago": 20,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
import os
|
import os
|
||||||
|
import time
|
||||||
|
|
||||||
|
import jwt
|
||||||
import pytest
|
import pytest
|
||||||
from httpx import ASGITransport, AsyncClient
|
from httpx import ASGITransport, AsyncClient
|
||||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||||
|
|
||||||
import app.models # noqa: F401 — ensures all models register with Base.metadata
|
import app.models # noqa: F401 — ensures all models register with Base.metadata
|
||||||
|
from app.core.auth import AuthUser, get_current_user
|
||||||
from app.core.database import Base, get_session
|
from app.core.database import Base, get_session
|
||||||
from app.main import app
|
from app.main import app
|
||||||
|
|
||||||
|
TEST_JWT_SECRET = "test-jwt-secret-for-testing-only"
|
||||||
|
|
||||||
TEST_DATABASE_URL = os.getenv(
|
TEST_DATABASE_URL = os.getenv(
|
||||||
"TEST_DATABASE_URL",
|
"TEST_DATABASE_URL",
|
||||||
"postgresql+asyncpg://postgres:postgres@localhost:5433/nuzlocke_test",
|
"postgresql+asyncpg://postgres:postgres@localhost:5433/nuzlocke_test",
|
||||||
@@ -59,3 +64,43 @@ async def client(db_session):
|
|||||||
transport=ASGITransport(app=app), base_url="http://test"
|
transport=ASGITransport(app=app), base_url="http://test"
|
||||||
) as ac:
|
) as ac:
|
||||||
yield ac
|
yield ac
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_auth_user():
|
||||||
|
"""Return a mock authenticated user for tests."""
|
||||||
|
return AuthUser(id="test-user-123", email="test@example.com", role="authenticated")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def auth_override(mock_auth_user):
|
||||||
|
"""Override get_current_user to return a mock user."""
|
||||||
|
|
||||||
|
def _override():
|
||||||
|
return mock_auth_user
|
||||||
|
|
||||||
|
app.dependency_overrides[get_current_user] = _override
|
||||||
|
yield
|
||||||
|
app.dependency_overrides.pop(get_current_user, None)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def auth_client(db_session, auth_override):
|
||||||
|
"""Async HTTP client with mocked authentication."""
|
||||||
|
async with AsyncClient(
|
||||||
|
transport=ASGITransport(app=app), base_url="http://test"
|
||||||
|
) as ac:
|
||||||
|
yield ac
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def valid_token():
|
||||||
|
"""Generate a valid JWT token for testing."""
|
||||||
|
payload = {
|
||||||
|
"sub": "test-user-123",
|
||||||
|
"email": "test@example.com",
|
||||||
|
"role": "authenticated",
|
||||||
|
"aud": "authenticated",
|
||||||
|
"exp": int(time.time()) + 3600,
|
||||||
|
}
|
||||||
|
return jwt.encode(payload, TEST_JWT_SECRET, algorithm="HS256")
|
||||||
|
|||||||
179
backend/tests/test_auth.py
Normal file
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
|
@pytest.fixture
|
||||||
async def game(client: AsyncClient) -> dict:
|
async def game(auth_client: AsyncClient) -> dict:
|
||||||
"""A game created via the API (no version_group_id)."""
|
"""A game created via the API (no version_group_id)."""
|
||||||
response = await client.post(BASE, json=GAME_PAYLOAD)
|
response = await auth_client.post(BASE, json=GAME_PAYLOAD)
|
||||||
assert response.status_code == 201
|
assert response.status_code == 201
|
||||||
return response.json()
|
return response.json()
|
||||||
|
|
||||||
@@ -68,22 +68,24 @@ class TestListGames:
|
|||||||
|
|
||||||
|
|
||||||
class TestCreateGame:
|
class TestCreateGame:
|
||||||
async def test_creates_and_returns_game(self, client: AsyncClient):
|
async def test_creates_and_returns_game(self, auth_client: AsyncClient):
|
||||||
response = await client.post(BASE, json=GAME_PAYLOAD)
|
response = await auth_client.post(BASE, json=GAME_PAYLOAD)
|
||||||
assert response.status_code == 201
|
assert response.status_code == 201
|
||||||
data = response.json()
|
data = response.json()
|
||||||
assert data["name"] == "Pokemon Red"
|
assert data["name"] == "Pokemon Red"
|
||||||
assert data["slug"] == "red"
|
assert data["slug"] == "red"
|
||||||
assert isinstance(data["id"], int)
|
assert isinstance(data["id"], int)
|
||||||
|
|
||||||
async def test_duplicate_slug_returns_409(self, client: AsyncClient, game: dict):
|
async def test_duplicate_slug_returns_409(
|
||||||
response = await client.post(
|
self, auth_client: AsyncClient, game: dict
|
||||||
|
):
|
||||||
|
response = await auth_client.post(
|
||||||
BASE, json={**GAME_PAYLOAD, "name": "Pokemon Red v2"}
|
BASE, json={**GAME_PAYLOAD, "name": "Pokemon Red v2"}
|
||||||
)
|
)
|
||||||
assert response.status_code == 409
|
assert response.status_code == 409
|
||||||
|
|
||||||
async def test_missing_required_field_returns_422(self, client: AsyncClient):
|
async def test_missing_required_field_returns_422(self, auth_client: AsyncClient):
|
||||||
response = await client.post(BASE, json={"name": "Pokemon Red"})
|
response = await auth_client.post(BASE, json={"name": "Pokemon Red"})
|
||||||
assert response.status_code == 422
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
@@ -113,29 +115,35 @@ class TestGetGame:
|
|||||||
|
|
||||||
|
|
||||||
class TestUpdateGame:
|
class TestUpdateGame:
|
||||||
async def test_updates_name(self, client: AsyncClient, game: dict):
|
async def test_updates_name(self, auth_client: AsyncClient, game: dict):
|
||||||
response = await client.put(
|
response = await auth_client.put(
|
||||||
f"{BASE}/{game['id']}", json={"name": "Pokemon Blue"}
|
f"{BASE}/{game['id']}", json={"name": "Pokemon Blue"}
|
||||||
)
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json()["name"] == "Pokemon Blue"
|
assert response.json()["name"] == "Pokemon Blue"
|
||||||
|
|
||||||
async def test_slug_unchanged_on_partial_update(
|
async def test_slug_unchanged_on_partial_update(
|
||||||
self, client: AsyncClient, game: dict
|
self, auth_client: AsyncClient, game: dict
|
||||||
):
|
):
|
||||||
response = await client.put(f"{BASE}/{game['id']}", json={"name": "New Name"})
|
response = await auth_client.put(
|
||||||
|
f"{BASE}/{game['id']}", json={"name": "New Name"}
|
||||||
|
)
|
||||||
assert response.json()["slug"] == "red"
|
assert response.json()["slug"] == "red"
|
||||||
|
|
||||||
async def test_not_found_returns_404(self, client: AsyncClient):
|
async def test_not_found_returns_404(self, auth_client: AsyncClient):
|
||||||
assert (await client.put(f"{BASE}/9999", json={"name": "x"})).status_code == 404
|
assert (
|
||||||
|
await auth_client.put(f"{BASE}/9999", json={"name": "x"})
|
||||||
|
).status_code == 404
|
||||||
|
|
||||||
async def test_duplicate_slug_returns_409(self, client: AsyncClient):
|
async def test_duplicate_slug_returns_409(self, auth_client: AsyncClient):
|
||||||
await client.post(BASE, json={**GAME_PAYLOAD, "slug": "blue", "name": "Blue"})
|
await auth_client.post(
|
||||||
r1 = await client.post(
|
BASE, json={**GAME_PAYLOAD, "slug": "blue", "name": "Blue"}
|
||||||
|
)
|
||||||
|
r1 = await auth_client.post(
|
||||||
BASE, json={**GAME_PAYLOAD, "slug": "red", "name": "Red"}
|
BASE, json={**GAME_PAYLOAD, "slug": "red", "name": "Red"}
|
||||||
)
|
)
|
||||||
game_id = r1.json()["id"]
|
game_id = r1.json()["id"]
|
||||||
response = await client.put(f"{BASE}/{game_id}", json={"slug": "blue"})
|
response = await auth_client.put(f"{BASE}/{game_id}", json={"slug": "blue"})
|
||||||
assert response.status_code == 409
|
assert response.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
@@ -145,13 +153,13 @@ class TestUpdateGame:
|
|||||||
|
|
||||||
|
|
||||||
class TestDeleteGame:
|
class TestDeleteGame:
|
||||||
async def test_deletes_game(self, client: AsyncClient, game: dict):
|
async def test_deletes_game(self, auth_client: AsyncClient, game: dict):
|
||||||
response = await client.delete(f"{BASE}/{game['id']}")
|
response = await auth_client.delete(f"{BASE}/{game['id']}")
|
||||||
assert response.status_code == 204
|
assert response.status_code == 204
|
||||||
assert (await client.get(f"{BASE}/{game['id']}")).status_code == 404
|
assert (await auth_client.get(f"{BASE}/{game['id']}")).status_code == 404
|
||||||
|
|
||||||
async def test_not_found_returns_404(self, client: AsyncClient):
|
async def test_not_found_returns_404(self, auth_client: AsyncClient):
|
||||||
assert (await client.delete(f"{BASE}/9999")).status_code == 404
|
assert (await auth_client.delete(f"{BASE}/9999")).status_code == 404
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -187,9 +195,9 @@ class TestListByRegion:
|
|||||||
|
|
||||||
|
|
||||||
class TestCreateRoute:
|
class TestCreateRoute:
|
||||||
async def test_creates_route(self, client: AsyncClient, game_with_vg: tuple):
|
async def test_creates_route(self, auth_client: AsyncClient, game_with_vg: tuple):
|
||||||
game_id, _ = game_with_vg
|
game_id, _ = game_with_vg
|
||||||
response = await client.post(
|
response = await auth_client.post(
|
||||||
f"{BASE}/{game_id}/routes",
|
f"{BASE}/{game_id}/routes",
|
||||||
json={"name": "Pallet Town", "order": 1},
|
json={"name": "Pallet Town", "order": 1},
|
||||||
)
|
)
|
||||||
@@ -200,35 +208,35 @@ class TestCreateRoute:
|
|||||||
assert isinstance(data["id"], int)
|
assert isinstance(data["id"], int)
|
||||||
|
|
||||||
async def test_game_detail_includes_route(
|
async def test_game_detail_includes_route(
|
||||||
self, client: AsyncClient, game_with_vg: tuple
|
self, auth_client: AsyncClient, game_with_vg: tuple
|
||||||
):
|
):
|
||||||
game_id, _ = game_with_vg
|
game_id, _ = game_with_vg
|
||||||
await client.post(
|
await auth_client.post(
|
||||||
f"{BASE}/{game_id}/routes", json={"name": "Route 1", "order": 1}
|
f"{BASE}/{game_id}/routes", json={"name": "Route 1", "order": 1}
|
||||||
)
|
)
|
||||||
response = await client.get(f"{BASE}/{game_id}")
|
response = await auth_client.get(f"{BASE}/{game_id}")
|
||||||
routes = response.json()["routes"]
|
routes = response.json()["routes"]
|
||||||
assert len(routes) == 1
|
assert len(routes) == 1
|
||||||
assert routes[0]["name"] == "Route 1"
|
assert routes[0]["name"] == "Route 1"
|
||||||
|
|
||||||
async def test_game_without_version_group_returns_400(
|
async def test_game_without_version_group_returns_400(
|
||||||
self, client: AsyncClient, game: dict
|
self, auth_client: AsyncClient, game: dict
|
||||||
):
|
):
|
||||||
response = await client.post(
|
response = await auth_client.post(
|
||||||
f"{BASE}/{game['id']}/routes",
|
f"{BASE}/{game['id']}/routes",
|
||||||
json={"name": "Route 1", "order": 1},
|
json={"name": "Route 1", "order": 1},
|
||||||
)
|
)
|
||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
|
|
||||||
async def test_list_routes_excludes_routes_without_encounters(
|
async def test_list_routes_excludes_routes_without_encounters(
|
||||||
self, client: AsyncClient, game_with_vg: tuple
|
self, auth_client: AsyncClient, game_with_vg: tuple
|
||||||
):
|
):
|
||||||
"""list_game_routes only returns routes that have Pokemon encounters."""
|
"""list_game_routes only returns routes that have Pokemon encounters."""
|
||||||
game_id, _ = game_with_vg
|
game_id, _ = game_with_vg
|
||||||
await client.post(
|
await auth_client.post(
|
||||||
f"{BASE}/{game_id}/routes", json={"name": "Route 1", "order": 1}
|
f"{BASE}/{game_id}/routes", json={"name": "Route 1", "order": 1}
|
||||||
)
|
)
|
||||||
response = await client.get(f"{BASE}/{game_id}/routes?flat=true")
|
response = await auth_client.get(f"{BASE}/{game_id}/routes?flat=true")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json() == []
|
assert response.json() == []
|
||||||
|
|
||||||
@@ -239,14 +247,16 @@ class TestCreateRoute:
|
|||||||
|
|
||||||
|
|
||||||
class TestUpdateRoute:
|
class TestUpdateRoute:
|
||||||
async def test_updates_route_name(self, client: AsyncClient, game_with_vg: tuple):
|
async def test_updates_route_name(
|
||||||
|
self, auth_client: AsyncClient, game_with_vg: tuple
|
||||||
|
):
|
||||||
game_id, _ = game_with_vg
|
game_id, _ = game_with_vg
|
||||||
r = (
|
r = (
|
||||||
await client.post(
|
await auth_client.post(
|
||||||
f"{BASE}/{game_id}/routes", json={"name": "Old Name", "order": 1}
|
f"{BASE}/{game_id}/routes", json={"name": "Old Name", "order": 1}
|
||||||
)
|
)
|
||||||
).json()
|
).json()
|
||||||
response = await client.put(
|
response = await auth_client.put(
|
||||||
f"{BASE}/{game_id}/routes/{r['id']}",
|
f"{BASE}/{game_id}/routes/{r['id']}",
|
||||||
json={"name": "New Name"},
|
json={"name": "New Name"},
|
||||||
)
|
)
|
||||||
@@ -254,11 +264,11 @@ class TestUpdateRoute:
|
|||||||
assert response.json()["name"] == "New Name"
|
assert response.json()["name"] == "New Name"
|
||||||
|
|
||||||
async def test_route_not_found_returns_404(
|
async def test_route_not_found_returns_404(
|
||||||
self, client: AsyncClient, game_with_vg: tuple
|
self, auth_client: AsyncClient, game_with_vg: tuple
|
||||||
):
|
):
|
||||||
game_id, _ = game_with_vg
|
game_id, _ = game_with_vg
|
||||||
assert (
|
assert (
|
||||||
await client.put(f"{BASE}/{game_id}/routes/9999", json={"name": "x"})
|
await auth_client.put(f"{BASE}/{game_id}/routes/9999", json={"name": "x"})
|
||||||
).status_code == 404
|
).status_code == 404
|
||||||
|
|
||||||
|
|
||||||
@@ -268,25 +278,27 @@ class TestUpdateRoute:
|
|||||||
|
|
||||||
|
|
||||||
class TestDeleteRoute:
|
class TestDeleteRoute:
|
||||||
async def test_deletes_route(self, client: AsyncClient, game_with_vg: tuple):
|
async def test_deletes_route(self, auth_client: AsyncClient, game_with_vg: tuple):
|
||||||
game_id, _ = game_with_vg
|
game_id, _ = game_with_vg
|
||||||
r = (
|
r = (
|
||||||
await client.post(
|
await auth_client.post(
|
||||||
f"{BASE}/{game_id}/routes", json={"name": "Route 1", "order": 1}
|
f"{BASE}/{game_id}/routes", json={"name": "Route 1", "order": 1}
|
||||||
)
|
)
|
||||||
).json()
|
).json()
|
||||||
assert (
|
assert (
|
||||||
await client.delete(f"{BASE}/{game_id}/routes/{r['id']}")
|
await auth_client.delete(f"{BASE}/{game_id}/routes/{r['id']}")
|
||||||
).status_code == 204
|
).status_code == 204
|
||||||
# No longer in game detail
|
# No longer in game detail
|
||||||
detail = (await client.get(f"{BASE}/{game_id}")).json()
|
detail = (await auth_client.get(f"{BASE}/{game_id}")).json()
|
||||||
assert all(route["id"] != r["id"] for route in detail["routes"])
|
assert all(route["id"] != r["id"] for route in detail["routes"])
|
||||||
|
|
||||||
async def test_route_not_found_returns_404(
|
async def test_route_not_found_returns_404(
|
||||||
self, client: AsyncClient, game_with_vg: tuple
|
self, auth_client: AsyncClient, game_with_vg: tuple
|
||||||
):
|
):
|
||||||
game_id, _ = game_with_vg
|
game_id, _ = game_with_vg
|
||||||
assert (await client.delete(f"{BASE}/{game_id}/routes/9999")).status_code == 404
|
assert (
|
||||||
|
await auth_client.delete(f"{BASE}/{game_id}/routes/9999")
|
||||||
|
).status_code == 404
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -295,20 +307,20 @@ class TestDeleteRoute:
|
|||||||
|
|
||||||
|
|
||||||
class TestReorderRoutes:
|
class TestReorderRoutes:
|
||||||
async def test_reorders_routes(self, client: AsyncClient, game_with_vg: tuple):
|
async def test_reorders_routes(self, auth_client: AsyncClient, game_with_vg: tuple):
|
||||||
game_id, _ = game_with_vg
|
game_id, _ = game_with_vg
|
||||||
r1 = (
|
r1 = (
|
||||||
await client.post(
|
await auth_client.post(
|
||||||
f"{BASE}/{game_id}/routes", json={"name": "A", "order": 1}
|
f"{BASE}/{game_id}/routes", json={"name": "A", "order": 1}
|
||||||
)
|
)
|
||||||
).json()
|
).json()
|
||||||
r2 = (
|
r2 = (
|
||||||
await client.post(
|
await auth_client.post(
|
||||||
f"{BASE}/{game_id}/routes", json={"name": "B", "order": 2}
|
f"{BASE}/{game_id}/routes", json={"name": "B", "order": 2}
|
||||||
)
|
)
|
||||||
).json()
|
).json()
|
||||||
|
|
||||||
response = await client.put(
|
response = await auth_client.put(
|
||||||
f"{BASE}/{game_id}/routes/reorder",
|
f"{BASE}/{game_id}/routes/reorder",
|
||||||
json={
|
json={
|
||||||
"routes": [{"id": r1["id"], "order": 2}, {"id": r2["id"], "order": 1}]
|
"routes": [{"id": r1["id"], "order": 2}, {"id": r2["id"], "order": 1}]
|
||||||
|
|||||||
@@ -30,9 +30,11 @@ async def game_id(db_session: AsyncSession) -> int:
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
async def run(client: AsyncClient, game_id: int) -> dict:
|
async def run(auth_client: AsyncClient, game_id: int) -> dict:
|
||||||
"""An active run created via the API."""
|
"""An active run created via the API."""
|
||||||
response = await client.post(RUNS_BASE, json={"gameId": game_id, "name": "My Run"})
|
response = await auth_client.post(
|
||||||
|
RUNS_BASE, json={"gameId": game_id, "name": "My Run"}
|
||||||
|
)
|
||||||
assert response.status_code == 201
|
assert response.status_code == 201
|
||||||
return response.json()
|
return response.json()
|
||||||
|
|
||||||
@@ -127,8 +129,8 @@ class TestListRuns:
|
|||||||
|
|
||||||
|
|
||||||
class TestCreateRun:
|
class TestCreateRun:
|
||||||
async def test_creates_active_run(self, client: AsyncClient, game_id: int):
|
async def test_creates_active_run(self, auth_client: AsyncClient, game_id: int):
|
||||||
response = await client.post(
|
response = await auth_client.post(
|
||||||
RUNS_BASE, json={"gameId": game_id, "name": "New Run"}
|
RUNS_BASE, json={"gameId": game_id, "name": "New Run"}
|
||||||
)
|
)
|
||||||
assert response.status_code == 201
|
assert response.status_code == 201
|
||||||
@@ -138,20 +140,22 @@ class TestCreateRun:
|
|||||||
assert data["gameId"] == game_id
|
assert data["gameId"] == game_id
|
||||||
assert isinstance(data["id"], int)
|
assert isinstance(data["id"], int)
|
||||||
|
|
||||||
async def test_rules_stored(self, client: AsyncClient, game_id: int):
|
async def test_rules_stored(self, auth_client: AsyncClient, game_id: int):
|
||||||
rules = {"duplicatesClause": True, "shinyClause": False}
|
rules = {"duplicatesClause": True, "shinyClause": False}
|
||||||
response = await client.post(
|
response = await auth_client.post(
|
||||||
RUNS_BASE, json={"gameId": game_id, "name": "Run", "rules": rules}
|
RUNS_BASE, json={"gameId": game_id, "name": "Run", "rules": rules}
|
||||||
)
|
)
|
||||||
assert response.status_code == 201
|
assert response.status_code == 201
|
||||||
assert response.json()["rules"]["duplicatesClause"] is True
|
assert response.json()["rules"]["duplicatesClause"] is True
|
||||||
|
|
||||||
async def test_invalid_game_returns_404(self, client: AsyncClient):
|
async def test_invalid_game_returns_404(self, auth_client: AsyncClient):
|
||||||
response = await client.post(RUNS_BASE, json={"gameId": 9999, "name": "Run"})
|
response = await auth_client.post(
|
||||||
|
RUNS_BASE, json={"gameId": 9999, "name": "Run"}
|
||||||
|
)
|
||||||
assert response.status_code == 404
|
assert response.status_code == 404
|
||||||
|
|
||||||
async def test_missing_required_returns_422(self, client: AsyncClient):
|
async def test_missing_required_returns_422(self, auth_client: AsyncClient):
|
||||||
response = await client.post(RUNS_BASE, json={"name": "Run"})
|
response = await auth_client.post(RUNS_BASE, json={"name": "Run"})
|
||||||
assert response.status_code == 422
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
@@ -181,15 +185,17 @@ class TestGetRun:
|
|||||||
|
|
||||||
|
|
||||||
class TestUpdateRun:
|
class TestUpdateRun:
|
||||||
async def test_updates_name(self, client: AsyncClient, run: dict):
|
async def test_updates_name(self, auth_client: AsyncClient, run: dict):
|
||||||
response = await client.patch(
|
response = await auth_client.patch(
|
||||||
f"{RUNS_BASE}/{run['id']}", json={"name": "Renamed"}
|
f"{RUNS_BASE}/{run['id']}", json={"name": "Renamed"}
|
||||||
)
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json()["name"] == "Renamed"
|
assert response.json()["name"] == "Renamed"
|
||||||
|
|
||||||
async def test_complete_run_sets_completed_at(self, client: AsyncClient, run: dict):
|
async def test_complete_run_sets_completed_at(
|
||||||
response = await client.patch(
|
self, auth_client: AsyncClient, run: dict
|
||||||
|
):
|
||||||
|
response = await auth_client.patch(
|
||||||
f"{RUNS_BASE}/{run['id']}", json={"status": "completed"}
|
f"{RUNS_BASE}/{run['id']}", json={"status": "completed"}
|
||||||
)
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
@@ -197,25 +203,27 @@ class TestUpdateRun:
|
|||||||
assert data["status"] == "completed"
|
assert data["status"] == "completed"
|
||||||
assert data["completedAt"] is not None
|
assert data["completedAt"] is not None
|
||||||
|
|
||||||
async def test_fail_run(self, client: AsyncClient, run: dict):
|
async def test_fail_run(self, auth_client: AsyncClient, run: dict):
|
||||||
response = await client.patch(
|
response = await auth_client.patch(
|
||||||
f"{RUNS_BASE}/{run['id']}", json={"status": "failed"}
|
f"{RUNS_BASE}/{run['id']}", json={"status": "failed"}
|
||||||
)
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json()["status"] == "failed"
|
assert response.json()["status"] == "failed"
|
||||||
|
|
||||||
async def test_ending_already_ended_run_returns_400(
|
async def test_ending_already_ended_run_returns_400(
|
||||||
self, client: AsyncClient, run: dict
|
self, auth_client: AsyncClient, run: dict
|
||||||
):
|
):
|
||||||
await client.patch(f"{RUNS_BASE}/{run['id']}", json={"status": "completed"})
|
await auth_client.patch(
|
||||||
response = await client.patch(
|
f"{RUNS_BASE}/{run['id']}", json={"status": "completed"}
|
||||||
|
)
|
||||||
|
response = await auth_client.patch(
|
||||||
f"{RUNS_BASE}/{run['id']}", json={"status": "failed"}
|
f"{RUNS_BASE}/{run['id']}", json={"status": "failed"}
|
||||||
)
|
)
|
||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
|
|
||||||
async def test_not_found_returns_404(self, client: AsyncClient):
|
async def test_not_found_returns_404(self, auth_client: AsyncClient):
|
||||||
assert (
|
assert (
|
||||||
await client.patch(f"{RUNS_BASE}/9999", json={"name": "x"})
|
await auth_client.patch(f"{RUNS_BASE}/9999", json={"name": "x"})
|
||||||
).status_code == 404
|
).status_code == 404
|
||||||
|
|
||||||
|
|
||||||
@@ -225,12 +233,12 @@ class TestUpdateRun:
|
|||||||
|
|
||||||
|
|
||||||
class TestDeleteRun:
|
class TestDeleteRun:
|
||||||
async def test_deletes_run(self, client: AsyncClient, run: dict):
|
async def test_deletes_run(self, auth_client: AsyncClient, run: dict):
|
||||||
assert (await client.delete(f"{RUNS_BASE}/{run['id']}")).status_code == 204
|
assert (await auth_client.delete(f"{RUNS_BASE}/{run['id']}")).status_code == 204
|
||||||
assert (await client.get(f"{RUNS_BASE}/{run['id']}")).status_code == 404
|
assert (await auth_client.get(f"{RUNS_BASE}/{run['id']}")).status_code == 404
|
||||||
|
|
||||||
async def test_not_found_returns_404(self, client: AsyncClient):
|
async def test_not_found_returns_404(self, auth_client: AsyncClient):
|
||||||
assert (await client.delete(f"{RUNS_BASE}/9999")).status_code == 404
|
assert (await auth_client.delete(f"{RUNS_BASE}/9999")).status_code == 404
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -239,8 +247,8 @@ class TestDeleteRun:
|
|||||||
|
|
||||||
|
|
||||||
class TestCreateEncounter:
|
class TestCreateEncounter:
|
||||||
async def test_creates_encounter(self, client: AsyncClient, enc_ctx: dict):
|
async def test_creates_encounter(self, auth_client: AsyncClient, enc_ctx: dict):
|
||||||
response = await client.post(
|
response = await auth_client.post(
|
||||||
f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters",
|
f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters",
|
||||||
json={
|
json={
|
||||||
"routeId": enc_ctx["standalone_id"],
|
"routeId": enc_ctx["standalone_id"],
|
||||||
@@ -255,8 +263,10 @@ class TestCreateEncounter:
|
|||||||
assert data["status"] == "caught"
|
assert data["status"] == "caught"
|
||||||
assert data["isShiny"] is False
|
assert data["isShiny"] is False
|
||||||
|
|
||||||
async def test_invalid_run_returns_404(self, client: AsyncClient, enc_ctx: dict):
|
async def test_invalid_run_returns_404(
|
||||||
response = await client.post(
|
self, auth_client: AsyncClient, enc_ctx: dict
|
||||||
|
):
|
||||||
|
response = await auth_client.post(
|
||||||
f"{RUNS_BASE}/9999/encounters",
|
f"{RUNS_BASE}/9999/encounters",
|
||||||
json={
|
json={
|
||||||
"routeId": enc_ctx["standalone_id"],
|
"routeId": enc_ctx["standalone_id"],
|
||||||
@@ -266,8 +276,10 @@ class TestCreateEncounter:
|
|||||||
)
|
)
|
||||||
assert response.status_code == 404
|
assert response.status_code == 404
|
||||||
|
|
||||||
async def test_invalid_route_returns_404(self, client: AsyncClient, enc_ctx: dict):
|
async def test_invalid_route_returns_404(
|
||||||
response = await client.post(
|
self, auth_client: AsyncClient, enc_ctx: dict
|
||||||
|
):
|
||||||
|
response = await auth_client.post(
|
||||||
f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters",
|
f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters",
|
||||||
json={
|
json={
|
||||||
"routeId": 9999,
|
"routeId": 9999,
|
||||||
@@ -278,9 +290,9 @@ class TestCreateEncounter:
|
|||||||
assert response.status_code == 404
|
assert response.status_code == 404
|
||||||
|
|
||||||
async def test_invalid_pokemon_returns_404(
|
async def test_invalid_pokemon_returns_404(
|
||||||
self, client: AsyncClient, enc_ctx: dict
|
self, auth_client: AsyncClient, enc_ctx: dict
|
||||||
):
|
):
|
||||||
response = await client.post(
|
response = await auth_client.post(
|
||||||
f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters",
|
f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters",
|
||||||
json={
|
json={
|
||||||
"routeId": enc_ctx["standalone_id"],
|
"routeId": enc_ctx["standalone_id"],
|
||||||
@@ -290,9 +302,11 @@ class TestCreateEncounter:
|
|||||||
)
|
)
|
||||||
assert response.status_code == 404
|
assert response.status_code == 404
|
||||||
|
|
||||||
async def test_parent_route_rejected_400(self, client: AsyncClient, enc_ctx: dict):
|
async def test_parent_route_rejected_400(
|
||||||
|
self, auth_client: AsyncClient, enc_ctx: dict
|
||||||
|
):
|
||||||
"""Cannot create an encounter directly on a parent route (use child routes)."""
|
"""Cannot create an encounter directly on a parent route (use child routes)."""
|
||||||
response = await client.post(
|
response = await auth_client.post(
|
||||||
f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters",
|
f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters",
|
||||||
json={
|
json={
|
||||||
"routeId": enc_ctx["parent_id"],
|
"routeId": enc_ctx["parent_id"],
|
||||||
@@ -303,10 +317,10 @@ class TestCreateEncounter:
|
|||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
|
|
||||||
async def test_route_lock_prevents_second_sibling_encounter(
|
async def test_route_lock_prevents_second_sibling_encounter(
|
||||||
self, client: AsyncClient, enc_ctx: dict
|
self, auth_client: AsyncClient, enc_ctx: dict
|
||||||
):
|
):
|
||||||
"""Once a sibling child has an encounter, other siblings in the group return 409."""
|
"""Once a sibling child has an encounter, other siblings in the group return 409."""
|
||||||
await client.post(
|
await auth_client.post(
|
||||||
f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters",
|
f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters",
|
||||||
json={
|
json={
|
||||||
"routeId": enc_ctx["child1_id"],
|
"routeId": enc_ctx["child1_id"],
|
||||||
@@ -314,7 +328,7 @@ class TestCreateEncounter:
|
|||||||
"status": "caught",
|
"status": "caught",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
response = await client.post(
|
response = await auth_client.post(
|
||||||
f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters",
|
f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters",
|
||||||
json={
|
json={
|
||||||
"routeId": enc_ctx["child2_id"],
|
"routeId": enc_ctx["child2_id"],
|
||||||
@@ -325,11 +339,11 @@ class TestCreateEncounter:
|
|||||||
assert response.status_code == 409
|
assert response.status_code == 409
|
||||||
|
|
||||||
async def test_shiny_bypasses_route_lock(
|
async def test_shiny_bypasses_route_lock(
|
||||||
self, client: AsyncClient, enc_ctx: dict, db_session: AsyncSession
|
self, auth_client: AsyncClient, enc_ctx: dict, db_session: AsyncSession
|
||||||
):
|
):
|
||||||
"""A shiny encounter bypasses the route-lock when shinyClause is enabled."""
|
"""A shiny encounter bypasses the route-lock when shinyClause is enabled."""
|
||||||
# First encounter occupies the group
|
# First encounter occupies the group
|
||||||
await client.post(
|
await auth_client.post(
|
||||||
f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters",
|
f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters",
|
||||||
json={
|
json={
|
||||||
"routeId": enc_ctx["child1_id"],
|
"routeId": enc_ctx["child1_id"],
|
||||||
@@ -338,7 +352,7 @@ class TestCreateEncounter:
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
# Shiny encounter on sibling should succeed
|
# Shiny encounter on sibling should succeed
|
||||||
response = await client.post(
|
response = await auth_client.post(
|
||||||
f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters",
|
f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters",
|
||||||
json={
|
json={
|
||||||
"routeId": enc_ctx["child2_id"],
|
"routeId": enc_ctx["child2_id"],
|
||||||
@@ -351,7 +365,7 @@ class TestCreateEncounter:
|
|||||||
assert response.json()["isShiny"] is True
|
assert response.json()["isShiny"] is True
|
||||||
|
|
||||||
async def test_gift_bypasses_route_lock_when_clause_on(
|
async def test_gift_bypasses_route_lock_when_clause_on(
|
||||||
self, client: AsyncClient, enc_ctx: dict, db_session: AsyncSession
|
self, auth_client: AsyncClient, enc_ctx: dict, db_session: AsyncSession
|
||||||
):
|
):
|
||||||
"""A gift encounter bypasses route-lock when giftClause is enabled."""
|
"""A gift encounter bypasses route-lock when giftClause is enabled."""
|
||||||
# Enable giftClause on the run
|
# Enable giftClause on the run
|
||||||
@@ -359,7 +373,7 @@ class TestCreateEncounter:
|
|||||||
run.rules = {"shinyClause": True, "giftClause": True}
|
run.rules = {"shinyClause": True, "giftClause": True}
|
||||||
await db_session.commit()
|
await db_session.commit()
|
||||||
|
|
||||||
await client.post(
|
await auth_client.post(
|
||||||
f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters",
|
f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters",
|
||||||
json={
|
json={
|
||||||
"routeId": enc_ctx["child1_id"],
|
"routeId": enc_ctx["child1_id"],
|
||||||
@@ -367,7 +381,7 @@ class TestCreateEncounter:
|
|||||||
"status": "caught",
|
"status": "caught",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
response = await client.post(
|
response = await auth_client.post(
|
||||||
f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters",
|
f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters",
|
||||||
json={
|
json={
|
||||||
"routeId": enc_ctx["child2_id"],
|
"routeId": enc_ctx["child2_id"],
|
||||||
@@ -387,8 +401,8 @@ class TestCreateEncounter:
|
|||||||
|
|
||||||
class TestUpdateEncounter:
|
class TestUpdateEncounter:
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
async def encounter(self, client: AsyncClient, enc_ctx: dict) -> dict:
|
async def encounter(self, auth_client: AsyncClient, enc_ctx: dict) -> dict:
|
||||||
response = await client.post(
|
response = await auth_client.post(
|
||||||
f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters",
|
f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters",
|
||||||
json={
|
json={
|
||||||
"routeId": enc_ctx["standalone_id"],
|
"routeId": enc_ctx["standalone_id"],
|
||||||
@@ -398,17 +412,17 @@ class TestUpdateEncounter:
|
|||||||
)
|
)
|
||||||
return response.json()
|
return response.json()
|
||||||
|
|
||||||
async def test_updates_nickname(self, client: AsyncClient, encounter: dict):
|
async def test_updates_nickname(self, auth_client: AsyncClient, encounter: dict):
|
||||||
response = await client.patch(
|
response = await auth_client.patch(
|
||||||
f"{ENC_BASE}/{encounter['id']}", json={"nickname": "Sparky"}
|
f"{ENC_BASE}/{encounter['id']}", json={"nickname": "Sparky"}
|
||||||
)
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json()["nickname"] == "Sparky"
|
assert response.json()["nickname"] == "Sparky"
|
||||||
|
|
||||||
async def test_updates_status_to_fainted(
|
async def test_updates_status_to_fainted(
|
||||||
self, client: AsyncClient, encounter: dict
|
self, auth_client: AsyncClient, encounter: dict
|
||||||
):
|
):
|
||||||
response = await client.patch(
|
response = await auth_client.patch(
|
||||||
f"{ENC_BASE}/{encounter['id']}",
|
f"{ENC_BASE}/{encounter['id']}",
|
||||||
json={"status": "fainted", "faintLevel": 12, "deathCause": "wild battle"},
|
json={"status": "fainted", "faintLevel": 12, "deathCause": "wild battle"},
|
||||||
)
|
)
|
||||||
@@ -418,9 +432,9 @@ class TestUpdateEncounter:
|
|||||||
assert data["faintLevel"] == 12
|
assert data["faintLevel"] == 12
|
||||||
assert data["deathCause"] == "wild battle"
|
assert data["deathCause"] == "wild battle"
|
||||||
|
|
||||||
async def test_not_found_returns_404(self, client: AsyncClient):
|
async def test_not_found_returns_404(self, auth_client: AsyncClient):
|
||||||
assert (
|
assert (
|
||||||
await client.patch(f"{ENC_BASE}/9999", json={"nickname": "x"})
|
await auth_client.patch(f"{ENC_BASE}/9999", json={"nickname": "x"})
|
||||||
).status_code == 404
|
).status_code == 404
|
||||||
|
|
||||||
|
|
||||||
@@ -431,8 +445,8 @@ class TestUpdateEncounter:
|
|||||||
|
|
||||||
class TestDeleteEncounter:
|
class TestDeleteEncounter:
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
async def encounter(self, client: AsyncClient, enc_ctx: dict) -> dict:
|
async def encounter(self, auth_client: AsyncClient, enc_ctx: dict) -> dict:
|
||||||
response = await client.post(
|
response = await auth_client.post(
|
||||||
f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters",
|
f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters",
|
||||||
json={
|
json={
|
||||||
"routeId": enc_ctx["standalone_id"],
|
"routeId": enc_ctx["standalone_id"],
|
||||||
@@ -443,12 +457,14 @@ class TestDeleteEncounter:
|
|||||||
return response.json()
|
return response.json()
|
||||||
|
|
||||||
async def test_deletes_encounter(
|
async def test_deletes_encounter(
|
||||||
self, client: AsyncClient, encounter: dict, enc_ctx: dict
|
self, auth_client: AsyncClient, encounter: dict, enc_ctx: dict
|
||||||
):
|
):
|
||||||
assert (await client.delete(f"{ENC_BASE}/{encounter['id']}")).status_code == 204
|
assert (
|
||||||
|
await auth_client.delete(f"{ENC_BASE}/{encounter['id']}")
|
||||||
|
).status_code == 204
|
||||||
# Run detail should no longer include it
|
# Run detail should no longer include it
|
||||||
detail = (await client.get(f"{RUNS_BASE}/{enc_ctx['run_id']}")).json()
|
detail = (await auth_client.get(f"{RUNS_BASE}/{enc_ctx['run_id']}")).json()
|
||||||
assert all(e["id"] != encounter["id"] for e in detail["encounters"])
|
assert all(e["id"] != encounter["id"] for e in detail["encounters"])
|
||||||
|
|
||||||
async def test_not_found_returns_404(self, client: AsyncClient):
|
async def test_not_found_returns_404(self, auth_client: AsyncClient):
|
||||||
assert (await client.delete(f"{ENC_BASE}/9999")).status_code == 404
|
assert (await auth_client.delete(f"{ENC_BASE}/9999")).status_code == 404
|
||||||
|
|||||||
128
docs/supabase-auth-setup.md
Normal file
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/core": "6.3.1",
|
||||||
"@dnd-kit/sortable": "10.0.0",
|
"@dnd-kit/sortable": "10.0.0",
|
||||||
"@dnd-kit/utilities": "3.2.2",
|
"@dnd-kit/utilities": "3.2.2",
|
||||||
|
"@supabase/supabase-js": "^2.99.3",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@tanstack/react-query": "5.91.3",
|
"@tanstack/react-query": "5.91.3",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
@@ -2148,6 +2149,86 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@supabase/auth-js": {
|
||||||
|
"version": "2.99.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.99.3.tgz",
|
||||||
|
"integrity": "sha512-vMEVLA1kGGYd/kdsJSwtjiFUZM1nGfrz2DWmgMBZtocV48qL+L2+4QpIkueXyBEumMQZFEyhz57i/5zGHjvdBw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "2.8.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@supabase/functions-js": {
|
||||||
|
"version": "2.99.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.99.3.tgz",
|
||||||
|
"integrity": "sha512-6tk2zrcBkzKaaBXPOG5nshn30uJNFGOH9LxOnE8i850eQmsX+jVm7vql9kTPyvUzEHwU4zdjSOkXS9M+9ukMVA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "2.8.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@supabase/postgrest-js": {
|
||||||
|
"version": "2.99.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.99.3.tgz",
|
||||||
|
"integrity": "sha512-8HxEf+zNycj7Z8+ONhhlu+7J7Ha+L6weyCtdEeK2mN5OWJbh6n4LPU4iuJ5UlCvvNnbSXMoutY7piITEEAgl2g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "2.8.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@supabase/realtime-js": {
|
||||||
|
"version": "2.99.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.99.3.tgz",
|
||||||
|
"integrity": "sha512-c1azgZ2nZPczbY5k5u5iFrk1InpxN81IvNE+UBAkjrBz3yc5ALLJNkeTQwbJZT4PZBuYXEzqYGLMuh9fdTtTMg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/phoenix": "^1.6.6",
|
||||||
|
"@types/ws": "^8.18.1",
|
||||||
|
"tslib": "2.8.1",
|
||||||
|
"ws": "^8.18.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@supabase/storage-js": {
|
||||||
|
"version": "2.99.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.99.3.tgz",
|
||||||
|
"integrity": "sha512-lOfIm4hInNcd8x0i1LWphnLKxec42wwbjs+vhaVAvR801Vda0UAMbTooUY6gfqgQb8v29GofqKuQMMTAsl6w/w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"iceberg-js": "^0.8.1",
|
||||||
|
"tslib": "2.8.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@supabase/supabase-js": {
|
||||||
|
"version": "2.99.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.99.3.tgz",
|
||||||
|
"integrity": "sha512-GuPbzoEaI51AkLw9VGhLNvnzw4PHbS3p8j2/JlvLeZNQMKwZw4aEYQIDBRtFwL5Nv7/275n9m4DHtakY8nCvgg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@supabase/auth-js": "2.99.3",
|
||||||
|
"@supabase/functions-js": "2.99.3",
|
||||||
|
"@supabase/postgrest-js": "2.99.3",
|
||||||
|
"@supabase/realtime-js": "2.99.3",
|
||||||
|
"@supabase/storage-js": "2.99.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tailwindcss/node": {
|
"node_modules/@tailwindcss/node": {
|
||||||
"version": "4.2.2",
|
"version": "4.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz",
|
||||||
@@ -2735,12 +2816,17 @@
|
|||||||
"version": "24.12.0",
|
"version": "24.12.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.0.tgz",
|
||||||
"integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==",
|
"integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~7.16.0"
|
"undici-types": "~7.16.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/phoenix": {
|
||||||
|
"version": "1.6.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.7.tgz",
|
||||||
|
"integrity": "sha512-oN9ive//QSBkf19rfDv45M7eZPi0eEXylht2OLEXicu5b4KoQ1OzXIw+xDSGWxSxe1JmepRR/ZH283vsu518/Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/react": {
|
"node_modules/@types/react": {
|
||||||
"version": "19.2.14",
|
"version": "19.2.14",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||||
@@ -2766,6 +2852,15 @@
|
|||||||
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
|
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/ws": {
|
||||||
|
"version": "8.18.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||||
|
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@ungap/structured-clone": {
|
"node_modules/@ungap/structured-clone": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz",
|
||||||
@@ -3584,6 +3679,15 @@
|
|||||||
"node": ">= 14"
|
"node": ">= 14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/iceberg-js": {
|
||||||
|
"version": "0.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz",
|
||||||
|
"integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/indent-string": {
|
"node_modules/indent-string": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
|
||||||
@@ -5778,7 +5882,6 @@
|
|||||||
"version": "7.16.0",
|
"version": "7.16.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
|
||||||
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
|
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/unified": {
|
"node_modules/unified": {
|
||||||
@@ -6155,6 +6258,27 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ws": {
|
||||||
|
"version": "8.19.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
|
||||||
|
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"bufferutil": "^4.0.1",
|
||||||
|
"utf-8-validate": ">=5.0.2"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"bufferutil": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"utf-8-validate": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/xml-name-validator": {
|
"node_modules/xml-name-validator": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
"@dnd-kit/core": "6.3.1",
|
"@dnd-kit/core": "6.3.1",
|
||||||
"@dnd-kit/sortable": "10.0.0",
|
"@dnd-kit/sortable": "10.0.0",
|
||||||
"@dnd-kit/utilities": "3.2.2",
|
"@dnd-kit/utilities": "3.2.2",
|
||||||
|
"@supabase/supabase-js": "^2.99.3",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@tanstack/react-query": "5.91.3",
|
"@tanstack/react-query": "5.91.3",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
|
|||||||
@@ -2,14 +2,17 @@ import { Routes, Route, Navigate } from 'react-router-dom'
|
|||||||
import { Layout } from './components'
|
import { Layout } from './components'
|
||||||
import { AdminLayout } from './components/admin'
|
import { AdminLayout } from './components/admin'
|
||||||
import {
|
import {
|
||||||
|
AuthCallback,
|
||||||
GenlockeDetail,
|
GenlockeDetail,
|
||||||
GenlockeList,
|
GenlockeList,
|
||||||
Home,
|
Home,
|
||||||
JournalEntryPage,
|
JournalEntryPage,
|
||||||
|
Login,
|
||||||
NewGenlocke,
|
NewGenlocke,
|
||||||
NewRun,
|
NewRun,
|
||||||
RunList,
|
RunList,
|
||||||
RunEncounters,
|
RunEncounters,
|
||||||
|
Signup,
|
||||||
Stats,
|
Stats,
|
||||||
} from './pages'
|
} from './pages'
|
||||||
import {
|
import {
|
||||||
@@ -28,6 +31,9 @@ function App() {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Layout />}>
|
<Route path="/" element={<Layout />}>
|
||||||
<Route index element={<Home />} />
|
<Route index element={<Home />} />
|
||||||
|
<Route path="login" element={<Login />} />
|
||||||
|
<Route path="signup" element={<Signup />} />
|
||||||
|
<Route path="auth/callback" element={<AuthCallback />} />
|
||||||
<Route path="runs" element={<RunList />} />
|
<Route path="runs" element={<RunList />} />
|
||||||
<Route path="runs/new" element={<NewRun />} />
|
<Route path="runs/new" element={<NewRun />} />
|
||||||
<Route path="runs/:runId" element={<RunEncounters />} />
|
<Route path="runs/:runId" element={<RunEncounters />} />
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { supabase } from '../lib/supabase'
|
||||||
|
|
||||||
const API_BASE = import.meta.env['VITE_API_URL'] ?? ''
|
const API_BASE = import.meta.env['VITE_API_URL'] ?? ''
|
||||||
|
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
@@ -10,11 +12,21 @@ export class ApiError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getAuthHeaders(): Promise<Record<string, string>> {
|
||||||
|
const { data } = await supabase.auth.getSession()
|
||||||
|
if (data.session?.access_token) {
|
||||||
|
return { Authorization: `Bearer ${data.session.access_token}` }
|
||||||
|
}
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
||||||
|
const authHeaders = await getAuthHeaders()
|
||||||
const res = await fetch(`${API_BASE}/api/v1${path}`, {
|
const res = await fetch(`${API_BASE}/api/v1${path}`, {
|
||||||
...options,
|
...options,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
...authHeaders,
|
||||||
...options?.headers,
|
...options?.headers,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,10 +5,7 @@ import type {
|
|||||||
UpdateJournalEntryInput,
|
UpdateJournalEntryInput,
|
||||||
} from '../types/journal'
|
} from '../types/journal'
|
||||||
|
|
||||||
export function getJournalEntries(
|
export function getJournalEntries(runId: number, bossResultId?: number): Promise<JournalEntry[]> {
|
||||||
runId: number,
|
|
||||||
bossResultId?: number
|
|
||||||
): Promise<JournalEntry[]> {
|
|
||||||
const params = bossResultId != null ? `?boss_result_id=${bossResultId}` : ''
|
const params = bossResultId != null ? `?boss_result_id=${bossResultId}` : ''
|
||||||
return api.get(`/runs/${runId}/journal${params}`)
|
return api.get(`/runs/${runId}/journal${params}`)
|
||||||
}
|
}
|
||||||
|
|||||||
30
frontend/src/api/moves.ts
Normal file
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 FormEvent, useMemo, useState } from 'react'
|
||||||
import type { BossBattle, CreateBossResultInput } from '../types/game'
|
import type {
|
||||||
|
BossBattle,
|
||||||
|
BossResultTeamMemberInput,
|
||||||
|
CreateBossResultInput,
|
||||||
|
EncounterDetail,
|
||||||
|
} from '../types/game'
|
||||||
import { ConditionBadge } from './ConditionBadge'
|
import { ConditionBadge } from './ConditionBadge'
|
||||||
|
|
||||||
interface BossDefeatModalProps {
|
interface BossDefeatModalProps {
|
||||||
boss: BossBattle
|
boss: BossBattle
|
||||||
|
aliveEncounters: EncounterDetail[]
|
||||||
onSubmit: (data: CreateBossResultInput) => void
|
onSubmit: (data: CreateBossResultInput) => void
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
isPending?: boolean
|
isPending?: boolean
|
||||||
@@ -17,14 +23,43 @@ function matchVariant(labels: string[], starterName?: string | null): string | n
|
|||||||
return matches.length === 1 ? (matches[0] ?? null) : null
|
return matches.length === 1 ? (matches[0] ?? null) : null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface TeamSelection {
|
||||||
|
encounterId: number
|
||||||
|
level: number
|
||||||
|
}
|
||||||
|
|
||||||
export function BossDefeatModal({
|
export function BossDefeatModal({
|
||||||
boss,
|
boss,
|
||||||
|
aliveEncounters,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
onClose,
|
onClose,
|
||||||
isPending,
|
isPending,
|
||||||
starterName,
|
starterName,
|
||||||
}: BossDefeatModalProps) {
|
}: BossDefeatModalProps) {
|
||||||
|
const [selectedTeam, setSelectedTeam] = useState<Map<number, TeamSelection>>(new Map())
|
||||||
|
|
||||||
|
const toggleTeamMember = (enc: EncounterDetail) => {
|
||||||
|
setSelectedTeam((prev) => {
|
||||||
|
const next = new Map(prev)
|
||||||
|
if (next.has(enc.id)) {
|
||||||
|
next.delete(enc.id)
|
||||||
|
} else {
|
||||||
|
next.set(enc.id, { encounterId: enc.id, level: enc.catchLevel ?? 1 })
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateLevel = (encounterId: number, level: number) => {
|
||||||
|
setSelectedTeam((prev) => {
|
||||||
|
const next = new Map(prev)
|
||||||
|
const existing = next.get(encounterId)
|
||||||
|
if (existing) {
|
||||||
|
next.set(encounterId, { ...existing, level })
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
const variantLabels = useMemo(() => {
|
const variantLabels = useMemo(() => {
|
||||||
const labels = new Set<string>()
|
const labels = new Set<string>()
|
||||||
for (const bp of boss.pokemon) {
|
for (const bp of boss.pokemon) {
|
||||||
@@ -52,10 +87,12 @@ export function BossDefeatModal({
|
|||||||
|
|
||||||
const handleSubmit = (e: FormEvent) => {
|
const handleSubmit = (e: FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
const team: BossResultTeamMemberInput[] = Array.from(selectedTeam.values())
|
||||||
onSubmit({
|
onSubmit({
|
||||||
bossBattleId: boss.id,
|
bossBattleId: boss.id,
|
||||||
result: 'won',
|
result: 'won',
|
||||||
attempts: 1,
|
attempts: 1,
|
||||||
|
team,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,7 +129,9 @@ export function BossDefeatModal({
|
|||||||
<div className="flex flex-wrap gap-3">
|
<div className="flex flex-wrap gap-3">
|
||||||
{[...displayedPokemon]
|
{[...displayedPokemon]
|
||||||
.sort((a, b) => a.order - b.order)
|
.sort((a, b) => a.order - b.order)
|
||||||
.map((bp) => (
|
.map((bp) => {
|
||||||
|
const moves = [bp.move1, bp.move2, bp.move3, bp.move4].filter(Boolean)
|
||||||
|
return (
|
||||||
<div key={bp.id} className="flex flex-col items-center">
|
<div key={bp.id} className="flex flex-col items-center">
|
||||||
{bp.pokemon.spriteUrl ? (
|
{bp.pokemon.spriteUrl ? (
|
||||||
<img src={bp.pokemon.spriteUrl} alt={bp.pokemon.name} className="w-10 h-10" />
|
<img src={bp.pokemon.spriteUrl} alt={bp.pokemon.name} className="w-10 h-10" />
|
||||||
@@ -102,8 +141,81 @@ export function BossDefeatModal({
|
|||||||
<span className="text-xs text-text-tertiary capitalize">{bp.pokemon.name}</span>
|
<span className="text-xs text-text-tertiary capitalize">{bp.pokemon.name}</span>
|
||||||
<span className="text-xs font-medium text-text-secondary">Lv.{bp.level}</span>
|
<span className="text-xs font-medium text-text-secondary">Lv.{bp.level}</span>
|
||||||
<ConditionBadge condition={bp.conditionLabel} size="xs" />
|
<ConditionBadge condition={bp.conditionLabel} size="xs" />
|
||||||
|
{bp.ability && (
|
||||||
|
<span className="text-[10px] text-text-muted">{bp.ability.name}</span>
|
||||||
|
)}
|
||||||
|
{bp.heldItem && (
|
||||||
|
<span className="text-[10px] text-yellow-500/80">{bp.heldItem}</span>
|
||||||
|
)}
|
||||||
|
{moves.length > 0 && (
|
||||||
|
<div className="text-[9px] text-text-muted text-center leading-tight max-w-[80px]">
|
||||||
|
{moves.map((m) => m!.name).join(', ')}
|
||||||
</div>
|
</div>
|
||||||
))}
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Team selection */}
|
||||||
|
{aliveEncounters.length > 0 && (
|
||||||
|
<div className="px-6 py-3 border-b border-border-default">
|
||||||
|
<p className="text-sm font-medium text-text-secondary mb-2">Your team (optional)</p>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2 max-h-48 overflow-y-auto">
|
||||||
|
{aliveEncounters.map((enc) => {
|
||||||
|
const isSelected = selectedTeam.has(enc.id)
|
||||||
|
const selection = selectedTeam.get(enc.id)
|
||||||
|
const displayPokemon = enc.currentPokemon ?? enc.pokemon
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={enc.id}
|
||||||
|
className={`flex items-center gap-2 p-2 rounded border cursor-pointer transition-colors ${
|
||||||
|
isSelected
|
||||||
|
? 'border-accent-500 bg-accent-500/10'
|
||||||
|
: 'border-border-default hover:bg-surface-2'
|
||||||
|
}`}
|
||||||
|
onClick={() => toggleTeamMember(enc)}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isSelected}
|
||||||
|
onChange={() => toggleTeamMember(enc)}
|
||||||
|
className="sr-only"
|
||||||
|
/>
|
||||||
|
{displayPokemon.spriteUrl ? (
|
||||||
|
<img
|
||||||
|
src={displayPokemon.spriteUrl}
|
||||||
|
alt={displayPokemon.name}
|
||||||
|
className="w-8 h-8"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-8 h-8 bg-surface-3 rounded-full" />
|
||||||
|
)}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-xs font-medium truncate">
|
||||||
|
{enc.nickname ?? displayPokemon.name}
|
||||||
|
</p>
|
||||||
|
{isSelected && (
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={100}
|
||||||
|
value={selection?.level ?? enc.catchLevel ?? 1}
|
||||||
|
onChange={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
updateLevel(enc.id, Number.parseInt(e.target.value, 10) || 1)
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="w-14 text-xs px-1 py-0.5 mt-1 rounded border border-border-default bg-surface-1"
|
||||||
|
placeholder="Lv"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { render, screen } from '@testing-library/react'
|
|||||||
import userEvent from '@testing-library/user-event'
|
import userEvent from '@testing-library/user-event'
|
||||||
import { MemoryRouter } from 'react-router-dom'
|
import { MemoryRouter } from 'react-router-dom'
|
||||||
import { Layout } from './Layout'
|
import { Layout } from './Layout'
|
||||||
|
import { AuthProvider } from '../contexts/AuthContext'
|
||||||
|
|
||||||
vi.mock('../hooks/useTheme', () => ({
|
vi.mock('../hooks/useTheme', () => ({
|
||||||
useTheme: () => ({ theme: 'dark' as const, toggle: vi.fn() }),
|
useTheme: () => ({ theme: 'dark' as const, toggle: vi.fn() }),
|
||||||
@@ -10,7 +11,9 @@ vi.mock('../hooks/useTheme', () => ({
|
|||||||
function renderLayout(initialPath = '/') {
|
function renderLayout(initialPath = '/') {
|
||||||
return render(
|
return render(
|
||||||
<MemoryRouter initialEntries={[initialPath]}>
|
<MemoryRouter initialEntries={[initialPath]}>
|
||||||
|
<AuthProvider>
|
||||||
<Layout />
|
<Layout />
|
||||||
|
</AuthProvider>
|
||||||
</MemoryRouter>
|
</MemoryRouter>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Link, Outlet, useLocation } from 'react-router-dom'
|
import { Link, Outlet, useLocation } from 'react-router-dom'
|
||||||
import { useTheme } from '../hooks/useTheme'
|
import { useTheme } from '../hooks/useTheme'
|
||||||
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
|
|
||||||
const navLinks = [
|
const navLinks = [
|
||||||
{ to: '/runs/new', label: 'New Run' },
|
{ to: '/runs/new', label: 'New Run' },
|
||||||
@@ -71,6 +72,67 @@ function ThemeToggle() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function UserMenu({ onAction }: { onAction?: () => void }) {
|
||||||
|
const { user, loading, signOut } = useAuth()
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="w-8 h-8 rounded-full bg-surface-3 animate-pulse" />
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
to="/login"
|
||||||
|
onClick={onAction}
|
||||||
|
className="px-3 py-2 rounded-md text-sm font-medium text-text-secondary hover:text-text-primary hover:bg-surface-3 transition-colors"
|
||||||
|
>
|
||||||
|
Sign in
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const email = user.email ?? ''
|
||||||
|
const initials = email.charAt(0).toUpperCase()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen(!open)}
|
||||||
|
className="flex items-center gap-2 p-1 rounded-full hover:bg-surface-3 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="w-8 h-8 rounded-full bg-accent-600 flex items-center justify-center text-white text-sm font-medium">
|
||||||
|
{initials}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<>
|
||||||
|
<div className="fixed inset-0 z-40" onClick={() => setOpen(false)} />
|
||||||
|
<div className="absolute right-0 mt-2 w-48 bg-surface-2 border border-border-default rounded-lg shadow-lg z-50">
|
||||||
|
<div className="px-4 py-3 border-b border-border-default">
|
||||||
|
<p className="text-sm text-text-primary truncate">{email}</p>
|
||||||
|
</div>
|
||||||
|
<div className="py-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={async () => {
|
||||||
|
setOpen(false)
|
||||||
|
onAction?.()
|
||||||
|
await signOut()
|
||||||
|
}}
|
||||||
|
className="w-full text-left px-4 py-2 text-sm text-text-secondary hover:text-text-primary hover:bg-surface-3 transition-colors"
|
||||||
|
>
|
||||||
|
Sign out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function Layout() {
|
export function Layout() {
|
||||||
const [menuOpen, setMenuOpen] = useState(false)
|
const [menuOpen, setMenuOpen] = useState(false)
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
@@ -103,6 +165,7 @@ export function Layout() {
|
|||||||
</NavLink>
|
</NavLink>
|
||||||
))}
|
))}
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
|
<UserMenu />
|
||||||
</div>
|
</div>
|
||||||
{/* Mobile hamburger */}
|
{/* Mobile hamburger */}
|
||||||
<div className="flex items-center gap-1 sm:hidden">
|
<div className="flex items-center gap-1 sm:hidden">
|
||||||
@@ -149,6 +212,9 @@ export function Layout() {
|
|||||||
{link.label}
|
{link.label}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
))}
|
))}
|
||||||
|
<div className="pt-2 border-t border-border-default mt-2">
|
||||||
|
<UserMenu onAction={() => setMenuOpen(false)} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
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 { type FormEvent, useState } from 'react'
|
||||||
import { PokemonSelector } from './PokemonSelector'
|
import { PokemonSelector } from './PokemonSelector'
|
||||||
|
import { MoveSelector } from './MoveSelector'
|
||||||
|
import { AbilitySelector } from './AbilitySelector'
|
||||||
import type { BossBattle } from '../../types/game'
|
import type { BossBattle } from '../../types/game'
|
||||||
import type { BossPokemonInput } from '../../types/admin'
|
import type { BossPokemonInput } from '../../types/admin'
|
||||||
|
|
||||||
|
const NATURES = [
|
||||||
|
'Hardy',
|
||||||
|
'Lonely',
|
||||||
|
'Brave',
|
||||||
|
'Adamant',
|
||||||
|
'Naughty',
|
||||||
|
'Bold',
|
||||||
|
'Docile',
|
||||||
|
'Relaxed',
|
||||||
|
'Impish',
|
||||||
|
'Lax',
|
||||||
|
'Timid',
|
||||||
|
'Hasty',
|
||||||
|
'Serious',
|
||||||
|
'Jolly',
|
||||||
|
'Naive',
|
||||||
|
'Modest',
|
||||||
|
'Mild',
|
||||||
|
'Quiet',
|
||||||
|
'Bashful',
|
||||||
|
'Rash',
|
||||||
|
'Calm',
|
||||||
|
'Gentle',
|
||||||
|
'Sassy',
|
||||||
|
'Careful',
|
||||||
|
'Quirky',
|
||||||
|
]
|
||||||
|
|
||||||
interface BossTeamEditorProps {
|
interface BossTeamEditorProps {
|
||||||
boss: BossBattle
|
boss: BossBattle
|
||||||
onSave: (team: BossPokemonInput[]) => void
|
onSave: (team: BossPokemonInput[]) => void
|
||||||
@@ -15,6 +45,19 @@ interface PokemonSlot {
|
|||||||
pokemonName: string
|
pokemonName: string
|
||||||
level: string
|
level: string
|
||||||
order: number
|
order: number
|
||||||
|
// Detail fields
|
||||||
|
abilityId: number | null
|
||||||
|
abilityName: string
|
||||||
|
heldItem: string
|
||||||
|
nature: string
|
||||||
|
move1Id: number | null
|
||||||
|
move1Name: string
|
||||||
|
move2Id: number | null
|
||||||
|
move2Name: string
|
||||||
|
move3Id: number | null
|
||||||
|
move3Name: string
|
||||||
|
move4Id: number | null
|
||||||
|
move4Name: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Variant {
|
interface Variant {
|
||||||
@@ -22,6 +65,27 @@ interface Variant {
|
|||||||
pokemon: PokemonSlot[]
|
pokemon: PokemonSlot[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createEmptySlot(order: number): PokemonSlot {
|
||||||
|
return {
|
||||||
|
pokemonId: null,
|
||||||
|
pokemonName: '',
|
||||||
|
level: '',
|
||||||
|
order,
|
||||||
|
abilityId: null,
|
||||||
|
abilityName: '',
|
||||||
|
heldItem: '',
|
||||||
|
nature: '',
|
||||||
|
move1Id: null,
|
||||||
|
move1Name: '',
|
||||||
|
move2Id: null,
|
||||||
|
move2Name: '',
|
||||||
|
move3Id: null,
|
||||||
|
move3Name: '',
|
||||||
|
move4Id: null,
|
||||||
|
move4Name: '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function groupByVariant(boss: BossBattle): Variant[] {
|
function groupByVariant(boss: BossBattle): Variant[] {
|
||||||
const sorted = [...boss.pokemon].sort((a, b) => a.order - b.order)
|
const sorted = [...boss.pokemon].sort((a, b) => a.order - b.order)
|
||||||
const map = new Map<string | null, PokemonSlot[]>()
|
const map = new Map<string | null, PokemonSlot[]>()
|
||||||
@@ -34,25 +98,30 @@ function groupByVariant(boss: BossBattle): Variant[] {
|
|||||||
pokemonName: bp.pokemon.name,
|
pokemonName: bp.pokemon.name,
|
||||||
level: String(bp.level),
|
level: String(bp.level),
|
||||||
order: bp.order,
|
order: bp.order,
|
||||||
|
abilityId: bp.abilityId,
|
||||||
|
abilityName: bp.ability?.name ?? '',
|
||||||
|
heldItem: bp.heldItem ?? '',
|
||||||
|
nature: bp.nature ?? '',
|
||||||
|
move1Id: bp.move1Id,
|
||||||
|
move1Name: bp.move1?.name ?? '',
|
||||||
|
move2Id: bp.move2Id,
|
||||||
|
move2Name: bp.move2?.name ?? '',
|
||||||
|
move3Id: bp.move3Id,
|
||||||
|
move3Name: bp.move3?.name ?? '',
|
||||||
|
move4Id: bp.move4Id,
|
||||||
|
move4Name: bp.move4?.name ?? '',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (map.size === 0) {
|
if (map.size === 0) {
|
||||||
return [
|
return [{ label: null, pokemon: [createEmptySlot(1)] }]
|
||||||
{
|
|
||||||
label: null,
|
|
||||||
pokemon: [{ pokemonId: null, pokemonName: '', level: '', order: 1 }],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const variants: Variant[] = []
|
const variants: Variant[] = []
|
||||||
// null (default) first
|
|
||||||
if (map.has(null)) {
|
if (map.has(null)) {
|
||||||
variants.push({ label: null, pokemon: map.get(null)! })
|
variants.push({ label: null, pokemon: map.get(null)! })
|
||||||
map.delete(null)
|
map.delete(null)
|
||||||
}
|
}
|
||||||
// Then alphabetical
|
|
||||||
const remaining = [...map.entries()].sort((a, b) => (a[0] ?? '').localeCompare(b[0] ?? ''))
|
const remaining = [...map.entries()].sort((a, b) => (a[0] ?? '').localeCompare(b[0] ?? ''))
|
||||||
for (const [label, pokemon] of remaining) {
|
for (const [label, pokemon] of remaining) {
|
||||||
variants.push({ label, pokemon })
|
variants.push({ label, pokemon })
|
||||||
@@ -65,9 +134,19 @@ export function BossTeamEditor({ boss, onSave, onClose, isSaving }: BossTeamEdit
|
|||||||
const [activeTab, setActiveTab] = useState(0)
|
const [activeTab, setActiveTab] = useState(0)
|
||||||
const [newVariantName, setNewVariantName] = useState('')
|
const [newVariantName, setNewVariantName] = useState('')
|
||||||
const [showAddVariant, setShowAddVariant] = useState(false)
|
const [showAddVariant, setShowAddVariant] = useState(false)
|
||||||
|
const [expandedSlots, setExpandedSlots] = useState<Set<string>>(new Set())
|
||||||
|
|
||||||
const activeVariant = variants[activeTab] ?? variants[0]
|
const activeVariant = variants[activeTab] ?? variants[0]
|
||||||
|
|
||||||
|
const toggleExpanded = (key: string) => {
|
||||||
|
setExpandedSlots((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(key)) next.delete(key)
|
||||||
|
else next.add(key)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const updateVariant = (tabIndex: number, updater: (v: Variant) => Variant) => {
|
const updateVariant = (tabIndex: number, updater: (v: Variant) => Variant) => {
|
||||||
setVariants((prev) => prev.map((v, i) => (i === tabIndex ? updater(v) : v)))
|
setVariants((prev) => prev.map((v, i) => (i === tabIndex ? updater(v) : v)))
|
||||||
}
|
}
|
||||||
@@ -75,15 +154,7 @@ export function BossTeamEditor({ boss, onSave, onClose, isSaving }: BossTeamEdit
|
|||||||
const addSlot = () => {
|
const addSlot = () => {
|
||||||
updateVariant(activeTab, (v) => ({
|
updateVariant(activeTab, (v) => ({
|
||||||
...v,
|
...v,
|
||||||
pokemon: [
|
pokemon: [...v.pokemon, createEmptySlot(v.pokemon.length + 1)],
|
||||||
...v.pokemon,
|
|
||||||
{
|
|
||||||
pokemonId: null,
|
|
||||||
pokemonName: '',
|
|
||||||
level: '',
|
|
||||||
order: v.pokemon.length + 1,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,10 +167,10 @@ export function BossTeamEditor({ boss, onSave, onClose, isSaving }: BossTeamEdit
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateSlot = (index: number, field: string, value: number | string | null) => {
|
const updateSlot = (index: number, updates: Partial<PokemonSlot>) => {
|
||||||
updateVariant(activeTab, (v) => ({
|
updateVariant(activeTab, (v) => ({
|
||||||
...v,
|
...v,
|
||||||
pokemon: v.pokemon.map((item, i) => (i === index ? { ...item, [field]: value } : item)),
|
pokemon: v.pokemon.map((item, i) => (i === index ? { ...item, ...updates } : item)),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,13 +178,7 @@ export function BossTeamEditor({ boss, onSave, onClose, isSaving }: BossTeamEdit
|
|||||||
const name = newVariantName.trim()
|
const name = newVariantName.trim()
|
||||||
if (!name) return
|
if (!name) return
|
||||||
if (variants.some((v) => v.label === name)) return
|
if (variants.some((v) => v.label === name)) return
|
||||||
setVariants((prev) => [
|
setVariants((prev) => [...prev, { label: name, pokemon: [createEmptySlot(1)] }])
|
||||||
...prev,
|
|
||||||
{
|
|
||||||
label: name,
|
|
||||||
pokemon: [{ pokemonId: null, pokemonName: '', level: '', order: 1 }],
|
|
||||||
},
|
|
||||||
])
|
|
||||||
setActiveTab(variants.length)
|
setActiveTab(variants.length)
|
||||||
setNewVariantName('')
|
setNewVariantName('')
|
||||||
setShowAddVariant(false)
|
setShowAddVariant(false)
|
||||||
@@ -141,6 +206,13 @@ export function BossTeamEditor({ boss, onSave, onClose, isSaving }: BossTeamEdit
|
|||||||
level: Number(p.level),
|
level: Number(p.level),
|
||||||
order: i + 1,
|
order: i + 1,
|
||||||
conditionLabel,
|
conditionLabel,
|
||||||
|
abilityId: p.abilityId,
|
||||||
|
heldItem: p.heldItem || null,
|
||||||
|
nature: p.nature || null,
|
||||||
|
move1Id: p.move1Id,
|
||||||
|
move2Id: p.move2Id,
|
||||||
|
move3Id: p.move3Id,
|
||||||
|
move4Id: p.move4Id,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -150,7 +222,7 @@ export function BossTeamEditor({ boss, onSave, onClose, isSaving }: BossTeamEdit
|
|||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
<div className="fixed inset-0 bg-black/50" onClick={onClose} />
|
<div className="fixed inset-0 bg-black/50" onClick={onClose} />
|
||||||
<div className="relative bg-surface-1 rounded-lg shadow-xl max-w-lg w-full mx-4 max-h-[90vh] overflow-y-auto">
|
<div className="relative bg-surface-1 rounded-lg shadow-xl max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
|
||||||
<div className="px-6 py-4 border-b border-border-default">
|
<div className="px-6 py-4 border-b border-border-default">
|
||||||
<h2 className="text-lg font-semibold">{boss.name}'s Team</h2>
|
<h2 className="text-lg font-semibold">{boss.name}'s Team</h2>
|
||||||
</div>
|
</div>
|
||||||
@@ -209,11 +281,7 @@ export function BossTeamEditor({ boss, onSave, onClose, isSaving }: BossTeamEdit
|
|||||||
className="px-2 py-1 text-sm border rounded bg-surface-2 border-border-default w-40"
|
className="px-2 py-1 text-sm border rounded bg-surface-2 border-border-default w-40"
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<button
|
<button type="button" onClick={addVariant} className="px-2 py-1 text-sm text-text-link">
|
||||||
type="button"
|
|
||||||
onClick={addVariant}
|
|
||||||
className="px-2 py-1 text-sm text-text-link"
|
|
||||||
>
|
|
||||||
Add
|
Add
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@@ -228,15 +296,32 @@ export function BossTeamEditor({ boss, onSave, onClose, isSaving }: BossTeamEdit
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
<div className="px-6 py-4 space-y-3">
|
<div className="px-6 py-4 space-y-4">
|
||||||
{activeVariant?.pokemon.map((slot, index) => (
|
{activeVariant?.pokemon.map((slot, index) => {
|
||||||
<div key={`${activeTab}-${index}`} className="flex items-end gap-2">
|
const slotKey = `${activeTab}-${index}`
|
||||||
|
const isExpanded = expandedSlots.has(slotKey)
|
||||||
|
const hasDetails =
|
||||||
|
slot.abilityId ||
|
||||||
|
slot.heldItem ||
|
||||||
|
slot.nature ||
|
||||||
|
slot.move1Id ||
|
||||||
|
slot.move2Id ||
|
||||||
|
slot.move3Id ||
|
||||||
|
slot.move4Id
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={slotKey}
|
||||||
|
className="border border-border-default rounded-lg p-3 bg-surface-0"
|
||||||
|
>
|
||||||
|
{/* Main row: Pokemon + Level */}
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<PokemonSelector
|
<PokemonSelector
|
||||||
label={`Pokemon ${index + 1}`}
|
label={`Pokemon ${index + 1}`}
|
||||||
selectedId={slot.pokemonId}
|
selectedId={slot.pokemonId}
|
||||||
initialName={slot.pokemonName}
|
initialName={slot.pokemonName}
|
||||||
onChange={(id) => updateSlot(index, 'pokemonId', id)}
|
onChange={(id) => updateSlot(index, { pokemonId: id })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-20">
|
<div className="w-20">
|
||||||
@@ -246,10 +331,20 @@ export function BossTeamEditor({ boss, onSave, onClose, isSaving }: BossTeamEdit
|
|||||||
min={1}
|
min={1}
|
||||||
max={100}
|
max={100}
|
||||||
value={slot.level}
|
value={slot.level}
|
||||||
onChange={(e) => updateSlot(index, 'level', e.target.value)}
|
onChange={(e) => updateSlot(index, { level: e.target.value })}
|
||||||
className="w-full px-3 py-2 border rounded-md bg-surface-2 border-border-default"
|
className="w-full px-3 py-2 border rounded-md bg-surface-2 border-border-default"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleExpanded(slotKey)}
|
||||||
|
className={`px-2 py-2 text-sm transition-colors ${
|
||||||
|
hasDetails ? 'text-accent-500' : 'text-text-tertiary hover:text-text-secondary'
|
||||||
|
}`}
|
||||||
|
title={isExpanded ? 'Hide details' : 'Show details'}
|
||||||
|
>
|
||||||
|
{isExpanded ? '▼' : '▶'}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => removeSlot(index)}
|
onClick={() => removeSlot(index)}
|
||||||
@@ -259,7 +354,91 @@ export function BossTeamEditor({ boss, onSave, onClose, isSaving }: BossTeamEdit
|
|||||||
✕
|
✕
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Expandable details */}
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="mt-3 pt-3 border-t border-border-default space-y-3">
|
||||||
|
{/* Row 1: Ability, Held Item, Nature */}
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<AbilitySelector
|
||||||
|
label="Ability"
|
||||||
|
selectedId={slot.abilityId}
|
||||||
|
initialName={slot.abilityName}
|
||||||
|
onChange={(id, name) =>
|
||||||
|
updateSlot(index, { abilityId: id, abilityName: name })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium mb-0.5 text-text-secondary">
|
||||||
|
Held Item
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={slot.heldItem}
|
||||||
|
onChange={(e) => updateSlot(index, { heldItem: e.target.value })}
|
||||||
|
placeholder="e.g. Leftovers"
|
||||||
|
className="w-full px-2 py-1.5 text-sm border rounded bg-surface-2 border-border-default"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium mb-0.5 text-text-secondary">
|
||||||
|
Nature
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={slot.nature}
|
||||||
|
onChange={(e) => updateSlot(index, { nature: e.target.value })}
|
||||||
|
className="w-full px-2 py-1.5 text-sm border rounded bg-surface-2 border-border-default"
|
||||||
|
>
|
||||||
|
<option value="">—</option>
|
||||||
|
{NATURES.map((n) => (
|
||||||
|
<option key={n} value={n}>
|
||||||
|
{n}
|
||||||
|
</option>
|
||||||
))}
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row 2: Moves */}
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<MoveSelector
|
||||||
|
label="Move 1"
|
||||||
|
selectedId={slot.move1Id}
|
||||||
|
initialName={slot.move1Name}
|
||||||
|
onChange={(id, name) =>
|
||||||
|
updateSlot(index, { move1Id: id, move1Name: name })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<MoveSelector
|
||||||
|
label="Move 2"
|
||||||
|
selectedId={slot.move2Id}
|
||||||
|
initialName={slot.move2Name}
|
||||||
|
onChange={(id, name) =>
|
||||||
|
updateSlot(index, { move2Id: id, move2Name: name })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<MoveSelector
|
||||||
|
label="Move 3"
|
||||||
|
selectedId={slot.move3Id}
|
||||||
|
initialName={slot.move3Name}
|
||||||
|
onChange={(id, name) =>
|
||||||
|
updateSlot(index, { move3Id: id, move3Name: name })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<MoveSelector
|
||||||
|
label="Move 4"
|
||||||
|
selectedId={slot.move4Id}
|
||||||
|
initialName={slot.move4Name}
|
||||||
|
onChange={(id, name) =>
|
||||||
|
updateSlot(index, { move4Id: id, move4Name: name })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
{activeVariant && activeVariant.pokemon.length < 6 && (
|
{activeVariant && activeVariant.pokemon.length < 6 && (
|
||||||
<button
|
<button
|
||||||
|
|||||||
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 { CustomRulesDisplay } from './CustomRulesDisplay'
|
||||||
|
export { ProtectedRoute } from './ProtectedRoute'
|
||||||
export { EggEncounterModal } from './EggEncounterModal'
|
export { EggEncounterModal } from './EggEncounterModal'
|
||||||
export { EncounterMethodBadge } from './EncounterMethodBadge'
|
export { EncounterMethodBadge } from './EncounterMethodBadge'
|
||||||
export { EncounterModal } from './EncounterModal'
|
export { EncounterModal } from './EncounterModal'
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import type { BossResult, BossBattle } from '../../types/game'
|
|||||||
|
|
||||||
interface JournalEditorProps {
|
interface JournalEditorProps {
|
||||||
entry?: JournalEntry | null
|
entry?: JournalEntry | null
|
||||||
bossResults?: BossResult[]
|
bossResults?: BossResult[] | undefined
|
||||||
bosses?: BossBattle[]
|
bosses?: BossBattle[] | undefined
|
||||||
onSave: (data: { title: string; body: string; bossResultId: number | null }) => void
|
onSave: (data: { title: string; body: string; bossResultId: number | null }) => void
|
||||||
onDelete?: () => void
|
onDelete?: () => void
|
||||||
onCancel: () => void
|
onCancel: () => void
|
||||||
@@ -67,7 +67,10 @@ export function JournalEditor({
|
|||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="journal-title" className="block text-sm font-medium text-text-secondary mb-1">
|
<label
|
||||||
|
htmlFor="journal-title"
|
||||||
|
className="block text-sm font-medium text-text-secondary mb-1"
|
||||||
|
>
|
||||||
Title
|
Title
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -82,7 +85,10 @@ export function JournalEditor({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="journal-boss" className="block text-sm font-medium text-text-secondary mb-1">
|
<label
|
||||||
|
htmlFor="journal-boss"
|
||||||
|
className="block text-sm font-medium text-text-secondary mb-1"
|
||||||
|
>
|
||||||
Linked Boss Battle (optional)
|
Linked Boss Battle (optional)
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import type { BossResult, BossBattle } from '../../types/game'
|
|||||||
|
|
||||||
interface JournalEntryViewProps {
|
interface JournalEntryViewProps {
|
||||||
entry: JournalEntry
|
entry: JournalEntry
|
||||||
bossResult?: BossResult | null
|
bossResult?: BossResult | null | undefined
|
||||||
boss?: BossBattle | null
|
boss?: BossBattle | null | undefined
|
||||||
onEdit?: () => void
|
onEdit?: () => void
|
||||||
onBack?: () => void
|
onBack?: () => void
|
||||||
}
|
}
|
||||||
@@ -38,7 +38,12 @@ export function JournalEntryView({
|
|||||||
className="text-text-secondary hover:text-text-primary transition-colors flex items-center gap-1"
|
className="text-text-secondary hover:text-text-primary transition-colors flex items-center gap-1"
|
||||||
>
|
>
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M15 19l-7-7 7-7"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
Back to Journal
|
Back to Journal
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -19,7 +19,10 @@ function formatDate(dateString: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getPreviewSnippet(body: string, maxLength = 120): string {
|
function getPreviewSnippet(body: string, maxLength = 120): string {
|
||||||
const stripped = body.replace(/[#*_`~[\]]/g, '').replace(/\n+/g, ' ').trim()
|
const stripped = body
|
||||||
|
.replace(/[#*_`~[\]]/g, '')
|
||||||
|
.replace(/\n+/g, ' ')
|
||||||
|
.trim()
|
||||||
if (stripped.length <= maxLength) return stripped
|
if (stripped.length <= maxLength) return stripped
|
||||||
return stripped.slice(0, maxLength).trim() + '...'
|
return stripped.slice(0, maxLength).trim() + '...'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import type { BossResult, BossBattle } from '../../types/game'
|
|||||||
|
|
||||||
interface JournalSectionProps {
|
interface JournalSectionProps {
|
||||||
runId: number
|
runId: number
|
||||||
bossResults?: BossResult[]
|
bossResults?: BossResult[] | undefined
|
||||||
bosses?: BossBattle[]
|
bosses?: BossBattle[] | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
type Mode = 'list' | 'new'
|
type Mode = 'list' | 'new'
|
||||||
|
|||||||
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 { BrowserRouter } from 'react-router-dom'
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
import { Toaster } from 'sonner'
|
import { Toaster } from 'sonner'
|
||||||
|
import { AuthProvider } from './contexts/AuthContext'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
import App from './App.tsx'
|
import App from './App.tsx'
|
||||||
|
|
||||||
@@ -19,8 +20,10 @@ createRoot(document.getElementById('root')!).render(
|
|||||||
<StrictMode>
|
<StrictMode>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
|
<AuthProvider>
|
||||||
<App />
|
<App />
|
||||||
<Toaster position="bottom-right" richColors />
|
<Toaster position="bottom-right" richColors />
|
||||||
|
</AuthProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</StrictMode>
|
</StrictMode>
|
||||||
|
|||||||
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 { Link, useParams } from 'react-router-dom'
|
||||||
import { useGenlocke } from '../hooks/useGenlockes'
|
import { useGenlocke } from '../hooks/useGenlockes'
|
||||||
import { usePokemonFamilies } from '../hooks/usePokemon'
|
import { usePokemonFamilies } from '../hooks/usePokemon'
|
||||||
import { CustomRulesDisplay, GenlockeGraveyard, GenlockeLineage, StatCard, RuleBadges } from '../components'
|
import {
|
||||||
|
CustomRulesDisplay,
|
||||||
|
GenlockeGraveyard,
|
||||||
|
GenlockeLineage,
|
||||||
|
StatCard,
|
||||||
|
RuleBadges,
|
||||||
|
} from '../components'
|
||||||
import type { GenlockeLegDetail, RetiredPokemon, RunStatus } from '../types'
|
import type { GenlockeLegDetail, RetiredPokemon, RunStatus } from '../types'
|
||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
|
|
||||||
|
|||||||
@@ -2,11 +2,7 @@ import { useState } from 'react'
|
|||||||
import { useParams, useNavigate } from 'react-router-dom'
|
import { useParams, useNavigate } from 'react-router-dom'
|
||||||
import { useRun } from '../hooks/useRuns'
|
import { useRun } from '../hooks/useRuns'
|
||||||
import { useBossResults, useGameBosses } from '../hooks/useBosses'
|
import { useBossResults, useGameBosses } from '../hooks/useBosses'
|
||||||
import {
|
import { useJournalEntry, useUpdateJournalEntry, useDeleteJournalEntry } from '../hooks/useJournal'
|
||||||
useJournalEntry,
|
|
||||||
useUpdateJournalEntry,
|
|
||||||
useDeleteJournalEntry,
|
|
||||||
} from '../hooks/useJournal'
|
|
||||||
import { JournalEntryView } from '../components/journal/JournalEntryView'
|
import { JournalEntryView } from '../components/journal/JournalEntryView'
|
||||||
import { JournalEditor } from '../components/journal/JournalEditor'
|
import { JournalEditor } from '../components/journal/JournalEditor'
|
||||||
|
|
||||||
|
|||||||
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.
|
// In preset modes, filter out regions already used.
|
||||||
const availableRegions =
|
const availableRegions =
|
||||||
preset === 'custom'
|
preset === 'custom'
|
||||||
? regions ?? []
|
? (regions ?? [])
|
||||||
: regions?.filter((r) => !legs.some((l) => l.region === r.name)) ?? []
|
: (regions?.filter((r) => !legs.some((l) => l.region === r.name)) ?? [])
|
||||||
|
|
||||||
const usedRegionNames = new Set(legs.map((l) => l.region))
|
const usedRegionNames = new Set(legs.map((l) => l.region))
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useNavigate } from 'react-router-dom'
|
|||||||
import { GameGrid, RulesConfiguration, StepIndicator } from '../components'
|
import { GameGrid, RulesConfiguration, StepIndicator } from '../components'
|
||||||
import { useGames, useGameRoutes } from '../hooks/useGames'
|
import { useGames, useGameRoutes } from '../hooks/useGames'
|
||||||
import { useCreateRun, useRuns, useNamingCategories } from '../hooks/useRuns'
|
import { useCreateRun, useRuns, useNamingCategories } from '../hooks/useRuns'
|
||||||
import type { Game, NuzlockeRules } from '../types'
|
import type { Game, NuzlockeRules, RunVisibility } from '../types'
|
||||||
import { DEFAULT_RULES } from '../types'
|
import { DEFAULT_RULES } from '../types'
|
||||||
import { RULE_DEFINITIONS } from '../types/rules'
|
import { RULE_DEFINITIONS } from '../types/rules'
|
||||||
|
|
||||||
@@ -21,6 +21,7 @@ export function NewRun() {
|
|||||||
const [rules, setRules] = useState<NuzlockeRules>(DEFAULT_RULES)
|
const [rules, setRules] = useState<NuzlockeRules>(DEFAULT_RULES)
|
||||||
const [runName, setRunName] = useState('')
|
const [runName, setRunName] = useState('')
|
||||||
const [namingScheme, setNamingScheme] = useState<string | null>(null)
|
const [namingScheme, setNamingScheme] = useState<string | null>(null)
|
||||||
|
const [visibility, setVisibility] = useState<RunVisibility>('public')
|
||||||
const { data: routes } = useGameRoutes(selectedGame?.id ?? null)
|
const { data: routes } = useGameRoutes(selectedGame?.id ?? null)
|
||||||
|
|
||||||
const hiddenRules = useMemo(() => {
|
const hiddenRules = useMemo(() => {
|
||||||
@@ -46,7 +47,7 @@ export function NewRun() {
|
|||||||
const handleCreate = () => {
|
const handleCreate = () => {
|
||||||
if (!selectedGame) return
|
if (!selectedGame) return
|
||||||
createRun.mutate(
|
createRun.mutate(
|
||||||
{ gameId: selectedGame.id, name: runName, rules, namingScheme },
|
{ gameId: selectedGame.id, name: runName, rules, namingScheme, visibility },
|
||||||
{ onSuccess: (data) => navigate(`/runs/${data.id}`) }
|
{ onSuccess: (data) => navigate(`/runs/${data.id}`) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -195,6 +196,29 @@ export function NewRun() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="visibility"
|
||||||
|
className="block text-sm font-medium text-text-secondary mb-1"
|
||||||
|
>
|
||||||
|
Visibility
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="visibility"
|
||||||
|
value={visibility}
|
||||||
|
onChange={(e) => setVisibility(e.target.value as RunVisibility)}
|
||||||
|
className="w-full px-3 py-2 rounded-lg border border-border-default bg-surface-2 text-text-primary focus:outline-none focus:ring-2 focus:ring-accent-400 focus:border-transparent"
|
||||||
|
>
|
||||||
|
<option value="public">Public</option>
|
||||||
|
<option value="private">Private</option>
|
||||||
|
</select>
|
||||||
|
<p className="mt-1 text-xs text-text-tertiary">
|
||||||
|
{visibility === 'private'
|
||||||
|
? 'Only you will be able to see this run'
|
||||||
|
: 'Anyone can view this run'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="border-t border-border-default pt-4">
|
<div className="border-t border-border-default pt-4">
|
||||||
<h3 className="text-sm font-medium text-text-tertiary mb-2">Summary</h3>
|
<h3 className="text-sm font-medium text-text-tertiary mb-2">Summary</h3>
|
||||||
<dl className="space-y-1 text-sm">
|
<dl className="space-y-1 text-sm">
|
||||||
@@ -223,6 +247,10 @@ export function NewRun() {
|
|||||||
: 'None'}
|
: 'None'}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<dt className="text-text-tertiary">Visibility</dt>
|
||||||
|
<dd className="text-text-primary font-medium capitalize">{visibility}</dd>
|
||||||
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,10 +1,18 @@
|
|||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import { useParams, Link } from 'react-router-dom'
|
import { useParams, Link } from 'react-router-dom'
|
||||||
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
import { useRun, useUpdateRun, useNamingCategories } from '../hooks/useRuns'
|
import { useRun, useUpdateRun, useNamingCategories } from '../hooks/useRuns'
|
||||||
import { useGameRoutes } from '../hooks/useGames'
|
import { useGameRoutes } from '../hooks/useGames'
|
||||||
import { useCreateEncounter, useUpdateEncounter } from '../hooks/useEncounters'
|
import { useCreateEncounter, useUpdateEncounter } from '../hooks/useEncounters'
|
||||||
import { CustomRulesDisplay, StatCard, PokemonCard, RuleBadges, StatusChangeModal, EndRunModal } from '../components'
|
import {
|
||||||
import type { RunStatus, EncounterDetail } from '../types'
|
CustomRulesDisplay,
|
||||||
|
StatCard,
|
||||||
|
PokemonCard,
|
||||||
|
RuleBadges,
|
||||||
|
StatusChangeModal,
|
||||||
|
EndRunModal,
|
||||||
|
} from '../components'
|
||||||
|
import type { RunStatus, EncounterDetail, RunVisibility } from '../types'
|
||||||
|
|
||||||
type TeamSortKey = 'route' | 'level' | 'species' | 'dex'
|
type TeamSortKey = 'route' | 'level' | 'species' | 'dex'
|
||||||
|
|
||||||
@@ -49,6 +57,7 @@ export function RunDashboard() {
|
|||||||
const runIdNum = Number(runId)
|
const runIdNum = Number(runId)
|
||||||
const { data: run, isLoading, error } = useRun(runIdNum)
|
const { data: run, isLoading, error } = useRun(runIdNum)
|
||||||
const { data: routes } = useGameRoutes(run?.gameId ?? null)
|
const { data: routes } = useGameRoutes(run?.gameId ?? null)
|
||||||
|
const { user } = useAuth()
|
||||||
const createEncounter = useCreateEncounter(runIdNum)
|
const createEncounter = useCreateEncounter(runIdNum)
|
||||||
const updateEncounter = useUpdateEncounter(runIdNum)
|
const updateEncounter = useUpdateEncounter(runIdNum)
|
||||||
const updateRun = useUpdateRun(runIdNum)
|
const updateRun = useUpdateRun(runIdNum)
|
||||||
@@ -57,6 +66,9 @@ export function RunDashboard() {
|
|||||||
const [showEndRun, setShowEndRun] = useState(false)
|
const [showEndRun, setShowEndRun] = useState(false)
|
||||||
const [teamSort, setTeamSort] = useState<TeamSortKey>('route')
|
const [teamSort, setTeamSort] = useState<TeamSortKey>('route')
|
||||||
|
|
||||||
|
const isOwner = user && run?.owner?.id === user.id
|
||||||
|
const canEdit = isOwner || !run?.owner
|
||||||
|
|
||||||
const encounters = run?.encounters ?? []
|
const encounters = run?.encounters ?? []
|
||||||
const alive = useMemo(
|
const alive = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -190,11 +202,31 @@ export function RunDashboard() {
|
|||||||
<CustomRulesDisplay customRules={run.rules?.customRules ?? ''} />
|
<CustomRulesDisplay customRules={run.rules?.customRules ?? ''} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Visibility */}
|
||||||
|
{canEdit && (
|
||||||
|
<div className="mb-6">
|
||||||
|
<h2 className="text-sm font-medium text-text-tertiary mb-2">Visibility</h2>
|
||||||
|
<select
|
||||||
|
value={run.visibility}
|
||||||
|
onChange={(e) => updateRun.mutate({ visibility: e.target.value as RunVisibility })}
|
||||||
|
className="text-sm border border-border-default rounded-lg px-3 py-1.5 bg-surface-1 text-text-primary"
|
||||||
|
>
|
||||||
|
<option value="public">Public</option>
|
||||||
|
<option value="private">Private</option>
|
||||||
|
</select>
|
||||||
|
<p className="mt-1 text-xs text-text-tertiary">
|
||||||
|
{run.visibility === 'private'
|
||||||
|
? 'Only you can see this run'
|
||||||
|
: 'Anyone can view this run'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Naming Scheme */}
|
{/* Naming Scheme */}
|
||||||
{namingCategories && namingCategories.length > 0 && (
|
{namingCategories && namingCategories.length > 0 && (
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<h2 className="text-sm font-medium text-text-tertiary mb-2">Naming Scheme</h2>
|
<h2 className="text-sm font-medium text-text-tertiary mb-2">Naming Scheme</h2>
|
||||||
{isActive ? (
|
{isActive && canEdit ? (
|
||||||
<select
|
<select
|
||||||
value={run.namingScheme ?? ''}
|
value={run.namingScheme ?? ''}
|
||||||
onChange={(e) => updateRun.mutate({ namingScheme: e.target.value || null })}
|
onChange={(e) => updateRun.mutate({ namingScheme: e.target.value || null })}
|
||||||
@@ -246,7 +278,7 @@ export function RunDashboard() {
|
|||||||
<PokemonCard
|
<PokemonCard
|
||||||
key={enc.id}
|
key={enc.id}
|
||||||
encounter={enc}
|
encounter={enc}
|
||||||
onClick={isActive ? () => setSelectedEncounter(enc) : undefined}
|
onClick={isActive && canEdit ? () => setSelectedEncounter(enc) : undefined}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -263,7 +295,7 @@ export function RunDashboard() {
|
|||||||
key={enc.id}
|
key={enc.id}
|
||||||
encounter={enc}
|
encounter={enc}
|
||||||
showFaintLevel
|
showFaintLevel
|
||||||
onClick={isActive ? () => setSelectedEncounter(enc) : undefined}
|
onClick={isActive && canEdit ? () => setSelectedEncounter(enc) : undefined}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -272,7 +304,7 @@ export function RunDashboard() {
|
|||||||
|
|
||||||
{/* Quick Actions */}
|
{/* Quick Actions */}
|
||||||
<div className="mt-8 flex gap-3">
|
<div className="mt-8 flex gap-3">
|
||||||
{isActive && (
|
{isActive && canEdit && (
|
||||||
<>
|
<>
|
||||||
<Link
|
<Link
|
||||||
to={`/runs/${runId}/encounters`}
|
to={`/runs/${runId}/encounters`}
|
||||||
|
|||||||
@@ -246,7 +246,9 @@ function BossTeamPreview({
|
|||||||
<div className="flex gap-2 flex-wrap">
|
<div className="flex gap-2 flex-wrap">
|
||||||
{[...displayed]
|
{[...displayed]
|
||||||
.sort((a, b) => a.order - b.order)
|
.sort((a, b) => a.order - b.order)
|
||||||
.map((bp) => (
|
.map((bp) => {
|
||||||
|
const moves = [bp.move1, bp.move2, bp.move3, bp.move4].filter(Boolean)
|
||||||
|
return (
|
||||||
<div key={bp.id} className="flex items-center gap-1">
|
<div key={bp.id} className="flex items-center gap-1">
|
||||||
{bp.pokemon.spriteUrl ? (
|
{bp.pokemon.spriteUrl ? (
|
||||||
<img src={bp.pokemon.spriteUrl} alt={bp.pokemon.name} className="w-20 h-20" />
|
<img src={bp.pokemon.spriteUrl} alt={bp.pokemon.name} className="w-20 h-20" />
|
||||||
@@ -256,9 +258,21 @@ function BossTeamPreview({
|
|||||||
<div className="flex flex-col items-start gap-0.5">
|
<div className="flex flex-col items-start gap-0.5">
|
||||||
<span className="text-xs text-text-tertiary">Lvl {bp.level}</span>
|
<span className="text-xs text-text-tertiary">Lvl {bp.level}</span>
|
||||||
<ConditionBadge condition={bp.conditionLabel} size="xs" />
|
<ConditionBadge condition={bp.conditionLabel} size="xs" />
|
||||||
|
{bp.ability && (
|
||||||
|
<span className="text-[10px] text-text-muted">{bp.ability.name}</span>
|
||||||
|
)}
|
||||||
|
{bp.heldItem && (
|
||||||
|
<span className="text-[10px] text-yellow-500/80">{bp.heldItem}</span>
|
||||||
|
)}
|
||||||
|
{moves.length > 0 && (
|
||||||
|
<div className="text-[9px] text-text-muted leading-tight">
|
||||||
|
{moves.map((m) => m!.name).join(', ')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -663,6 +677,28 @@ export function RunEncounters() {
|
|||||||
return set
|
return set
|
||||||
}, [bossResults])
|
}, [bossResults])
|
||||||
|
|
||||||
|
// Map encounter ID to encounter detail for team display
|
||||||
|
const encounterById = useMemo(() => {
|
||||||
|
const map = new Map<number, EncounterDetail>()
|
||||||
|
if (run) {
|
||||||
|
for (const enc of run.encounters) {
|
||||||
|
map.set(enc.id, enc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
}, [run])
|
||||||
|
|
||||||
|
// Map boss battle ID to result for team snapshot
|
||||||
|
const bossResultByBattleId = useMemo(() => {
|
||||||
|
const map = new Map<number, (typeof bossResults)[number]>()
|
||||||
|
if (bossResults) {
|
||||||
|
for (const r of bossResults) {
|
||||||
|
map.set(r.bossBattleId, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
}, [bossResults])
|
||||||
|
|
||||||
const sortedBosses = useMemo(() => {
|
const sortedBosses = useMemo(() => {
|
||||||
if (!bosses) return []
|
if (!bosses) return []
|
||||||
return [...bosses].sort((a, b) => a.order - b.order)
|
return [...bosses].sort((a, b) => a.order - b.order)
|
||||||
@@ -1287,7 +1323,9 @@ export function RunEncounters() {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
const remaining = totalLocations - completedCount
|
const remaining = totalLocations - completedCount
|
||||||
if (
|
if (
|
||||||
window.confirm(`Randomize encounters for all ${remaining} remaining locations?`)
|
window.confirm(
|
||||||
|
`Randomize encounters for all ${remaining} remaining locations?`
|
||||||
|
)
|
||||||
) {
|
) {
|
||||||
bulkRandomize.mutate()
|
bulkRandomize.mutate()
|
||||||
}
|
}
|
||||||
@@ -1349,7 +1387,9 @@ export function RunEncounters() {
|
|||||||
{filteredRoutes.map((route) => {
|
{filteredRoutes.map((route) => {
|
||||||
// Collect all route IDs to check for boss cards after
|
// Collect all route IDs to check for boss cards after
|
||||||
const routeIds: number[] =
|
const routeIds: number[] =
|
||||||
route.children.length > 0 ? [route.id, ...route.children.map((c) => c.id)] : [route.id]
|
route.children.length > 0
|
||||||
|
? [route.id, ...route.children.map((c) => c.id)]
|
||||||
|
: [route.id]
|
||||||
|
|
||||||
// Find boss battles positioned after this route (or any of its children)
|
// Find boss battles positioned after this route (or any of its children)
|
||||||
const bossesHere: BossBattle[] = []
|
const bossesHere: BossBattle[] = []
|
||||||
@@ -1507,7 +1547,11 @@ export function RunEncounters() {
|
|||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
>
|
>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M9 5l7 7-7 7"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{boss.spriteUrl && (
|
{boss.spriteUrl && (
|
||||||
<img src={boss.spriteUrl} alt={boss.name} className="h-10 w-auto" />
|
<img src={boss.spriteUrl} alt={boss.name} className="h-10 w-auto" />
|
||||||
@@ -1546,6 +1590,36 @@ export function RunEncounters() {
|
|||||||
{isBossExpanded && boss.pokemon.length > 0 && (
|
{isBossExpanded && boss.pokemon.length > 0 && (
|
||||||
<BossTeamPreview pokemon={boss.pokemon} starterName={starterName} />
|
<BossTeamPreview pokemon={boss.pokemon} starterName={starterName} />
|
||||||
)}
|
)}
|
||||||
|
{/* Player team snapshot */}
|
||||||
|
{isDefeated && (() => {
|
||||||
|
const result = bossResultByBattleId.get(boss.id)
|
||||||
|
if (!result || result.team.length === 0) return null
|
||||||
|
return (
|
||||||
|
<div className="mt-3 pt-3 border-t border-border-default">
|
||||||
|
<p className="text-xs font-medium text-text-secondary mb-2">Your Team</p>
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
{result.team.map((tm) => {
|
||||||
|
const enc = encounterById.get(tm.encounterId)
|
||||||
|
if (!enc) return null
|
||||||
|
const dp = enc.currentPokemon ?? enc.pokemon
|
||||||
|
return (
|
||||||
|
<div key={tm.id} className="flex flex-col items-center">
|
||||||
|
{dp.spriteUrl ? (
|
||||||
|
<img src={dp.spriteUrl} alt={dp.name} className="w-10 h-10" />
|
||||||
|
) : (
|
||||||
|
<div className="w-10 h-10 bg-surface-3 rounded-full" />
|
||||||
|
)}
|
||||||
|
<span className="text-[10px] text-text-tertiary capitalize">
|
||||||
|
{enc.nickname ?? dp.name}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-text-muted">Lv.{tm.level}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
{sectionAfter && (
|
{sectionAfter && (
|
||||||
<div className="flex items-center gap-3 my-4">
|
<div className="flex items-center gap-3 my-4">
|
||||||
@@ -1633,6 +1707,7 @@ export function RunEncounters() {
|
|||||||
{selectedBoss && (
|
{selectedBoss && (
|
||||||
<BossDefeatModal
|
<BossDefeatModal
|
||||||
boss={selectedBoss}
|
boss={selectedBoss}
|
||||||
|
aliveEncounters={alive}
|
||||||
onSubmit={(data) => {
|
onSubmit={(data) => {
|
||||||
createBossResult.mutate(data, {
|
createBossResult.mutate(data, {
|
||||||
onSuccess: () => setSelectedBoss(null),
|
onSuccess: () => setSelectedBoss(null),
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
import { useRuns } from '../hooks/useRuns'
|
import { useRuns } from '../hooks/useRuns'
|
||||||
import type { RunStatus } from '../types'
|
import type { NuzlockeRun, RunStatus } from '../types'
|
||||||
|
|
||||||
const statusStyles: Record<RunStatus, string> = {
|
const statusStyles: Record<RunStatus, string> = {
|
||||||
active: 'bg-status-active-bg text-status-active border border-status-active/20',
|
active: 'bg-status-active-bg text-status-active border border-status-active/20',
|
||||||
@@ -8,22 +10,95 @@ const statusStyles: Record<RunStatus, string> = {
|
|||||||
failed: 'bg-status-failed-bg text-status-failed border border-status-failed/20',
|
failed: 'bg-status-failed-bg text-status-failed border border-status-failed/20',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function VisibilityBadge({ visibility }: { visibility: 'public' | 'private' }) {
|
||||||
|
if (visibility === 'private') {
|
||||||
|
return (
|
||||||
|
<span className="px-2 py-0.5 rounded-full text-xs font-medium bg-surface-3 text-text-tertiary border border-border-default">
|
||||||
|
Private
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function RunCard({ run, isOwned }: { run: NuzlockeRun; isOwned: boolean }) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
to={`/runs/${run.id}`}
|
||||||
|
className="block bg-surface-1 rounded-xl border border-border-default hover:border-border-accent transition-all hover:-translate-y-0.5 p-4"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h2 className="text-lg font-semibold text-text-primary truncate">{run.name}</h2>
|
||||||
|
{isOwned && <VisibilityBadge visibility={run.visibility} />}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-text-secondary">
|
||||||
|
Started{' '}
|
||||||
|
{new Date(run.startedAt).toLocaleDateString(undefined, {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
})}
|
||||||
|
{!isOwned && run.owner?.displayName && (
|
||||||
|
<span className="text-text-tertiary"> · by {run.owner.displayName}</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`px-2.5 py-0.5 rounded-full text-xs font-medium capitalize flex-shrink-0 ml-2 ${statusStyles[run.status]}`}
|
||||||
|
>
|
||||||
|
{run.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function RunList() {
|
export function RunList() {
|
||||||
const { data: runs, isLoading, error } = useRuns()
|
const { data: runs, isLoading, error } = useRuns()
|
||||||
|
const { user, loading: authLoading } = useAuth()
|
||||||
|
|
||||||
|
const { myRuns, publicRuns } = useMemo(() => {
|
||||||
|
if (!runs) return { myRuns: [], publicRuns: [] }
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return { myRuns: [], publicRuns: runs }
|
||||||
|
}
|
||||||
|
|
||||||
|
const owned: NuzlockeRun[] = []
|
||||||
|
const others: NuzlockeRun[] = []
|
||||||
|
|
||||||
|
for (const run of runs) {
|
||||||
|
if (run.owner?.id === user.id) {
|
||||||
|
owned.push(run)
|
||||||
|
} else {
|
||||||
|
others.push(run)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { myRuns: owned, publicRuns: others }
|
||||||
|
}, [runs, user])
|
||||||
|
|
||||||
|
const showLoading = isLoading || authLoading
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-4xl mx-auto p-8">
|
<div className="max-w-4xl mx-auto p-8">
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<h1 className="text-3xl font-bold text-text-primary">Your Runs</h1>
|
<h1 className="text-3xl font-bold text-text-primary">
|
||||||
|
{user ? 'Nuzlocke Runs' : 'Public Runs'}
|
||||||
|
</h1>
|
||||||
|
{user && (
|
||||||
<Link
|
<Link
|
||||||
to="/runs/new"
|
to="/runs/new"
|
||||||
className="px-4 py-2 bg-accent-600 text-white rounded-lg font-medium hover:bg-accent-500 focus:outline-none focus:ring-2 focus:ring-accent-400 focus:ring-offset-2 focus:ring-offset-surface-0 transition-all active:scale-[0.98]"
|
className="px-4 py-2 bg-accent-600 text-white rounded-lg font-medium hover:bg-accent-500 focus:outline-none focus:ring-2 focus:ring-accent-400 focus:ring-offset-2 focus:ring-offset-surface-0 transition-all active:scale-[0.98]"
|
||||||
>
|
>
|
||||||
Start New Run
|
Start New Run
|
||||||
</Link>
|
</Link>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isLoading && (
|
{showLoading && (
|
||||||
<div className="flex items-center justify-center py-12">
|
<div className="flex items-center justify-center py-12">
|
||||||
<div className="w-8 h-8 border-4 border-accent-400 border-t-transparent rounded-full animate-spin" />
|
<div className="w-8 h-8 border-4 border-accent-400 border-t-transparent rounded-full animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
@@ -35,49 +110,56 @@ export function RunList() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{runs && runs.length === 0 && (
|
{!showLoading && runs && runs.length === 0 && (
|
||||||
<div className="text-center py-16">
|
<div className="text-center py-16">
|
||||||
<p className="text-lg text-text-secondary mb-4">
|
<p className="text-lg text-text-secondary mb-4">
|
||||||
No runs yet. Start your first Nuzlocke!
|
{user ? 'No runs yet. Start your first Nuzlocke!' : 'No public runs available.'}
|
||||||
</p>
|
</p>
|
||||||
|
{user && (
|
||||||
<Link
|
<Link
|
||||||
to="/runs/new"
|
to="/runs/new"
|
||||||
className="inline-block px-6 py-2 bg-accent-600 text-white rounded-lg font-medium hover:bg-accent-500 transition-all active:scale-[0.98]"
|
className="inline-block px-6 py-2 bg-accent-600 text-white rounded-lg font-medium hover:bg-accent-500 transition-all active:scale-[0.98]"
|
||||||
>
|
>
|
||||||
Start New Run
|
Start New Run
|
||||||
</Link>
|
</Link>
|
||||||
|
)}
|
||||||
|
{!user && (
|
||||||
|
<Link
|
||||||
|
to="/login"
|
||||||
|
className="inline-block px-6 py-2 bg-accent-600 text-white rounded-lg font-medium hover:bg-accent-500 transition-all active:scale-[0.98]"
|
||||||
|
>
|
||||||
|
Sign In to Create Runs
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{runs && runs.length > 0 && (
|
{!showLoading && runs && runs.length > 0 && (
|
||||||
|
<>
|
||||||
|
{user && myRuns.length > 0 && (
|
||||||
|
<div className="mb-8">
|
||||||
|
<h2 className="text-lg font-semibold text-text-primary mb-3">My Runs</h2>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{runs.map((run) => (
|
{myRuns.map((run) => (
|
||||||
<Link
|
<RunCard key={run.id} run={run} isOwned />
|
||||||
key={run.id}
|
|
||||||
to={`/runs/${run.id}`}
|
|
||||||
className="block bg-surface-1 rounded-xl border border-border-default hover:border-border-accent transition-all hover:-translate-y-0.5 p-4"
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-lg font-semibold text-text-primary">{run.name}</h2>
|
|
||||||
<p className="text-sm text-text-secondary">
|
|
||||||
Started{' '}
|
|
||||||
{new Date(run.startedAt).toLocaleDateString(undefined, {
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
className={`px-2.5 py-0.5 rounded-full text-xs font-medium capitalize ${statusStyles[run.status]}`}
|
|
||||||
>
|
|
||||||
{run.status}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{publicRuns.length > 0 && (
|
||||||
|
<div>
|
||||||
|
{user && myRuns.length > 0 && (
|
||||||
|
<h2 className="text-lg font-semibold text-text-primary mb-3">Public Runs</h2>
|
||||||
|
)}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{publicRuns.map((run) => (
|
||||||
|
<RunCard key={run.id} run={run} isOwned={false} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
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 { GenlockeDetail } from './GenlockeDetail'
|
||||||
export { GenlockeList } from './GenlockeList'
|
export { GenlockeList } from './GenlockeList'
|
||||||
export { Home } from './Home'
|
export { Home } from './Home'
|
||||||
export { JournalEntryPage } from './JournalEntryPage'
|
export { JournalEntryPage } from './JournalEntryPage'
|
||||||
|
export { Login } from './Login'
|
||||||
export { NewGenlocke } from './NewGenlocke'
|
export { NewGenlocke } from './NewGenlocke'
|
||||||
export { NewRun } from './NewRun'
|
export { NewRun } from './NewRun'
|
||||||
export { RunList } from './RunList'
|
export { RunList } from './RunList'
|
||||||
export { RunEncounters } from './RunEncounters'
|
export { RunEncounters } from './RunEncounters'
|
||||||
|
export { Signup } from './Signup'
|
||||||
export { Stats } from './Stats'
|
export { Stats } from './Stats'
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
|||||||
import { render, type RenderOptions } from '@testing-library/react'
|
import { render, type RenderOptions } from '@testing-library/react'
|
||||||
import { type ReactElement } from 'react'
|
import { type ReactElement } from 'react'
|
||||||
import { MemoryRouter } from 'react-router-dom'
|
import { MemoryRouter } from 'react-router-dom'
|
||||||
|
import { AuthProvider } from '../contexts/AuthContext'
|
||||||
|
|
||||||
export function createTestQueryClient(): QueryClient {
|
export function createTestQueryClient(): QueryClient {
|
||||||
return new QueryClient({
|
return new QueryClient({
|
||||||
@@ -16,7 +17,9 @@ function AllProviders({ children }: { children: React.ReactNode }) {
|
|||||||
const queryClient = createTestQueryClient()
|
const queryClient = createTestQueryClient()
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<MemoryRouter>{children}</MemoryRouter>
|
<MemoryRouter>
|
||||||
|
<AuthProvider>{children}</AuthProvider>
|
||||||
|
</MemoryRouter>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -182,6 +182,14 @@ export interface BossPokemonInput {
|
|||||||
level: number
|
level: number
|
||||||
order: number
|
order: number
|
||||||
conditionLabel?: string | null
|
conditionLabel?: string | null
|
||||||
|
// Detail fields
|
||||||
|
abilityId?: number | null
|
||||||
|
heldItem?: string | null
|
||||||
|
nature?: string | null
|
||||||
|
move1Id?: number | null
|
||||||
|
move2Id?: number | null
|
||||||
|
move3Id?: number | null
|
||||||
|
move4Id?: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Genlocke admin
|
// Genlocke admin
|
||||||
|
|||||||
@@ -84,6 +84,12 @@ export interface Encounter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type RunStatus = 'active' | 'completed' | 'failed'
|
export type RunStatus = 'active' | 'completed' | 'failed'
|
||||||
|
export type RunVisibility = 'public' | 'private'
|
||||||
|
|
||||||
|
export interface RunOwner {
|
||||||
|
id: string
|
||||||
|
displayName: string | null
|
||||||
|
}
|
||||||
|
|
||||||
export interface NuzlockeRun {
|
export interface NuzlockeRun {
|
||||||
id: number
|
id: number
|
||||||
@@ -93,6 +99,8 @@ export interface NuzlockeRun {
|
|||||||
rules: NuzlockeRules
|
rules: NuzlockeRules
|
||||||
hofEncounterIds: number[] | null
|
hofEncounterIds: number[] | null
|
||||||
namingScheme: string | null
|
namingScheme: string | null
|
||||||
|
visibility: RunVisibility
|
||||||
|
owner: RunOwner | null
|
||||||
startedAt: string
|
startedAt: string
|
||||||
completedAt: string | null
|
completedAt: string | null
|
||||||
}
|
}
|
||||||
@@ -136,6 +144,7 @@ export interface CreateRunInput {
|
|||||||
name: string
|
name: string
|
||||||
rules?: NuzlockeRules
|
rules?: NuzlockeRules
|
||||||
namingScheme?: string | null
|
namingScheme?: string | null
|
||||||
|
visibility?: RunVisibility
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateRunInput {
|
export interface UpdateRunInput {
|
||||||
@@ -144,6 +153,7 @@ export interface UpdateRunInput {
|
|||||||
rules?: NuzlockeRules
|
rules?: NuzlockeRules
|
||||||
hofEncounterIds?: number[]
|
hofEncounterIds?: number[]
|
||||||
namingScheme?: string | null
|
namingScheme?: string | null
|
||||||
|
visibility?: RunVisibility
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateEncounterInput {
|
export interface CreateEncounterInput {
|
||||||
@@ -175,6 +185,16 @@ export type BossType =
|
|||||||
| 'totem'
|
| 'totem'
|
||||||
| 'other'
|
| 'other'
|
||||||
|
|
||||||
|
export interface MoveRef {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AbilityRef {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface BossPokemon {
|
export interface BossPokemon {
|
||||||
id: number
|
id: number
|
||||||
pokemonId: number
|
pokemonId: number
|
||||||
@@ -182,6 +202,19 @@ export interface BossPokemon {
|
|||||||
order: number
|
order: number
|
||||||
conditionLabel: string | null
|
conditionLabel: string | null
|
||||||
pokemon: Pokemon
|
pokemon: Pokemon
|
||||||
|
// Detail fields
|
||||||
|
abilityId: number | null
|
||||||
|
ability: AbilityRef | null
|
||||||
|
heldItem: string | null
|
||||||
|
nature: string | null
|
||||||
|
move1Id: number | null
|
||||||
|
move2Id: number | null
|
||||||
|
move3Id: number | null
|
||||||
|
move4Id: number | null
|
||||||
|
move1: MoveRef | null
|
||||||
|
move2: MoveRef | null
|
||||||
|
move3: MoveRef | null
|
||||||
|
move4: MoveRef | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BossBattle {
|
export interface BossBattle {
|
||||||
@@ -202,6 +235,12 @@ export interface BossBattle {
|
|||||||
pokemon: BossPokemon[]
|
pokemon: BossPokemon[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BossResultTeamMember {
|
||||||
|
id: number
|
||||||
|
encounterId: number
|
||||||
|
level: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface BossResult {
|
export interface BossResult {
|
||||||
id: number
|
id: number
|
||||||
runId: number
|
runId: number
|
||||||
@@ -209,12 +248,19 @@ export interface BossResult {
|
|||||||
result: 'won' | 'lost'
|
result: 'won' | 'lost'
|
||||||
attempts: number
|
attempts: number
|
||||||
completedAt: string | null
|
completedAt: string | null
|
||||||
|
team: BossResultTeamMember[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BossResultTeamMemberInput {
|
||||||
|
encounterId: number
|
||||||
|
level: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateBossResultInput {
|
export interface CreateBossResultInput {
|
||||||
bossBattleId: number
|
bossBattleId: number
|
||||||
result: 'won' | 'lost'
|
result: 'won' | 'lost'
|
||||||
attempts?: number
|
attempts?: number
|
||||||
|
team?: BossResultTeamMemberInput[]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-export for convenience
|
// Re-export for convenience
|
||||||
|
|||||||
Reference in New Issue
Block a user