Restructure into full project layout
This commit is contained in:
@@ -0,0 +1,28 @@
|
|||||||
|
# ── Security ──────────────────────────────────────────────────────────────────
|
||||||
|
# Generate with: openssl rand -hex 32
|
||||||
|
SECRET_KEY=changeme
|
||||||
|
|
||||||
|
# ── Anthropic ─────────────────────────────────────────────────────────────────
|
||||||
|
ANTHROPIC_API_KEY=sk-ant-...
|
||||||
|
ANTHROPIC_MODEL=claude-sonnet-4-6
|
||||||
|
|
||||||
|
# ── Admin bootstrap ───────────────────────────────────────────────────────────
|
||||||
|
ADMIN_EMAIL=admin@example.com
|
||||||
|
ADMIN_PASSWORD=changeme
|
||||||
|
|
||||||
|
# ── Database ──────────────────────────────────────────────────────────────────
|
||||||
|
POSTGRES_USER=mtg
|
||||||
|
POSTGRES_PASSWORD=changeme
|
||||||
|
POSTGRES_DB=mtg
|
||||||
|
DATABASE_URL=postgresql+asyncpg://mtg:changeme@db:5432/mtg
|
||||||
|
|
||||||
|
# ── Redis ─────────────────────────────────────────────────────────────────────
|
||||||
|
REDIS_URL=redis://cache:6379
|
||||||
|
|
||||||
|
# ── Scryfall ──────────────────────────────────────────────────────────────────
|
||||||
|
SCRYFALL_RATE_LIMIT_RPS=8
|
||||||
|
SCRYFALL_CACHE_TTL_SECONDS=86400
|
||||||
|
|
||||||
|
# ── Domain (for SSL setup) ────────────────────────────────────────────────────
|
||||||
|
DOMAIN=yourdomain.com
|
||||||
|
CERTBOT_EMAIL=you@example.com
|
||||||
+6
-2
@@ -7,15 +7,19 @@ __pycache__/
|
|||||||
*.pyo
|
*.pyo
|
||||||
.venv/
|
.venv/
|
||||||
venv/
|
venv/
|
||||||
|
*.egg-info/
|
||||||
|
|
||||||
# Frontend build
|
# Frontend build
|
||||||
frontend/node_modules/
|
frontend/node_modules/
|
||||||
frontend/dist/
|
frontend/dist/
|
||||||
|
|
||||||
# Logs & misc
|
# Logs
|
||||||
*.log
|
*.log
|
||||||
.DS_Store
|
|
||||||
|
|
||||||
# SSL certs (generated on server)
|
# SSL certs (generated on server)
|
||||||
certbot/
|
certbot/
|
||||||
nginx/certbot/
|
nginx/certbot/
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Commander Forge
|
||||||
|
|
||||||
|
AI-powered Magic: The Gathering Commander deck builder.
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
- **Backend**: FastAPI (Python 3.12) + PostgreSQL + Redis
|
||||||
|
- **Frontend**: React 18 + TypeScript + Vite + Tailwind
|
||||||
|
- **AI**: Anthropic Claude (deck generation)
|
||||||
|
- **Infrastructure**: Docker Compose + Nginx
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
# Fill in .env with your values
|
||||||
|
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Open `http://localhost` and sign in with your `ADMIN_EMAIL` / `ADMIN_PASSWORD`.
|
||||||
|
|
||||||
|
## Environment variables
|
||||||
|
|
||||||
|
See `.env.example` for all required variables.
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install system dependencies
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
gcc \
|
||||||
|
libpq-dev \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Install Python dependencies
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Copy application code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Run migrations then start server
|
||||||
|
CMD ["sh", "-c", "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000"]
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
[alembic]
|
||||||
|
script_location = alembic
|
||||||
|
prepend_sys_path = .
|
||||||
|
sqlalchemy.url = driver://user:pass@localhost/dbname
|
||||||
|
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level = WARN
|
||||||
|
handlers = console
|
||||||
|
qualname =
|
||||||
|
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = WARN
|
||||||
|
handlers =
|
||||||
|
qualname = sqlalchemy.engine
|
||||||
|
|
||||||
|
[logger_alembic]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = alembic
|
||||||
|
|
||||||
|
[handler_console]
|
||||||
|
class = StreamHandler
|
||||||
|
args = (sys.stderr,)
|
||||||
|
level = NOTSET
|
||||||
|
formatter = generic
|
||||||
|
|
||||||
|
[formatter_generic]
|
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||||
|
datefmt = %H:%M:%S
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import asyncio
|
||||||
|
from logging.config import fileConfig
|
||||||
|
from sqlalchemy import pool
|
||||||
|
from sqlalchemy.engine import Connection
|
||||||
|
from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||||
|
from alembic import context
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.database import Base
|
||||||
|
import app.models # noqa: F401 — ensure all models are imported
|
||||||
|
|
||||||
|
config = context.config
|
||||||
|
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL)
|
||||||
|
|
||||||
|
if config.config_file_name is not None:
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
|
||||||
|
target_metadata = Base.metadata
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline() -> None:
|
||||||
|
url = config.get_main_option("sqlalchemy.url")
|
||||||
|
context.configure(url=url, target_metadata=target_metadata, literal_binds=True)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def do_run_migrations(connection: Connection) -> None:
|
||||||
|
context.configure(connection=connection, target_metadata=target_metadata)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
async def run_async_migrations() -> None:
|
||||||
|
connectable = async_engine_from_config(
|
||||||
|
config.get_section(config.config_ini_section, {}),
|
||||||
|
prefix="sqlalchemy.",
|
||||||
|
poolclass=pool.NullPool,
|
||||||
|
)
|
||||||
|
async with connectable.connect() as connection:
|
||||||
|
await connection.run_sync(do_run_migrations)
|
||||||
|
await connectable.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_online() -> None:
|
||||||
|
asyncio.run(run_async_migrations())
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
run_migrations_online()
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
"""initial schema
|
||||||
|
|
||||||
|
Revision ID: 0001
|
||||||
|
Revises:
|
||||||
|
Create Date: 2024-01-01 00:00:00.000000
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = '0001'
|
||||||
|
down_revision = None
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
'users',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('email', sa.String(255), nullable=False),
|
||||||
|
sa.Column('hashed_password', sa.String(255), nullable=False),
|
||||||
|
sa.Column('display_name', sa.String(100), nullable=True),
|
||||||
|
sa.Column('role', sa.Enum('pending', 'approved', 'admin', name='userrole'), nullable=False, server_default='pending'),
|
||||||
|
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true'),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('email'),
|
||||||
|
)
|
||||||
|
op.create_index('ix_users_email', 'users', ['email'])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
'decks',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('owner_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('name', sa.String(200), nullable=False),
|
||||||
|
sa.Column('commander', sa.String(200), nullable=False),
|
||||||
|
sa.Column('description', sa.Text(), nullable=True),
|
||||||
|
sa.Column('mode', sa.Enum('generate', 'complete', 'cull', name='deckmode'), nullable=False),
|
||||||
|
sa.Column('playstyle', sa.String(100), nullable=True),
|
||||||
|
sa.Column('prefer_owned', sa.Boolean(), nullable=False, server_default='false'),
|
||||||
|
sa.Column('budget_enabled', sa.Boolean(), nullable=False, server_default='false'),
|
||||||
|
sa.Column('budget_amount', sa.Float(), nullable=True),
|
||||||
|
sa.Column('budget_scope', sa.String(20), nullable=False, server_default='purchase'),
|
||||||
|
sa.Column('ai_reasoning', sa.JSON(), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.ForeignKeyConstraint(['owner_id'], ['users.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
)
|
||||||
|
op.create_index('ix_decks_owner_id', 'decks', ['owner_id'])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
'deck_cards',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('deck_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('scryfall_id', sa.String(36), nullable=False),
|
||||||
|
sa.Column('card_name', sa.String(200), nullable=False),
|
||||||
|
sa.Column('slot', sa.Enum('creature', 'instant', 'sorcery', 'enchantment', 'artifact', 'planeswalker', 'land', 'battle', name='cardslot'), nullable=False),
|
||||||
|
sa.Column('quantity', sa.Integer(), nullable=False, server_default='1'),
|
||||||
|
sa.Column('is_owned', sa.Boolean(), nullable=False, server_default='false'),
|
||||||
|
sa.Column('is_commander', sa.Boolean(), nullable=False, server_default='false'),
|
||||||
|
sa.Column('ai_reasoning', sa.Text(), nullable=True),
|
||||||
|
sa.Column('scryfall_data', sa.JSON(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['deck_id'], ['decks.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
)
|
||||||
|
op.create_index('ix_deck_cards_deck_id', 'deck_cards', ['deck_id'])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
'user_collection',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('owner_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('card_name', sa.String(200), nullable=False),
|
||||||
|
sa.Column('set_code', sa.String(10), nullable=False, server_default=''),
|
||||||
|
sa.Column('collector_number', sa.String(20), nullable=False, server_default=''),
|
||||||
|
sa.Column('quantity', sa.Integer(), nullable=False, server_default='0'),
|
||||||
|
sa.Column('foil_quantity', sa.Integer(), nullable=False, server_default='0'),
|
||||||
|
sa.Column('scryfall_id', sa.String(36), nullable=False, server_default=''),
|
||||||
|
sa.Column('scryfall_data', sa.JSON(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['owner_id'], ['users.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('owner_id', 'scryfall_id', name='uq_owner_scryfall'),
|
||||||
|
)
|
||||||
|
op.create_index('ix_user_collection_owner_id', 'user_collection', ['owner_id'])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table('user_collection')
|
||||||
|
op.drop_table('deck_cards')
|
||||||
|
op.drop_table('decks')
|
||||||
|
op.drop_table('users')
|
||||||
|
op.execute("DROP TYPE IF EXISTS userrole")
|
||||||
|
op.execute("DROP TYPE IF EXISTS deckmode")
|
||||||
|
op.execute("DROP TYPE IF EXISTS cardslot")
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.deps import get_admin_user
|
||||||
|
from app.models.user import User, UserRole
|
||||||
|
from app.schemas.user import UserOut, AdminUserUpdate
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/queue", response_model=list[UserOut])
|
||||||
|
async def get_queue(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_: User = Depends(get_admin_user),
|
||||||
|
):
|
||||||
|
result = await db.execute(select(User).where(User.role == UserRole.PENDING))
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/users", response_model=list[UserOut])
|
||||||
|
async def list_users(
|
||||||
|
role: str | None = None,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_: User = Depends(get_admin_user),
|
||||||
|
):
|
||||||
|
query = select(User)
|
||||||
|
if role:
|
||||||
|
query = query.where(User.role == role)
|
||||||
|
result = await db.execute(query)
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/users/{user_id}/approve", response_model=UserOut)
|
||||||
|
async def approve_user(
|
||||||
|
user_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_: User = Depends(get_admin_user),
|
||||||
|
):
|
||||||
|
result = await db.execute(select(User).where(User.id == user_id))
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
user.role = UserRole.APPROVED
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(user)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/users/{user_id}/deny", response_model=UserOut)
|
||||||
|
async def deny_user(
|
||||||
|
user_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_: User = Depends(get_admin_user),
|
||||||
|
):
|
||||||
|
result = await db.execute(select(User).where(User.id == user_id))
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
user.is_active = False
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(user)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/users/{user_id}", response_model=UserOut)
|
||||||
|
async def update_user(
|
||||||
|
user_id: int,
|
||||||
|
data: AdminUserUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_: User = Depends(get_admin_user),
|
||||||
|
):
|
||||||
|
result = await db.execute(select(User).where(User.id == user_id))
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
if data.role is not None:
|
||||||
|
user.role = data.role
|
||||||
|
if data.is_active is not None:
|
||||||
|
user.is_active = data.is_active
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(user)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/users/{user_id}", status_code=204)
|
||||||
|
async def delete_user(
|
||||||
|
user_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_: User = Depends(get_admin_user),
|
||||||
|
):
|
||||||
|
result = await db.execute(select(User).where(User.id == user_id))
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
await db.delete(user)
|
||||||
|
await db.commit()
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.security import hash_password, verify_password, create_access_token, create_refresh_token, decode_token
|
||||||
|
from app.models.user import User, UserRole
|
||||||
|
from app.schemas.user import UserRegister, UserLogin, TokenOut, RefreshRequest, UserOut
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/register", response_model=UserOut, status_code=201)
|
||||||
|
async def register(data: UserRegister, db: AsyncSession = Depends(get_db)):
|
||||||
|
result = await db.execute(select(User).where(User.email == data.email))
|
||||||
|
if result.scalar_one_or_none():
|
||||||
|
raise HTTPException(status_code=400, detail="Email already registered")
|
||||||
|
|
||||||
|
user = User(
|
||||||
|
email=data.email,
|
||||||
|
hashed_password=hash_password(data.password),
|
||||||
|
display_name=data.display_name,
|
||||||
|
role=UserRole.PENDING,
|
||||||
|
)
|
||||||
|
db.add(user)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(user)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login", response_model=TokenOut)
|
||||||
|
async def login(data: UserLogin, db: AsyncSession = Depends(get_db)):
|
||||||
|
result = await db.execute(select(User).where(User.email == data.email))
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
if not user or not verify_password(data.password, user.hashed_password):
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid credentials")
|
||||||
|
if not user.is_active:
|
||||||
|
raise HTTPException(status_code=403, detail="Account deactivated")
|
||||||
|
|
||||||
|
return TokenOut(
|
||||||
|
access_token=create_access_token(user.id),
|
||||||
|
refresh_token=create_refresh_token(user.id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/refresh", response_model=TokenOut)
|
||||||
|
async def refresh(data: RefreshRequest, db: AsyncSession = Depends(get_db)):
|
||||||
|
user_id = decode_token(data.refresh_token)
|
||||||
|
if not user_id:
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid refresh token")
|
||||||
|
|
||||||
|
result = await db.execute(select(User).where(User.id == user_id))
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
if not user or not user.is_active:
|
||||||
|
raise HTTPException(status_code=401, detail="User not found")
|
||||||
|
|
||||||
|
return TokenOut(
|
||||||
|
access_token=create_access_token(user.id),
|
||||||
|
refresh_token=create_refresh_token(user.id),
|
||||||
|
)
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select, func, delete
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.deps import get_approved_user
|
||||||
|
from app.models.user import User
|
||||||
|
from app.models.collection import CollectionCard
|
||||||
|
from app.schemas.collection import CollectionCardOut, CollectionStatsOut, PaginatedCollection
|
||||||
|
from app.services.imports import archidekt, manabox
|
||||||
|
from app.services.imports.enrichment import enrich_and_upsert
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/import/archidekt", status_code=201)
|
||||||
|
async def import_archidekt(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
replace: bool = False,
|
||||||
|
user: User = Depends(get_approved_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
raw = await file.read()
|
||||||
|
rows = archidekt.parse(raw)
|
||||||
|
if replace:
|
||||||
|
await db.execute(delete(CollectionCard).where(CollectionCard.owner_id == user.id))
|
||||||
|
await db.commit()
|
||||||
|
imported = await enrich_and_upsert(rows, user.id, db)
|
||||||
|
return {"imported": imported}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/import/manabox", status_code=201)
|
||||||
|
async def import_manabox(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
replace: bool = False,
|
||||||
|
user: User = Depends(get_approved_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
raw = await file.read()
|
||||||
|
rows = manabox.parse(raw)
|
||||||
|
if replace:
|
||||||
|
await db.execute(delete(CollectionCard).where(CollectionCard.owner_id == user.id))
|
||||||
|
await db.commit()
|
||||||
|
imported = await enrich_and_upsert(rows, user.id, db)
|
||||||
|
return {"imported": imported}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=PaginatedCollection)
|
||||||
|
async def list_collection(
|
||||||
|
search: str = "",
|
||||||
|
page: int = 1,
|
||||||
|
page_size: int = 50,
|
||||||
|
user: User = Depends(get_approved_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
query = select(CollectionCard).where(CollectionCard.owner_id == user.id)
|
||||||
|
if search:
|
||||||
|
query = query.where(CollectionCard.card_name.ilike(f"%{search}%"))
|
||||||
|
|
||||||
|
total_result = await db.execute(
|
||||||
|
select(func.count()).select_from(query.subquery())
|
||||||
|
)
|
||||||
|
total = total_result.scalar_one()
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
query.order_by(CollectionCard.card_name)
|
||||||
|
.offset((page - 1) * page_size)
|
||||||
|
.limit(page_size)
|
||||||
|
)
|
||||||
|
items = result.scalars().all()
|
||||||
|
|
||||||
|
return PaginatedCollection(
|
||||||
|
items=items, total=total, page=page, page_size=page_size,
|
||||||
|
pages=max(1, -(-total // page_size)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/stats", response_model=CollectionStatsOut)
|
||||||
|
async def collection_stats(
|
||||||
|
user: User = Depends(get_approved_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
result = await db.execute(
|
||||||
|
select(CollectionCard).where(CollectionCard.owner_id == user.id)
|
||||||
|
)
|
||||||
|
cards = result.scalars().all()
|
||||||
|
|
||||||
|
total = sum(c.quantity + c.foil_quantity for c in cards)
|
||||||
|
unique = len(cards)
|
||||||
|
foil = sum(c.foil_quantity for c in cards)
|
||||||
|
|
||||||
|
value = None
|
||||||
|
prices = []
|
||||||
|
for c in cards:
|
||||||
|
if c.scryfall_data:
|
||||||
|
p = c.scryfall_data.get("prices", {}).get("usd")
|
||||||
|
if p:
|
||||||
|
prices.append(float(p) * (c.quantity + c.foil_quantity))
|
||||||
|
if prices:
|
||||||
|
value = sum(prices)
|
||||||
|
|
||||||
|
return CollectionStatsOut(
|
||||||
|
total_cards=total, unique_cards=unique, foil_cards=foil, estimated_value=value
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/", status_code=204)
|
||||||
|
async def clear_collection(
|
||||||
|
user: User = Depends(get_approved_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
await db.execute(delete(CollectionCard).where(CollectionCard.owner_id == user.id))
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{card_id}", status_code=204)
|
||||||
|
async def delete_card(
|
||||||
|
card_id: int,
|
||||||
|
user: User = Depends(get_approved_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
result = await db.execute(
|
||||||
|
select(CollectionCard).where(CollectionCard.id == card_id, CollectionCard.owner_id == user.id)
|
||||||
|
)
|
||||||
|
card = result.scalar_one_or_none()
|
||||||
|
if not card:
|
||||||
|
raise HTTPException(status_code=404, detail="Card not found")
|
||||||
|
await db.delete(card)
|
||||||
|
await db.commit()
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select, func
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.deps import get_approved_user
|
||||||
|
from app.models.user import User
|
||||||
|
from app.models.deck import Deck, DeckCard
|
||||||
|
from app.schemas.deck import DeckOut, DeckSummaryOut, GenerateRequest, CompleteRequest, CullRequest
|
||||||
|
from app.services.ai.deck_service import generate_deck, complete_deck, cull_deck
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/generate", response_model=DeckOut, status_code=201)
|
||||||
|
async def api_generate(
|
||||||
|
req: GenerateRequest,
|
||||||
|
user: User = Depends(get_approved_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
return await generate_deck(req, user.id, db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/complete", response_model=DeckOut, status_code=201)
|
||||||
|
async def api_complete(
|
||||||
|
req: CompleteRequest,
|
||||||
|
user: User = Depends(get_approved_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
return await complete_deck(req, user.id, db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/cull", response_model=DeckOut, status_code=201)
|
||||||
|
async def api_cull(
|
||||||
|
req: CullRequest,
|
||||||
|
user: User = Depends(get_approved_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
return await cull_deck(req, user.id, db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=dict)
|
||||||
|
async def list_decks(
|
||||||
|
page: int = 1,
|
||||||
|
page_size: int = 20,
|
||||||
|
user: User = Depends(get_approved_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
total_result = await db.execute(
|
||||||
|
select(func.count(Deck.id)).where(Deck.owner_id == user.id)
|
||||||
|
)
|
||||||
|
total = total_result.scalar_one()
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
select(Deck)
|
||||||
|
.where(Deck.owner_id == user.id)
|
||||||
|
.order_by(Deck.created_at.desc())
|
||||||
|
.offset((page - 1) * page_size)
|
||||||
|
.limit(page_size)
|
||||||
|
)
|
||||||
|
decks = result.scalars().all()
|
||||||
|
|
||||||
|
items = []
|
||||||
|
for deck in decks:
|
||||||
|
count_result = await db.execute(
|
||||||
|
select(func.count(DeckCard.id)).where(DeckCard.deck_id == deck.id)
|
||||||
|
)
|
||||||
|
items.append(DeckSummaryOut(
|
||||||
|
id=deck.id, name=deck.name, commander=deck.commander,
|
||||||
|
mode=deck.mode, playstyle=deck.playstyle, created_at=deck.created_at,
|
||||||
|
card_count=count_result.scalar_one(),
|
||||||
|
))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"items": [i.model_dump() for i in items],
|
||||||
|
"total": total,
|
||||||
|
"page": page,
|
||||||
|
"page_size": page_size,
|
||||||
|
"pages": max(1, -(-total // page_size)),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{deck_id}", response_model=DeckOut)
|
||||||
|
async def get_deck(
|
||||||
|
deck_id: int,
|
||||||
|
user: User = Depends(get_approved_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
result = await db.execute(select(Deck).where(Deck.id == deck_id, Deck.owner_id == user.id))
|
||||||
|
deck = result.scalar_one_or_none()
|
||||||
|
if not deck:
|
||||||
|
raise HTTPException(status_code=404, detail="Deck not found")
|
||||||
|
|
||||||
|
cards_result = await db.execute(select(DeckCard).where(DeckCard.deck_id == deck_id))
|
||||||
|
deck.cards = cards_result.scalars().all()
|
||||||
|
return deck
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{deck_id}", status_code=204)
|
||||||
|
async def delete_deck(
|
||||||
|
deck_id: int,
|
||||||
|
user: User = Depends(get_approved_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
result = await db.execute(select(Deck).where(Deck.id == deck_id, Deck.owner_id == user.id))
|
||||||
|
deck = result.scalar_one_or_none()
|
||||||
|
if not deck:
|
||||||
|
raise HTTPException(status_code=404, detail="Deck not found")
|
||||||
|
await db.delete(deck)
|
||||||
|
await db.commit()
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.deps import get_approved_user
|
||||||
|
from app.core.security import hash_password
|
||||||
|
from app.models.user import User
|
||||||
|
from app.schemas.user import UserOut, UserUpdate
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me", response_model=UserOut)
|
||||||
|
async def get_me(user: User = Depends(get_approved_user)):
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/me", response_model=UserOut)
|
||||||
|
async def update_me(
|
||||||
|
data: UserUpdate,
|
||||||
|
user: User = Depends(get_approved_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
if data.display_name is not None:
|
||||||
|
user.display_name = data.display_name
|
||||||
|
if data.password:
|
||||||
|
user.hashed_password = hash_password(data.password)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(user)
|
||||||
|
return user
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
from sqlalchemy import select
|
||||||
|
from app.core.database import AsyncSessionLocal
|
||||||
|
from app.core.security import hash_password
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.models.user import User, UserRole
|
||||||
|
|
||||||
|
|
||||||
|
async def bootstrap_admin():
|
||||||
|
async with AsyncSessionLocal() as db:
|
||||||
|
result = await db.execute(select(User).where(User.email == settings.ADMIN_EMAIL))
|
||||||
|
if result.scalar_one_or_none():
|
||||||
|
return
|
||||||
|
|
||||||
|
admin = User(
|
||||||
|
email=settings.ADMIN_EMAIL,
|
||||||
|
hashed_password=hash_password(settings.ADMIN_PASSWORD),
|
||||||
|
display_name="Admin",
|
||||||
|
role=UserRole.ADMIN,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add(admin)
|
||||||
|
await db.commit()
|
||||||
|
print(f"Admin user created: {settings.ADMIN_EMAIL}")
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
from pydantic_settings import BaseSettings
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
SECRET_KEY: str
|
||||||
|
ALGORITHM: str = "HS256"
|
||||||
|
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60
|
||||||
|
REFRESH_TOKEN_EXPIRE_DAYS: int = 30
|
||||||
|
|
||||||
|
ANTHROPIC_API_KEY: str
|
||||||
|
ANTHROPIC_MODEL: str = "claude-sonnet-4-6"
|
||||||
|
|
||||||
|
ADMIN_EMAIL: str
|
||||||
|
ADMIN_PASSWORD: str
|
||||||
|
|
||||||
|
DATABASE_URL: str
|
||||||
|
REDIS_URL: str = "redis://cache:6379"
|
||||||
|
|
||||||
|
SCRYFALL_RATE_LIMIT_RPS: int = 8
|
||||||
|
SCRYFALL_CACHE_TTL_SECONDS: int = 86400
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
env_file = ".env"
|
||||||
|
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
|
||||||
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
engine = create_async_engine(settings.DATABASE_URL, echo=False)
|
||||||
|
AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False)
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def get_db() -> AsyncSession:
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
yield session
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
from fastapi import Depends, HTTPException, status
|
||||||
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.security import decode_token
|
||||||
|
from app.models.user import User, UserRole
|
||||||
|
|
||||||
|
bearer = HTTPBearer()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_user(
|
||||||
|
credentials: HTTPAuthorizationCredentials = Depends(bearer),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> User:
|
||||||
|
user_id = decode_token(credentials.credentials)
|
||||||
|
if not user_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
|
||||||
|
|
||||||
|
result = await db.execute(select(User).where(User.id == user_id))
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
if not user or not user.is_active:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
async def get_approved_user(user: User = Depends(get_current_user)) -> User:
|
||||||
|
if user.role == UserRole.PENDING:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Account pending approval")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
async def get_admin_user(user: User = Depends(get_approved_user)) -> User:
|
||||||
|
if user.role != UserRole.ADMIN:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
|
||||||
|
return user
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import redis.asyncio as redis
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
_redis = None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_redis():
|
||||||
|
global _redis
|
||||||
|
if _redis is None:
|
||||||
|
_redis = redis.from_url(settings.REDIS_URL, decode_responses=True)
|
||||||
|
return _redis
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from jose import JWTError, jwt
|
||||||
|
from passlib.context import CryptContext
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||||
|
|
||||||
|
|
||||||
|
def hash_password(password: str) -> str:
|
||||||
|
return pwd_context.hash(password)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(plain: str, hashed: str) -> bool:
|
||||||
|
return pwd_context.verify(plain, hashed)
|
||||||
|
|
||||||
|
|
||||||
|
def create_access_token(subject: int) -> str:
|
||||||
|
expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||||
|
return jwt.encode(
|
||||||
|
{"sub": str(subject), "exp": expire, "type": "access"},
|
||||||
|
settings.SECRET_KEY,
|
||||||
|
algorithm=settings.ALGORITHM,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def create_refresh_token(subject: int) -> str:
|
||||||
|
expire = datetime.utcnow() + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
|
||||||
|
return jwt.encode(
|
||||||
|
{"sub": str(subject), "exp": expire, "type": "refresh"},
|
||||||
|
settings.SECRET_KEY,
|
||||||
|
algorithm=settings.ALGORITHM,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def decode_token(token: str) -> Optional[int]:
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
|
||||||
|
return int(payload["sub"])
|
||||||
|
except (JWTError, KeyError, ValueError):
|
||||||
|
return None
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
|
from app.core.database import engine, Base
|
||||||
|
from app.core.admin_bootstrap import bootstrap_admin
|
||||||
|
from app.api.routes import auth, users, admin, decks, collection
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
await bootstrap_admin()
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(title="MTG Deck Builder", version="1.0.0", lifespan=lifespan)
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"],
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
|
||||||
|
app.include_router(users.router, prefix="/api/users", tags=["users"])
|
||||||
|
app.include_router(admin.router, prefix="/api/admin", tags=["admin"])
|
||||||
|
app.include_router(decks.router, prefix="/api/decks", tags=["decks"])
|
||||||
|
app.include_router(collection.router, prefix="/api/collection", tags=["collection"])
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/health")
|
||||||
|
async def health():
|
||||||
|
return {"status": "ok"}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from app.models.user import User, UserRole
|
||||||
|
from app.models.deck import Deck, DeckCard, DeckMode, CardSlot
|
||||||
|
from app.models.collection import CollectionCard
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
from sqlalchemy import String, Integer, ForeignKey, JSON, UniqueConstraint
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class CollectionCard(Base):
|
||||||
|
__tablename__ = "user_collection"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
owner_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
|
card_name: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||||
|
set_code: Mapped[str] = mapped_column(String(10), nullable=False, default="")
|
||||||
|
collector_number: Mapped[str] = mapped_column(String(20), nullable=False, default="")
|
||||||
|
quantity: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
foil_quantity: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
scryfall_id: Mapped[str] = mapped_column(String(36), nullable=False, default="")
|
||||||
|
scryfall_data: Mapped[dict | None] = mapped_column(JSON)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("owner_id", "scryfall_id", name="uq_owner_scryfall"),
|
||||||
|
)
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import enum
|
||||||
|
from datetime import datetime
|
||||||
|
from sqlalchemy import String, Boolean, Enum, DateTime, Integer, Float, ForeignKey, JSON, Text
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class DeckMode(str, enum.Enum):
|
||||||
|
GENERATE = "generate"
|
||||||
|
COMPLETE = "complete"
|
||||||
|
CULL = "cull"
|
||||||
|
|
||||||
|
|
||||||
|
class CardSlot(str, enum.Enum):
|
||||||
|
CREATURE = "creature"
|
||||||
|
INSTANT = "instant"
|
||||||
|
SORCERY = "sorcery"
|
||||||
|
ENCHANTMENT = "enchantment"
|
||||||
|
ARTIFACT = "artifact"
|
||||||
|
PLANESWALKER = "planeswalker"
|
||||||
|
LAND = "land"
|
||||||
|
BATTLE = "battle"
|
||||||
|
|
||||||
|
|
||||||
|
class Deck(Base):
|
||||||
|
__tablename__ = "decks"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
owner_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||||
|
commander: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text)
|
||||||
|
mode: Mapped[DeckMode] = mapped_column(Enum(DeckMode), nullable=False)
|
||||||
|
playstyle: Mapped[str | None] = mapped_column(String(100))
|
||||||
|
prefer_owned: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
budget_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
budget_amount: Mapped[float | None] = mapped_column(Float)
|
||||||
|
budget_scope: Mapped[str] = mapped_column(String(20), default="purchase")
|
||||||
|
ai_reasoning: Mapped[dict | None] = mapped_column(JSON)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class DeckCard(Base):
|
||||||
|
__tablename__ = "deck_cards"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
deck_id: Mapped[int] = mapped_column(ForeignKey("decks.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
|
scryfall_id: Mapped[str] = mapped_column(String(36), nullable=False)
|
||||||
|
card_name: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||||
|
slot: Mapped[CardSlot] = mapped_column(Enum(CardSlot), nullable=False)
|
||||||
|
quantity: Mapped[int] = mapped_column(Integer, default=1)
|
||||||
|
is_owned: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
is_commander: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
ai_reasoning: Mapped[str | None] = mapped_column(Text)
|
||||||
|
scryfall_data: Mapped[dict | None] = mapped_column(JSON)
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import enum
|
||||||
|
from datetime import datetime
|
||||||
|
from sqlalchemy import String, Boolean, Enum, DateTime
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class UserRole(str, enum.Enum):
|
||||||
|
PENDING = "pending"
|
||||||
|
APPROVED = "approved"
|
||||||
|
ADMIN = "admin"
|
||||||
|
|
||||||
|
|
||||||
|
class User(Base):
|
||||||
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
|
||||||
|
hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
display_name: Mapped[str | None] = mapped_column(String(100))
|
||||||
|
role: Mapped[UserRole] = mapped_column(Enum(UserRole), default=UserRole.PENDING, nullable=False)
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class CollectionCardOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
card_name: str
|
||||||
|
set_code: str
|
||||||
|
collector_number: str
|
||||||
|
quantity: int
|
||||||
|
foil_quantity: int
|
||||||
|
scryfall_id: str
|
||||||
|
scryfall_data: dict | None
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class CollectionStatsOut(BaseModel):
|
||||||
|
total_cards: int
|
||||||
|
unique_cards: int
|
||||||
|
foil_cards: int
|
||||||
|
estimated_value: float | None
|
||||||
|
|
||||||
|
|
||||||
|
class PaginatedCollection(BaseModel):
|
||||||
|
items: list[CollectionCardOut]
|
||||||
|
total: int
|
||||||
|
page: int
|
||||||
|
page_size: int
|
||||||
|
pages: int
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from app.models.deck import DeckMode, CardSlot
|
||||||
|
|
||||||
|
|
||||||
|
class DeckConstraints(BaseModel):
|
||||||
|
prefer_owned: bool = False
|
||||||
|
budget_enabled: bool = False
|
||||||
|
budget_amount: float | None = None
|
||||||
|
budget_scope: str = "purchase"
|
||||||
|
|
||||||
|
|
||||||
|
class ExistingCard(BaseModel):
|
||||||
|
card_name: str
|
||||||
|
slot: str | None = None
|
||||||
|
quantity: int = 1
|
||||||
|
|
||||||
|
|
||||||
|
class GenerateRequest(BaseModel):
|
||||||
|
commander: str
|
||||||
|
playstyle: str | None = None
|
||||||
|
name: str | None = None
|
||||||
|
description: str | None = None
|
||||||
|
constraints: DeckConstraints = DeckConstraints()
|
||||||
|
|
||||||
|
|
||||||
|
class CompleteRequest(BaseModel):
|
||||||
|
commander: str
|
||||||
|
playstyle: str | None = None
|
||||||
|
name: str | None = None
|
||||||
|
existing_cards: list[ExistingCard]
|
||||||
|
constraints: DeckConstraints = DeckConstraints()
|
||||||
|
|
||||||
|
|
||||||
|
class CullRequest(BaseModel):
|
||||||
|
commander: str
|
||||||
|
name: str | None = None
|
||||||
|
existing_cards: list[ExistingCard]
|
||||||
|
target_count: int = 99
|
||||||
|
constraints: DeckConstraints = DeckConstraints()
|
||||||
|
|
||||||
|
|
||||||
|
class DeckCardOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
deck_id: int
|
||||||
|
card_name: str
|
||||||
|
slot: CardSlot
|
||||||
|
quantity: int
|
||||||
|
is_owned: bool
|
||||||
|
is_commander: bool
|
||||||
|
ai_reasoning: str | None
|
||||||
|
scryfall_id: str
|
||||||
|
scryfall_data: dict | None
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class DeckOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
owner_id: int
|
||||||
|
name: str
|
||||||
|
commander: str
|
||||||
|
description: str | None
|
||||||
|
mode: DeckMode
|
||||||
|
playstyle: str | None
|
||||||
|
prefer_owned: bool
|
||||||
|
budget_enabled: bool
|
||||||
|
budget_amount: float | None
|
||||||
|
budget_scope: str
|
||||||
|
ai_reasoning: dict | None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
cards: list[DeckCardOut] = []
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class DeckSummaryOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
commander: str
|
||||||
|
mode: DeckMode
|
||||||
|
playstyle: str | None
|
||||||
|
created_at: datetime
|
||||||
|
card_count: int | None = None
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from pydantic import BaseModel, EmailStr
|
||||||
|
from app.models.user import UserRole
|
||||||
|
|
||||||
|
|
||||||
|
class UserRegister(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
password: str
|
||||||
|
display_name: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class UserLogin(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
class UserUpdate(BaseModel):
|
||||||
|
display_name: str | None = None
|
||||||
|
password: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class UserOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
email: str
|
||||||
|
display_name: str | None
|
||||||
|
role: UserRole
|
||||||
|
is_active: bool
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class TokenOut(BaseModel):
|
||||||
|
access_token: str
|
||||||
|
refresh_token: str
|
||||||
|
token_type: str = "bearer"
|
||||||
|
|
||||||
|
|
||||||
|
class RefreshRequest(BaseModel):
|
||||||
|
refresh_token: str
|
||||||
|
|
||||||
|
|
||||||
|
class AdminUserUpdate(BaseModel):
|
||||||
|
role: UserRole | None = None
|
||||||
|
is_active: bool | None = None
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
from app.schemas.deck import DeckConstraints
|
||||||
|
|
||||||
|
|
||||||
|
def build_constraint_context(constraints: DeckConstraints, owned_names: list[str] | None) -> str:
|
||||||
|
lines = []
|
||||||
|
|
||||||
|
if constraints.prefer_owned and owned_names:
|
||||||
|
lines.append("- Prefer cards the user already owns (marked [OWNED] in the list below)")
|
||||||
|
lines.append("- Mark any recommended card the user does NOT own with [UNOWNED] suffix")
|
||||||
|
|
||||||
|
if constraints.budget_enabled and constraints.budget_amount:
|
||||||
|
scope = "total deck" if constraints.budget_scope == "total" else "cards to purchase"
|
||||||
|
lines.append(f"- Budget limit: ${constraints.budget_amount:.2f} for {scope}")
|
||||||
|
|
||||||
|
if not lines:
|
||||||
|
lines.append("- No special constraints — recommend the strongest cards available")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def build_owned_card_list(owned_names: list[str]) -> str:
|
||||||
|
if not owned_names:
|
||||||
|
return ""
|
||||||
|
card_list = "\n".join(f"- {name}" for name in sorted(owned_names))
|
||||||
|
return f"\nOWNED CARDS:\n{card_list}\n"
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.dialects.postgresql import insert
|
||||||
|
|
||||||
|
from app.models.collection import CollectionCard
|
||||||
|
from app.services.imports.models import RawCardRow
|
||||||
|
from app.services import scryfall
|
||||||
|
|
||||||
|
|
||||||
|
async def enrich_and_upsert(rows: list[RawCardRow], user_id: int, db: AsyncSession) -> int:
|
||||||
|
# Try set+collector first for exact matches
|
||||||
|
enriched = {}
|
||||||
|
name_lookup = []
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
if row.set_code and row.collector_number:
|
||||||
|
card = await scryfall.get_card_by_set_and_collector(row.set_code, row.collector_number)
|
||||||
|
if card:
|
||||||
|
enriched[row.card_name.lower()] = card
|
||||||
|
continue
|
||||||
|
name_lookup.append(row.card_name)
|
||||||
|
|
||||||
|
# Batch enrich remaining by name
|
||||||
|
if name_lookup:
|
||||||
|
name_map = await scryfall.batch_enrich_by_name(name_lookup)
|
||||||
|
enriched.update(name_map)
|
||||||
|
|
||||||
|
count = 0
|
||||||
|
for row in rows:
|
||||||
|
sf_data = enriched.get(row.card_name.lower())
|
||||||
|
if not sf_data:
|
||||||
|
continue
|
||||||
|
|
||||||
|
scryfall_id = sf_data["id"]
|
||||||
|
|
||||||
|
# Upsert — add quantities if card already exists
|
||||||
|
result = await db.execute(
|
||||||
|
select(CollectionCard).where(
|
||||||
|
CollectionCard.owner_id == user_id,
|
||||||
|
CollectionCard.scryfall_id == scryfall_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
existing = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
existing.quantity += row.quantity
|
||||||
|
existing.foil_quantity += row.foil_quantity
|
||||||
|
existing.scryfall_data = sf_data
|
||||||
|
else:
|
||||||
|
db.add(CollectionCard(
|
||||||
|
owner_id=user_id,
|
||||||
|
card_name=sf_data.get("name", row.card_name),
|
||||||
|
set_code=sf_data.get("set", row.set_code),
|
||||||
|
collector_number=sf_data.get("collector_number", row.collector_number),
|
||||||
|
quantity=row.quantity,
|
||||||
|
foil_quantity=row.foil_quantity,
|
||||||
|
scryfall_id=scryfall_id,
|
||||||
|
scryfall_data=sf_data,
|
||||||
|
))
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
return count
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RawCardRow:
|
||||||
|
card_name: str = ""
|
||||||
|
set_code: str = ""
|
||||||
|
collector_number: str = ""
|
||||||
|
quantity: int = 0
|
||||||
|
foil_quantity: int = 0
|
||||||
|
import_source: str = ""
|
||||||
|
raw: dict = field(default_factory=dict)
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
fastapi==0.111.0
|
||||||
|
uvicorn[standard]==0.29.0
|
||||||
|
sqlalchemy[asyncio]==2.0.29
|
||||||
|
asyncpg==0.29.0
|
||||||
|
alembic==1.13.1
|
||||||
|
pydantic[email]==2.7.1
|
||||||
|
pydantic-settings==2.2.1
|
||||||
|
python-jose[cryptography]==3.3.0
|
||||||
|
passlib[bcrypt]==1.7.4
|
||||||
|
python-multipart==0.0.9
|
||||||
|
httpx==0.27.0
|
||||||
|
anthropic==0.25.0
|
||||||
|
redis[hiredis]==5.0.4
|
||||||
|
tenacity==8.2.3
|
||||||
|
chardet==5.2.0
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
services:
|
||||||
|
nginx:
|
||||||
|
image: nginx:alpine
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
volumes:
|
||||||
|
- ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
||||||
|
depends_on:
|
||||||
|
- frontend
|
||||||
|
- backend
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build: ./frontend
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
backend:
|
||||||
|
build: ./backend
|
||||||
|
env_file: .env
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
- cache
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB}
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
cache:
|
||||||
|
image: redis:7-alpine
|
||||||
|
volumes:
|
||||||
|
- redis_data:/data
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
redis_data:
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# ── Build stage ────────────────────────────────────────────────────────────────
|
||||||
|
FROM node:20-alpine AS build
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# ── Serve stage ────────────────────────────────────────────────────────────────
|
||||||
|
FROM nginx:alpine
|
||||||
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
EXPOSE 80
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Commander Forge</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Cinzel:wght@400;600;700&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# SPA fallback
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Static assets with long cache
|
||||||
|
location ~* \.(js|css|woff2?|png|jpg|ico|svg)$ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
|
# Gzip
|
||||||
|
gzip on;
|
||||||
|
gzip_types text/plain text/css application/javascript application/json;
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"name": "mtg-deck-builder-frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tanstack/react-query": "^5.28.0",
|
||||||
|
"axios": "^1.6.8",
|
||||||
|
"lucide-react": "^0.368.0",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-router-dom": "^6.22.3",
|
||||||
|
"zustand": "^4.5.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.3.1",
|
||||||
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^7.5.0",
|
||||||
|
"@typescript-eslint/parser": "^7.5.0",
|
||||||
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
|
"autoprefixer": "^10.4.19",
|
||||||
|
"eslint": "^8.57.0",
|
||||||
|
"eslint-plugin-react-hooks": "^4.6.0",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.6",
|
||||||
|
"postcss": "^8.4.38",
|
||||||
|
"tailwindcss": "^3.4.3",
|
||||||
|
"typescript": "^5.4.3",
|
||||||
|
"vite": "^5.2.6"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export default {
|
||||||
|
plugins: { tailwindcss: {}, autoprefixer: {} },
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { Routes, Route, Navigate } from 'react-router-dom'
|
||||||
|
import { RequireAuth, RequireAdmin, GuestOnly } from '@/components/layout/Guards'
|
||||||
|
import { LoginPage, RegisterPage, PendingPage } from '@/pages/auth/AuthPages'
|
||||||
|
import { DeckListPage } from '@/pages/decks/DeckListPage'
|
||||||
|
import { DeckViewPage } from '@/pages/decks/DeckViewPage'
|
||||||
|
import { BuildDeckPage } from '@/pages/decks/BuildDeckPage'
|
||||||
|
import { CollectionPage } from '@/pages/collection/CollectionPage'
|
||||||
|
import { AdminPage } from '@/pages/admin/AdminPage'
|
||||||
|
import { ProfilePage } from '@/pages/ProfilePage'
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
{/* Public / guest routes */}
|
||||||
|
<Route element={<GuestOnly />}>
|
||||||
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
<Route path="/register" element={<RegisterPage />} />
|
||||||
|
</Route>
|
||||||
|
|
||||||
|
{/* Pending holding page — accessible when logged in but pending */}
|
||||||
|
<Route path="/pending" element={<PendingPage />} />
|
||||||
|
|
||||||
|
{/* Protected routes */}
|
||||||
|
<Route element={<RequireAuth />}>
|
||||||
|
<Route path="/decks" element={<DeckListPage />} />
|
||||||
|
<Route path="/decks/:id" element={<DeckViewPage />} />
|
||||||
|
<Route path="/build" element={<BuildDeckPage />} />
|
||||||
|
<Route path="/collection" element={<CollectionPage />} />
|
||||||
|
<Route path="/profile" element={<ProfilePage />} />
|
||||||
|
|
||||||
|
{/* Admin-only */}
|
||||||
|
<Route element={<RequireAdmin />}>
|
||||||
|
<Route path="/admin" element={<AdminPage />} />
|
||||||
|
</Route>
|
||||||
|
</Route>
|
||||||
|
|
||||||
|
{/* Default redirect */}
|
||||||
|
<Route path="/" element={<Navigate to="/decks" replace />} />
|
||||||
|
<Route path="*" element={<Navigate to="/decks" replace />} />
|
||||||
|
</Routes>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios'
|
||||||
|
import type { AuthTokens } from '@/types'
|
||||||
|
|
||||||
|
const BASE_URL = '/api'
|
||||||
|
|
||||||
|
// ─── Token storage ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const ACCESS_KEY = 'mtg_access_token'
|
||||||
|
const REFRESH_KEY = 'mtg_refresh_token'
|
||||||
|
|
||||||
|
export const tokenStorage = {
|
||||||
|
getAccess: () => localStorage.getItem(ACCESS_KEY),
|
||||||
|
getRefresh: () => localStorage.getItem(REFRESH_KEY),
|
||||||
|
set: (tokens: AuthTokens) => {
|
||||||
|
localStorage.setItem(ACCESS_KEY, tokens.access_token)
|
||||||
|
localStorage.setItem(REFRESH_KEY, tokens.refresh_token)
|
||||||
|
},
|
||||||
|
clear: () => {
|
||||||
|
localStorage.removeItem(ACCESS_KEY)
|
||||||
|
localStorage.removeItem(REFRESH_KEY)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Axios instance ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const api = axios.create({
|
||||||
|
baseURL: BASE_URL,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
})
|
||||||
|
|
||||||
|
// Attach access token to every request
|
||||||
|
api.interceptors.request.use((config: InternalAxiosRequestConfig) => {
|
||||||
|
const token = tokenStorage.getAccess()
|
||||||
|
if (token && config.headers) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
})
|
||||||
|
|
||||||
|
// Track in-flight refresh to avoid multiple simultaneous refresh calls
|
||||||
|
let refreshPromise: Promise<string> | null = null
|
||||||
|
|
||||||
|
api.interceptors.response.use(
|
||||||
|
(res) => res,
|
||||||
|
async (error: AxiosError) => {
|
||||||
|
const original = error.config as InternalAxiosRequestConfig & { _retry?: boolean }
|
||||||
|
|
||||||
|
if (error.response?.status !== 401 || original._retry) {
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
original._retry = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!refreshPromise) {
|
||||||
|
refreshPromise = (async () => {
|
||||||
|
const refreshToken = tokenStorage.getRefresh()
|
||||||
|
if (!refreshToken) throw new Error('No refresh token')
|
||||||
|
|
||||||
|
const res = await axios.post<AuthTokens>(`${BASE_URL}/auth/refresh`, {
|
||||||
|
refresh_token: refreshToken,
|
||||||
|
})
|
||||||
|
tokenStorage.set(res.data)
|
||||||
|
return res.data.access_token
|
||||||
|
})().finally(() => { refreshPromise = null })
|
||||||
|
}
|
||||||
|
|
||||||
|
const newToken = await refreshPromise
|
||||||
|
if (original.headers) original.headers.Authorization = `Bearer ${newToken}`
|
||||||
|
return api(original)
|
||||||
|
} catch {
|
||||||
|
tokenStorage.clear()
|
||||||
|
window.location.href = '/login'
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// ─── Auth endpoints ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const authApi = {
|
||||||
|
register: (email: string, password: string, display_name?: string) =>
|
||||||
|
api.post('/auth/register', { email, password, display_name }),
|
||||||
|
|
||||||
|
login: (email: string, password: string) =>
|
||||||
|
api.post<AuthTokens>('/auth/login', { email, password }),
|
||||||
|
|
||||||
|
refresh: (refresh_token: string) =>
|
||||||
|
api.post<AuthTokens>('/auth/refresh', { refresh_token }),
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── User endpoints ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const usersApi = {
|
||||||
|
me: () => api.get('/users/me'),
|
||||||
|
updateMe: (data: { display_name?: string; password?: string }) =>
|
||||||
|
api.patch('/users/me', data),
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Deck endpoints ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const decksApi = {
|
||||||
|
list: (page = 1, pageSize = 20) =>
|
||||||
|
api.get('/decks/', { params: { page, page_size: pageSize } }),
|
||||||
|
|
||||||
|
get: (id: number) => api.get(`/decks/${id}`),
|
||||||
|
|
||||||
|
generate: (data: object) => api.post('/decks/generate', data),
|
||||||
|
complete: (data: object) => api.post('/decks/complete', data),
|
||||||
|
cull: (data: object) => api.post('/decks/cull', data),
|
||||||
|
|
||||||
|
delete: (id: number) => api.delete(`/decks/${id}`),
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Collection endpoints ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const collectionApi = {
|
||||||
|
import: (source: 'archidekt' | 'manabox', file: File, replace = false) => {
|
||||||
|
const form = new FormData()
|
||||||
|
form.append('file', file)
|
||||||
|
return api.post(`/collection/import/${source}?replace=${replace}`, form, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
list: (search = '', page = 1, pageSize = 50) =>
|
||||||
|
api.get('/collection/', { params: { search, page, page_size: pageSize } }),
|
||||||
|
|
||||||
|
stats: () => api.get('/collection/stats'),
|
||||||
|
|
||||||
|
clear: () => api.delete('/collection/'),
|
||||||
|
|
||||||
|
deleteCard: (id: number) => api.delete(`/collection/${id}`),
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Admin endpoints ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const adminApi = {
|
||||||
|
queue: () => api.get('/admin/queue'),
|
||||||
|
users: (role?: string) => api.get('/admin/users', { params: { role } }),
|
||||||
|
approve: (id: number) => api.post(`/admin/users/${id}/approve`),
|
||||||
|
deny: (id: number) => api.post(`/admin/users/${id}/deny`),
|
||||||
|
updateUser: (id: number, data: object) => api.patch(`/admin/users/${id}`, data),
|
||||||
|
deleteUser: (id: number) => api.delete(`/admin/users/${id}`),
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
import React, { useState } from 'react'
|
||||||
|
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
||||||
|
import {
|
||||||
|
Layers,
|
||||||
|
Package,
|
||||||
|
ShieldCheck,
|
||||||
|
LogOut,
|
||||||
|
User,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Menu,
|
||||||
|
X,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { useAuthStore } from '@/store/authStore'
|
||||||
|
|
||||||
|
// Mana pentagon SVG — the signature UI element
|
||||||
|
function ManaPentagon({ className = '' }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 40 40" className={className} fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
{/* Pentagon segments representing 5 mana colors */}
|
||||||
|
<polygon points="20,2 38,14 31,34 9,34 2,14" stroke="#7C3AED" strokeWidth="1.5" fill="none" opacity="0.6" />
|
||||||
|
<circle cx="20" cy="14" r="3" fill="#F9D71C" opacity="0.8" /> {/* White */}
|
||||||
|
<circle cx="29" cy="21" r="3" fill="#4A90D9" opacity="0.8" /> {/* Blue */}
|
||||||
|
<circle cx="25" cy="30" r="3" fill="#1a1a1a" stroke="#555" strokeWidth="1" /> {/* Black */}
|
||||||
|
<circle cx="15" cy="30" r="3" fill="#C0392B" opacity="0.8" /> {/* Red */}
|
||||||
|
<circle cx="11" cy="21" r="3" fill="#27AE60" opacity="0.8" /> {/* Green */}
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NavItem {
|
||||||
|
path: string
|
||||||
|
label: string
|
||||||
|
icon: React.ReactNode
|
||||||
|
adminOnly?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const NAV_ITEMS: NavItem[] = [
|
||||||
|
{ path: '/decks', label: 'My Decks', icon: <Layers className="w-5 h-5" /> },
|
||||||
|
{ path: '/build', label: 'Build Deck', icon: <span className="w-5 h-5 flex items-center justify-center text-lg leading-none">✦</span> },
|
||||||
|
{ path: '/collection', label: 'Collection', icon: <Package className="w-5 h-5" /> },
|
||||||
|
{ path: '/admin', label: 'Admin', icon: <ShieldCheck className="w-5 h-5" />, adminOnly: true },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function AppLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
const [collapsed, setCollapsed] = useState(false)
|
||||||
|
const [mobileOpen, setMobileOpen] = useState(false)
|
||||||
|
const location = useLocation()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { user, logout } = useAuthStore()
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
logout()
|
||||||
|
navigate('/login')
|
||||||
|
}
|
||||||
|
|
||||||
|
const visibleItems = NAV_ITEMS.filter(
|
||||||
|
(item) => !item.adminOnly || user?.role === 'admin'
|
||||||
|
)
|
||||||
|
|
||||||
|
const Sidebar = ({ mobile = false }: { mobile?: boolean }) => (
|
||||||
|
<aside
|
||||||
|
className={`
|
||||||
|
flex flex-col bg-bg-surface border-r border-bg-border h-full
|
||||||
|
transition-all duration-300
|
||||||
|
${mobile ? 'w-64' : collapsed ? 'w-16' : 'w-56'}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{/* Logo */}
|
||||||
|
<div className={`flex items-center gap-3 p-4 border-b border-bg-border ${collapsed && !mobile ? 'justify-center' : ''}`}>
|
||||||
|
<ManaPentagon className="w-8 h-8 flex-shrink-0" />
|
||||||
|
{(!collapsed || mobile) && (
|
||||||
|
<span className="font-display text-sm font-semibold text-text-primary tracking-wide whitespace-nowrap">
|
||||||
|
Commander Forge
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Nav */}
|
||||||
|
<nav className="flex-1 py-4 px-2 flex flex-col gap-1">
|
||||||
|
{visibleItems.map((item) => {
|
||||||
|
const active = location.pathname.startsWith(item.path)
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.path}
|
||||||
|
to={item.path}
|
||||||
|
onClick={() => setMobileOpen(false)}
|
||||||
|
className={`
|
||||||
|
flex items-center gap-3 rounded-md px-3 py-2.5 text-sm font-medium
|
||||||
|
transition-all duration-150
|
||||||
|
${active
|
||||||
|
? 'bg-accent-violet/20 text-accent-violet-light border border-accent-violet/30'
|
||||||
|
: 'text-text-secondary hover:text-text-primary hover:bg-bg-elevated'
|
||||||
|
}
|
||||||
|
${collapsed && !mobile ? 'justify-center px-0' : ''}
|
||||||
|
`}
|
||||||
|
title={collapsed && !mobile ? item.label : undefined}
|
||||||
|
>
|
||||||
|
<span className={`flex-shrink-0 ${active ? 'text-accent-violet-light' : ''}`}>
|
||||||
|
{item.icon}
|
||||||
|
</span>
|
||||||
|
{(!collapsed || mobile) && <span>{item.label}</span>}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* User section */}
|
||||||
|
<div className={`p-3 border-t border-bg-border flex flex-col gap-1 ${collapsed && !mobile ? 'items-center' : ''}`}>
|
||||||
|
<Link
|
||||||
|
to="/profile"
|
||||||
|
onClick={() => setMobileOpen(false)}
|
||||||
|
className={`
|
||||||
|
flex items-center gap-3 rounded-md px-3 py-2 text-sm
|
||||||
|
text-text-secondary hover:text-text-primary hover:bg-bg-elevated transition-all
|
||||||
|
${collapsed && !mobile ? 'justify-center px-0' : ''}
|
||||||
|
`}
|
||||||
|
title={collapsed && !mobile ? 'Profile' : undefined}
|
||||||
|
>
|
||||||
|
<User className="w-4 h-4 flex-shrink-0" />
|
||||||
|
{(!collapsed || mobile) && (
|
||||||
|
<span className="truncate">{user?.display_name || user?.email}</span>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className={`
|
||||||
|
flex items-center gap-3 rounded-md px-3 py-2 text-sm w-full
|
||||||
|
text-text-secondary hover:text-danger hover:bg-danger/10 transition-all
|
||||||
|
${collapsed && !mobile ? 'justify-center px-0' : ''}
|
||||||
|
`}
|
||||||
|
title={collapsed && !mobile ? 'Sign out' : undefined}
|
||||||
|
>
|
||||||
|
<LogOut className="w-4 h-4 flex-shrink-0" />
|
||||||
|
{(!collapsed || mobile) && <span>Sign out</span>}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{!mobile && (
|
||||||
|
<button
|
||||||
|
onClick={() => setCollapsed((c) => !c)}
|
||||||
|
className="mt-1 flex items-center justify-center w-full py-1.5 rounded text-text-muted hover:text-text-secondary hover:bg-bg-elevated transition-all"
|
||||||
|
>
|
||||||
|
{collapsed ? <ChevronRight className="w-4 h-4" /> : <ChevronLeft className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen bg-bg-base overflow-hidden">
|
||||||
|
{/* Desktop sidebar */}
|
||||||
|
<div className="hidden md:flex flex-shrink-0">
|
||||||
|
<Sidebar />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile overlay */}
|
||||||
|
{mobileOpen && (
|
||||||
|
<div className="md:hidden fixed inset-0 z-50 flex">
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/60 backdrop-blur-sm"
|
||||||
|
onClick={() => setMobileOpen(false)}
|
||||||
|
/>
|
||||||
|
<div className="relative z-10 animate-slide-up">
|
||||||
|
<Sidebar mobile />
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setMobileOpen(false)}
|
||||||
|
className="absolute top-4 right-4 z-20 text-text-secondary hover:text-text-primary"
|
||||||
|
>
|
||||||
|
<X className="w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Main content */}
|
||||||
|
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
||||||
|
{/* Mobile topbar */}
|
||||||
|
<header className="md:hidden flex items-center gap-3 px-4 py-3 border-b border-bg-border bg-bg-surface">
|
||||||
|
<button onClick={() => setMobileOpen(true)} className="text-text-secondary hover:text-text-primary">
|
||||||
|
<Menu className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ManaPentagon className="w-6 h-6" />
|
||||||
|
<span className="font-display text-sm font-semibold text-text-primary">Commander Forge</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Page content */}
|
||||||
|
<main className="flex-1 overflow-y-auto">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { Navigate, Outlet, useLocation } from 'react-router-dom'
|
||||||
|
import { useAuthStore } from '@/store/authStore'
|
||||||
|
import { AppLayout } from '@/components/layout/AppLayout'
|
||||||
|
import { Spinner } from '@/components/ui'
|
||||||
|
|
||||||
|
// ─── Auth guard: must be logged in and approved ───────────────────────────────
|
||||||
|
|
||||||
|
export function RequireAuth() {
|
||||||
|
const { user, initialized } = useAuthStore()
|
||||||
|
const location = useLocation()
|
||||||
|
|
||||||
|
if (!initialized) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-bg-base flex items-center justify-center">
|
||||||
|
<Spinner size="lg" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) return <Navigate to="/login" state={{ from: location }} replace />
|
||||||
|
if (user.role === 'pending') return <Navigate to="/pending" replace />
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppLayout>
|
||||||
|
<Outlet />
|
||||||
|
</AppLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Admin guard ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function RequireAdmin() {
|
||||||
|
const { user } = useAuthStore()
|
||||||
|
if (user?.role !== 'admin') return <Navigate to="/decks" replace />
|
||||||
|
return <Outlet />
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Redirect if already logged in ───────────────────────────────────────────
|
||||||
|
|
||||||
|
export function GuestOnly() {
|
||||||
|
const { user, initialized } = useAuthStore()
|
||||||
|
if (!initialized) return null
|
||||||
|
if (user && user.role !== 'pending') return <Navigate to="/decks" replace />
|
||||||
|
return <Outlet />
|
||||||
|
}
|
||||||
@@ -0,0 +1,297 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { Loader2 } from 'lucide-react'
|
||||||
|
|
||||||
|
// ─── Button ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger'
|
||||||
|
type ButtonSize = 'sm' | 'md' | 'lg'
|
||||||
|
|
||||||
|
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
variant?: ButtonVariant
|
||||||
|
size?: ButtonSize
|
||||||
|
loading?: boolean
|
||||||
|
icon?: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
const variantClasses: Record<ButtonVariant, string> = {
|
||||||
|
primary:
|
||||||
|
'bg-accent-violet hover:bg-accent-violet-light text-white shadow-glow hover:shadow-lg transition-all',
|
||||||
|
secondary:
|
||||||
|
'bg-bg-elevated border border-bg-border text-text-primary hover:border-accent-violet hover:text-accent-violet-light transition-all',
|
||||||
|
ghost: 'text-text-secondary hover:text-text-primary hover:bg-bg-elevated transition-all',
|
||||||
|
danger: 'bg-danger/20 border border-danger/50 text-danger hover:bg-danger/30 transition-all',
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizeClasses: Record<ButtonSize, string> = {
|
||||||
|
sm: 'px-3 py-1.5 text-sm',
|
||||||
|
md: 'px-4 py-2 text-sm',
|
||||||
|
lg: 'px-6 py-3 text-base',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Button({
|
||||||
|
variant = 'primary',
|
||||||
|
size = 'md',
|
||||||
|
loading,
|
||||||
|
icon,
|
||||||
|
children,
|
||||||
|
disabled,
|
||||||
|
className = '',
|
||||||
|
...props
|
||||||
|
}: ButtonProps) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
{...props}
|
||||||
|
disabled={disabled || loading}
|
||||||
|
className={`
|
||||||
|
inline-flex items-center gap-2 rounded-md font-medium
|
||||||
|
disabled:opacity-50 disabled:cursor-not-allowed
|
||||||
|
${variantClasses[variant]} ${sizeClasses[size]} ${className}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : icon}
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Input ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||||
|
label?: string
|
||||||
|
error?: string
|
||||||
|
hint?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Input({ label, error, hint, className = '', ...props }: InputProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{label && (
|
||||||
|
<label className="text-xs font-medium text-text-secondary uppercase tracking-wider">
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
{...props}
|
||||||
|
className={`
|
||||||
|
bg-bg-elevated border rounded-md px-3 py-2 text-sm text-text-primary
|
||||||
|
placeholder:text-text-muted
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-accent-violet focus:border-transparent
|
||||||
|
${error ? 'border-danger' : 'border-bg-border'}
|
||||||
|
${className}
|
||||||
|
`}
|
||||||
|
/>
|
||||||
|
{error && <p className="text-xs text-danger">{error}</p>}
|
||||||
|
{hint && !error && <p className="text-xs text-text-muted">{hint}</p>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Textarea ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
|
||||||
|
label?: string
|
||||||
|
error?: string
|
||||||
|
hint?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Textarea({ label, error, hint, className = '', ...props }: TextareaProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{label && (
|
||||||
|
<label className="text-xs font-medium text-text-secondary uppercase tracking-wider">
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<textarea
|
||||||
|
{...props}
|
||||||
|
className={`
|
||||||
|
bg-bg-elevated border rounded-md px-3 py-2 text-sm text-text-primary
|
||||||
|
placeholder:text-text-muted resize-none
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-accent-violet focus:border-transparent
|
||||||
|
${error ? 'border-danger' : 'border-bg-border'}
|
||||||
|
${className}
|
||||||
|
`}
|
||||||
|
/>
|
||||||
|
{error && <p className="text-xs text-danger">{error}</p>}
|
||||||
|
{hint && !error && <p className="text-xs text-text-muted">{hint}</p>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Select ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface SelectProps extends React.SelectHTMLAttributes<HTMLSelectElement> {
|
||||||
|
label?: string
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Select({ label, error, className = '', children, ...props }: SelectProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{label && (
|
||||||
|
<label className="text-xs font-medium text-text-secondary uppercase tracking-wider">
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<select
|
||||||
|
{...props}
|
||||||
|
className={`
|
||||||
|
bg-bg-elevated border rounded-md px-3 py-2 text-sm text-text-primary
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-accent-violet focus:border-transparent
|
||||||
|
${error ? 'border-danger' : 'border-bg-border'}
|
||||||
|
${className}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</select>
|
||||||
|
{error && <p className="text-xs text-danger">{error}</p>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Toggle ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface ToggleProps {
|
||||||
|
checked: boolean
|
||||||
|
onChange: (checked: boolean) => void
|
||||||
|
label?: string
|
||||||
|
description?: string
|
||||||
|
disabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Toggle({ checked, onChange, label, description, disabled }: ToggleProps) {
|
||||||
|
return (
|
||||||
|
<label className="flex items-start gap-3 cursor-pointer select-none group">
|
||||||
|
<div className="relative mt-0.5 flex-shrink-0">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
onChange={(e) => onChange(e.target.checked)}
|
||||||
|
disabled={disabled}
|
||||||
|
className="sr-only"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
w-10 h-6 rounded-full transition-colors duration-200
|
||||||
|
${checked ? 'bg-accent-violet' : 'bg-bg-border'}
|
||||||
|
${disabled ? 'opacity-50' : ''}
|
||||||
|
`}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
absolute top-1 left-1 w-4 h-4 rounded-full bg-white transition-transform duration-200
|
||||||
|
${checked ? 'translate-x-4' : 'translate-x-0'}
|
||||||
|
`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{(label || description) && (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
{label && <span className="text-sm font-medium text-text-primary">{label}</span>}
|
||||||
|
{description && <span className="text-xs text-text-muted">{description}</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Badge ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type BadgeVariant = 'owned' | 'unowned' | 'default' | 'mode' | 'slot'
|
||||||
|
|
||||||
|
interface BadgeProps {
|
||||||
|
variant?: BadgeVariant
|
||||||
|
children: React.ReactNode
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const badgeVariants: Record<BadgeVariant, string> = {
|
||||||
|
owned: 'bg-owned/20 text-owned border border-owned/30',
|
||||||
|
unowned: 'bg-unowned/20 text-unowned border border-unowned/30',
|
||||||
|
default: 'bg-bg-elevated text-text-secondary border border-bg-border',
|
||||||
|
mode: 'bg-accent-violet/20 text-accent-violet-light border border-accent-violet/30',
|
||||||
|
slot: 'bg-bg-surface text-text-secondary border border-bg-border',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Badge({ variant = 'default', children, className = '' }: BadgeProps) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${badgeVariants[variant]} ${className}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Card (panel) ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface PanelProps {
|
||||||
|
children: React.ReactNode
|
||||||
|
className?: string
|
||||||
|
glow?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Panel({ children, className = '', glow }: PanelProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
bg-bg-surface rounded-lg border border-bg-border
|
||||||
|
${glow ? 'shadow-glow' : 'shadow-card'}
|
||||||
|
${className}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Spinner ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function Spinner({ size = 'md', className = '' }: { size?: 'sm' | 'md' | 'lg'; className?: string }) {
|
||||||
|
const s = { sm: 'w-4 h-4', md: 'w-6 h-6', lg: 'w-10 h-10' }[size]
|
||||||
|
return <Loader2 className={`${s} animate-spin text-accent-violet ${className}`} />
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Empty state ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function EmptyState({
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
action,
|
||||||
|
}: {
|
||||||
|
icon?: React.ReactNode
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
action?: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center py-16 gap-4 text-center">
|
||||||
|
{icon && <div className="text-text-muted w-12 h-12">{icon}</div>}
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<p className="text-text-primary font-medium">{title}</p>
|
||||||
|
{description && <p className="text-text-muted text-sm max-w-sm">{description}</p>}
|
||||||
|
</div>
|
||||||
|
{action}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Error message ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function ErrorMessage({ message }: { message: string }) {
|
||||||
|
return (
|
||||||
|
<div className="bg-danger/10 border border-danger/30 rounded-md p-3 text-sm text-danger">
|
||||||
|
{message}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Skeleton loader ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function Skeleton({ className = '' }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`rounded bg-gradient-to-r from-bg-elevated via-bg-border to-bg-elevated bg-[length:200%_100%] animate-shimmer ${className}`}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
*, *::before, *::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
@apply bg-bg-base text-text-primary font-body;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom scrollbar */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
@apply bg-bg-base;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
@apply bg-bg-border rounded-full;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
@apply bg-text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Focus visible for accessibility */
|
||||||
|
:focus-visible {
|
||||||
|
@apply outline-2 outline-offset-2 outline-accent-violet;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
/* Card frame aesthetic — the signature element */
|
||||||
|
.card-frame {
|
||||||
|
@apply bg-bg-surface border border-bg-border rounded-lg;
|
||||||
|
background-image: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
rgba(124, 58, 237, 0.03) 0%,
|
||||||
|
transparent 50%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mana symbol decorative text */
|
||||||
|
.mana-text {
|
||||||
|
font-family: 'Cinzel', serif;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import React, { useEffect } from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
import { App } from './App'
|
||||||
|
import { useAuthStore } from './store/authStore'
|
||||||
|
import './index.css'
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
staleTime: 1000 * 60 * 2, // 2 minutes
|
||||||
|
retry: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
function Root() {
|
||||||
|
const initialize = useAuthStore((s) => s.initialize)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
initialize()
|
||||||
|
}, [initialize])
|
||||||
|
|
||||||
|
return <App />
|
||||||
|
}
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<BrowserRouter>
|
||||||
|
<Root />
|
||||||
|
</BrowserRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
|
</React.StrictMode>
|
||||||
|
)
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import React, { useState } from 'react'
|
||||||
|
import { useMutation } from '@tanstack/react-query'
|
||||||
|
import { useAuthStore } from '@/store/authStore'
|
||||||
|
import { usersApi } from '@/api'
|
||||||
|
import { Button, Input, Panel, ErrorMessage } from '@/components/ui'
|
||||||
|
import { AxiosError } from 'axios'
|
||||||
|
|
||||||
|
export function ProfilePage() {
|
||||||
|
const { user, fetchMe } = useAuthStore()
|
||||||
|
const [displayName, setDisplayName] = useState(user?.display_name ?? '')
|
||||||
|
const [currentPw, setCurrentPw] = useState('')
|
||||||
|
const [newPw, setNewPw] = useState('')
|
||||||
|
const [success, setSuccess] = useState('')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: (data: { display_name?: string; password?: string }) =>
|
||||||
|
usersApi.updateMe(data),
|
||||||
|
onSuccess: () => {
|
||||||
|
setSuccess('Profile updated.')
|
||||||
|
setCurrentPw('')
|
||||||
|
setNewPw('')
|
||||||
|
fetchMe()
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
if (err instanceof AxiosError) {
|
||||||
|
setError(err.response?.data?.detail ?? 'Update failed.')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setSuccess('')
|
||||||
|
setError('')
|
||||||
|
const payload: { display_name?: string; password?: string } = {}
|
||||||
|
if (displayName !== user?.display_name) payload.display_name = displayName
|
||||||
|
if (newPw) payload.password = newPw
|
||||||
|
if (!Object.keys(payload).length) return
|
||||||
|
updateMutation.mutate(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-lg mx-auto px-4 py-8">
|
||||||
|
<h1 className="font-display text-2xl text-text-primary mb-8">Profile</h1>
|
||||||
|
|
||||||
|
<Panel className="p-6">
|
||||||
|
<div className="mb-6 pb-6 border-b border-bg-border">
|
||||||
|
<p className="text-xs font-medium text-text-secondary uppercase tracking-wider mb-1">Account</p>
|
||||||
|
<p className="text-sm text-text-primary">{user?.email}</p>
|
||||||
|
<p className="text-xs text-text-muted mt-1 capitalize">{user?.role}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||||
|
{error && <ErrorMessage message={error} />}
|
||||||
|
{success && (
|
||||||
|
<div className="bg-owned/10 border border-owned/30 rounded-md p-3 text-sm text-owned">
|
||||||
|
{success}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="Display name"
|
||||||
|
value={displayName}
|
||||||
|
onChange={(e) => setDisplayName(e.target.value)}
|
||||||
|
placeholder="Your name"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="pt-2 border-t border-bg-border">
|
||||||
|
<p className="text-xs font-medium text-text-secondary uppercase tracking-wider mb-3">
|
||||||
|
Change password
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<Input
|
||||||
|
label="New password"
|
||||||
|
type="password"
|
||||||
|
value={newPw}
|
||||||
|
onChange={(e) => setNewPw(e.target.value)}
|
||||||
|
placeholder="Leave blank to keep current"
|
||||||
|
autoComplete="new-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
loading={updateMutation.isPending}
|
||||||
|
className="w-full mt-2"
|
||||||
|
>
|
||||||
|
Save changes
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
import React, { useState } from 'react'
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { CheckCircle, XCircle, ShieldCheck, Trash2, Users, Clock } from 'lucide-react'
|
||||||
|
import { adminApi } from '@/api'
|
||||||
|
import { Button, Badge, Panel, Spinner, EmptyState } from '@/components/ui'
|
||||||
|
import type { User, UserRole } from '@/types'
|
||||||
|
|
||||||
|
// ─── Approval queue ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function ApprovalQueue() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const { data: queue, isLoading } = useQuery<User[]>({
|
||||||
|
queryKey: ['admin-queue'],
|
||||||
|
queryFn: async () => (await adminApi.queue()).data,
|
||||||
|
refetchInterval: 30_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const approveMutation = useMutation({
|
||||||
|
mutationFn: (id: number) => adminApi.approve(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin-queue'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin-users'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const denyMutation = useMutation({
|
||||||
|
mutationFn: (id: number) => adminApi.deny(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin-queue'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin-users'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const pending = queue ?? []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<Clock className="w-4 h-4 text-unowned" />
|
||||||
|
<h2 className="font-display text-base text-text-primary">Pending approvals</h2>
|
||||||
|
{pending.length > 0 && (
|
||||||
|
<span className="bg-unowned/20 text-unowned text-xs px-2 py-0.5 rounded-full">
|
||||||
|
{pending.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex justify-center py-8"><Spinner /></div>
|
||||||
|
) : pending.length === 0 ? (
|
||||||
|
<Panel className="py-2">
|
||||||
|
<EmptyState
|
||||||
|
icon={<CheckCircle className="w-full h-full text-owned" />}
|
||||||
|
title="No pending approvals"
|
||||||
|
description="All registrations have been reviewed."
|
||||||
|
/>
|
||||||
|
</Panel>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{pending.map((user) => (
|
||||||
|
<Panel key={user.id} className="p-4 flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-text-primary">
|
||||||
|
{user.display_name || user.email}
|
||||||
|
</p>
|
||||||
|
{user.display_name && (
|
||||||
|
<p className="text-xs text-text-muted">{user.email}</p>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-text-muted mt-0.5">
|
||||||
|
Registered {new Date(user.created_at).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 flex-shrink-0">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
icon={<CheckCircle className="w-4 h-4 text-owned" />}
|
||||||
|
loading={approveMutation.isPending}
|
||||||
|
onClick={() => approveMutation.mutate(user.id)}
|
||||||
|
>
|
||||||
|
Approve
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
size="sm"
|
||||||
|
icon={<XCircle className="w-4 h-4" />}
|
||||||
|
loading={denyMutation.isPending}
|
||||||
|
onClick={() => denyMutation.mutate(user.id)}
|
||||||
|
>
|
||||||
|
Deny
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Role badge ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const ROLE_STYLE: Record<UserRole, string> = {
|
||||||
|
admin: 'bg-accent-violet/20 text-accent-violet-light border-accent-violet/30',
|
||||||
|
approved: 'bg-owned/20 text-owned border-owned/30',
|
||||||
|
pending: 'bg-unowned/20 text-unowned border-unowned/30',
|
||||||
|
}
|
||||||
|
|
||||||
|
function RoleBadge({ role }: { role: UserRole }) {
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium border capitalize ${ROLE_STYLE[role]}`}>
|
||||||
|
{role}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── User table ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function UserTable() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const [roleFilter, setRoleFilter] = useState<string>('all')
|
||||||
|
|
||||||
|
const { data: users, isLoading } = useQuery<User[]>({
|
||||||
|
queryKey: ['admin-users', roleFilter],
|
||||||
|
queryFn: async () =>
|
||||||
|
(await adminApi.users(roleFilter === 'all' ? undefined : roleFilter)).data,
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: number) => adminApi.deleteUser(id),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['admin-users'] }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const promoteMutation = useMutation({
|
||||||
|
mutationFn: ({ id, role }: { id: number; role: string }) =>
|
||||||
|
adminApi.updateUser(id, { role }),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['admin-users'] }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const allUsers = users ?? []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between gap-4 mb-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Users className="w-4 h-4 text-text-muted" />
|
||||||
|
<h2 className="font-display text-base text-text-primary">All users</h2>
|
||||||
|
<span className="text-sm text-text-muted">({allUsers.length})</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
{['all', 'pending', 'approved', 'admin'].map((r) => (
|
||||||
|
<button
|
||||||
|
key={r}
|
||||||
|
onClick={() => setRoleFilter(r)}
|
||||||
|
className={`text-xs px-3 py-1.5 rounded capitalize ${
|
||||||
|
roleFilter === r
|
||||||
|
? 'bg-accent-violet text-white'
|
||||||
|
: 'bg-bg-elevated text-text-muted hover:text-text-primary border border-bg-border'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{r}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex justify-center py-8"><Spinner /></div>
|
||||||
|
) : allUsers.length === 0 ? (
|
||||||
|
<Panel className="py-2">
|
||||||
|
<EmptyState title="No users found" />
|
||||||
|
</Panel>
|
||||||
|
) : (
|
||||||
|
<Panel className="overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-bg-border">
|
||||||
|
{['User', 'Role', 'Joined', 'Status', ''].map((h) => (
|
||||||
|
<th
|
||||||
|
key={h}
|
||||||
|
className="px-4 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider"
|
||||||
|
>
|
||||||
|
{h}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-bg-border">
|
||||||
|
{allUsers.map((user) => (
|
||||||
|
<tr key={user.id} className="hover:bg-bg-elevated transition-colors">
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<p className="font-medium text-text-primary">{user.display_name || '—'}</p>
|
||||||
|
<p className="text-xs text-text-muted">{user.email}</p>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<RoleBadge role={user.role} />
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-text-muted text-xs">
|
||||||
|
{new Date(user.created_at).toLocaleDateString()}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className={`text-xs ${user.is_active ? 'text-owned' : 'text-danger'}`}>
|
||||||
|
{user.is_active ? 'Active' : 'Inactive'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex items-center gap-2 justify-end">
|
||||||
|
{user.role !== 'admin' && (
|
||||||
|
<button
|
||||||
|
onClick={() => promoteMutation.mutate({ id: user.id, role: 'admin' })}
|
||||||
|
className="text-xs text-text-muted hover:text-accent-violet-light transition-colors"
|
||||||
|
title="Promote to admin"
|
||||||
|
>
|
||||||
|
<ShieldCheck className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm(`Delete ${user.email}? This removes all their data.`)) {
|
||||||
|
deleteMutation.mutate(user.id)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="text-text-muted hover:text-danger transition-colors"
|
||||||
|
title="Delete user"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main page ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function AdminPage() {
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto px-4 py-8">
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="font-display text-2xl text-text-primary">Admin panel</h1>
|
||||||
|
<p className="text-text-muted text-sm mt-1">Manage user access and permissions</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-10">
|
||||||
|
<ApprovalQueue />
|
||||||
|
<UserTable />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
import React, { useState } from 'react'
|
||||||
|
import { Link, useNavigate } from 'react-router-dom'
|
||||||
|
import { useAuthStore } from '@/store/authStore'
|
||||||
|
import { Button, Input, ErrorMessage } from '@/components/ui'
|
||||||
|
import { AxiosError } from 'axios'
|
||||||
|
|
||||||
|
// ─── Shared card wrapper ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function AuthCard({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-bg-base flex items-center justify-center p-4">
|
||||||
|
{/* Subtle background texture */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 opacity-5"
|
||||||
|
style={{
|
||||||
|
backgroundImage:
|
||||||
|
'radial-gradient(circle at 25% 25%, #7C3AED 0%, transparent 50%), radial-gradient(circle at 75% 75%, #4C1D95 0%, transparent 50%)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="relative w-full max-w-md">
|
||||||
|
{/* Logo */}
|
||||||
|
<div className="flex flex-col items-center mb-8 gap-2">
|
||||||
|
<svg viewBox="0 0 60 60" className="w-14 h-14" fill="none">
|
||||||
|
<polygon
|
||||||
|
points="30,3 57,21 46.5,51 13.5,51 3,21"
|
||||||
|
stroke="#7C3AED"
|
||||||
|
strokeWidth="2"
|
||||||
|
fill="none"
|
||||||
|
/>
|
||||||
|
<polygon
|
||||||
|
points="30,12 48,24 41,44 19,44 12,24"
|
||||||
|
stroke="#9F67FF"
|
||||||
|
strokeWidth="1"
|
||||||
|
fill="rgba(124,58,237,0.1)"
|
||||||
|
/>
|
||||||
|
<circle cx="30" cy="28" r="5" fill="#7C3AED" opacity="0.8" />
|
||||||
|
</svg>
|
||||||
|
<h1 className="font-display text-2xl text-text-primary tracking-wide">Commander Forge</h1>
|
||||||
|
<p className="text-text-muted text-sm">AI-powered deck building for Commander</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-bg-surface border border-bg-border rounded-xl p-8 shadow-card">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getErrorMessage(err: unknown): string {
|
||||||
|
if (err instanceof AxiosError) {
|
||||||
|
const detail = err.response?.data?.detail
|
||||||
|
if (typeof detail === 'string') return detail
|
||||||
|
if (Array.isArray(detail)) return detail.map((d) => d.msg).join('. ')
|
||||||
|
}
|
||||||
|
return 'Something went wrong. Please try again.'
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Login ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function LoginPage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { login, loading } = useAuthStore()
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
await login(email, password)
|
||||||
|
navigate('/decks')
|
||||||
|
} catch (err) {
|
||||||
|
setError(getErrorMessage(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthCard>
|
||||||
|
<h2 className="font-display text-lg text-text-primary mb-6">Sign in</h2>
|
||||||
|
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||||
|
{error && <ErrorMessage message={error} />}
|
||||||
|
<Input
|
||||||
|
label="Email"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
placeholder="you@example.com"
|
||||||
|
autoComplete="email"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="••••••••"
|
||||||
|
autoComplete="current-password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Button type="submit" loading={loading} className="w-full mt-2" size="lg">
|
||||||
|
Sign in
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
<p className="text-center text-sm text-text-muted mt-6">
|
||||||
|
No account?{' '}
|
||||||
|
<Link to="/register" className="text-accent-violet-light hover:underline">
|
||||||
|
Request access
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</AuthCard>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Register ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function RegisterPage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { register, loading } = useAuthStore()
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
|
const [displayName, setDisplayName] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [confirm, setConfirm] = useState('')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setError('')
|
||||||
|
if (password !== confirm) {
|
||||||
|
setError('Passwords do not match.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await register(email, password, displayName || undefined)
|
||||||
|
navigate('/pending')
|
||||||
|
} catch (err) {
|
||||||
|
setError(getErrorMessage(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthCard>
|
||||||
|
<h2 className="font-display text-lg text-text-primary mb-1">Request access</h2>
|
||||||
|
<p className="text-text-muted text-sm mb-6">
|
||||||
|
Accounts require admin approval before you can start building.
|
||||||
|
</p>
|
||||||
|
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||||
|
{error && <ErrorMessage message={error} />}
|
||||||
|
<Input
|
||||||
|
label="Email"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
placeholder="you@example.com"
|
||||||
|
autoComplete="email"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Display name (optional)"
|
||||||
|
type="text"
|
||||||
|
value={displayName}
|
||||||
|
onChange={(e) => setDisplayName(e.target.value)}
|
||||||
|
placeholder="Your name"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="At least 8 characters"
|
||||||
|
autoComplete="new-password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Confirm password"
|
||||||
|
type="password"
|
||||||
|
value={confirm}
|
||||||
|
onChange={(e) => setConfirm(e.target.value)}
|
||||||
|
placeholder="••••••••"
|
||||||
|
autoComplete="new-password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Button type="submit" loading={loading} className="w-full mt-2" size="lg">
|
||||||
|
Request access
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
<p className="text-center text-sm text-text-muted mt-6">
|
||||||
|
Already have an account?{' '}
|
||||||
|
<Link to="/login" className="text-accent-violet-light hover:underline">
|
||||||
|
Sign in
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</AuthCard>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Pending ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function PendingPage() {
|
||||||
|
const { logout } = useAuthStore()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthCard>
|
||||||
|
<div className="text-center flex flex-col items-center gap-4">
|
||||||
|
<div className="w-16 h-16 rounded-full bg-accent-violet/20 border border-accent-violet/40 flex items-center justify-center">
|
||||||
|
<svg className="w-8 h-8 text-accent-violet-light" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
|
||||||
|
d="M12 6v6l4 2M12 2a10 10 0 100 20 10 10 0 000-20z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="font-display text-lg text-text-primary mb-2">Access pending</h2>
|
||||||
|
<p className="text-text-secondary text-sm leading-relaxed">
|
||||||
|
Your account is awaiting admin approval. You'll be able to sign in once approved.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => { logout(); navigate('/login') }}
|
||||||
|
className="text-sm text-text-muted hover:text-text-secondary transition-colors"
|
||||||
|
>
|
||||||
|
Back to sign in
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</AuthCard>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,333 @@
|
|||||||
|
import React, { useState, useRef } from 'react'
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { Upload, Package, Search, Trash2, RefreshCw, X, ChevronLeft, ChevronRight } from 'lucide-react'
|
||||||
|
import { collectionApi } from '@/api'
|
||||||
|
import { Button, Input, Panel, Badge, Spinner, EmptyState, Skeleton, ErrorMessage } from '@/components/ui'
|
||||||
|
import type { CollectionCard, CollectionStats } from '@/types'
|
||||||
|
import { AxiosError } from 'axios'
|
||||||
|
|
||||||
|
// ─── Import panel ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function ImportPanel({ onSuccess }: { onSuccess: () => void }) {
|
||||||
|
const [source, setSource] = useState<'archidekt' | 'manabox'>('archidekt')
|
||||||
|
const [replace, setReplace] = useState(false)
|
||||||
|
const [file, setFile] = useState<File | null>(null)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [success, setSuccess] = useState('')
|
||||||
|
const fileRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
const importMutation = useMutation({
|
||||||
|
mutationFn: () => collectionApi.import(source, file!, replace),
|
||||||
|
onSuccess: (res) => {
|
||||||
|
setSuccess(`Imported ${res.data.imported ?? 'cards'} successfully.`)
|
||||||
|
setFile(null)
|
||||||
|
setError('')
|
||||||
|
onSuccess()
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
if (err instanceof AxiosError) {
|
||||||
|
setError(err.response?.data?.detail ?? 'Import failed.')
|
||||||
|
} else {
|
||||||
|
setError('Import failed.')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Panel className="p-5 flex flex-col gap-4">
|
||||||
|
<h2 className="font-display text-base text-text-primary">Import collection</h2>
|
||||||
|
|
||||||
|
{/* Source selector */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{(['archidekt', 'manabox'] as const).map((s) => (
|
||||||
|
<button
|
||||||
|
key={s}
|
||||||
|
onClick={() => setSource(s)}
|
||||||
|
className={`flex-1 py-2 rounded text-sm font-medium transition-all capitalize ${
|
||||||
|
source === s
|
||||||
|
? 'bg-accent-violet text-white'
|
||||||
|
: 'bg-bg-elevated text-text-muted hover:text-text-primary border border-bg-border'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{s}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* File drop zone */}
|
||||||
|
<div
|
||||||
|
onClick={() => fileRef.current?.click()}
|
||||||
|
className={`
|
||||||
|
border-2 border-dashed rounded-lg p-6 text-center cursor-pointer transition-all
|
||||||
|
${file ? 'border-accent-violet/50 bg-accent-violet/5' : 'border-bg-border hover:border-accent-violet/40 hover:bg-bg-elevated'}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={fileRef}
|
||||||
|
type="file"
|
||||||
|
accept=".csv,.json"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
|
||||||
|
/>
|
||||||
|
{file ? (
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
<span className="text-sm text-accent-violet-light">{file.name}</span>
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); setFile(null) }}
|
||||||
|
className="text-text-muted hover:text-danger"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center gap-2 text-text-muted">
|
||||||
|
<Upload className="w-6 h-6" />
|
||||||
|
<p className="text-sm">Click to choose {source === 'archidekt' ? 'CSV or JSON' : 'CSV'} file</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={replace}
|
||||||
|
onChange={(e) => setReplace(e.target.checked)}
|
||||||
|
className="rounded border-bg-border"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-text-secondary">Replace existing collection</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{error && <ErrorMessage message={error} />}
|
||||||
|
{success && (
|
||||||
|
<div className="bg-owned/10 border border-owned/30 rounded-md p-3 text-sm text-owned">
|
||||||
|
{success}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
loading={importMutation.isPending}
|
||||||
|
disabled={!file}
|
||||||
|
icon={<Upload className="w-4 h-4" />}
|
||||||
|
onClick={() => importMutation.mutate()}
|
||||||
|
>
|
||||||
|
Import
|
||||||
|
</Button>
|
||||||
|
</Panel>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Stats panel ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function StatsPanel() {
|
||||||
|
const { data } = useQuery<CollectionStats>({
|
||||||
|
queryKey: ['collection-stats'],
|
||||||
|
queryFn: async () => (await collectionApi.stats()).data,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!data) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||||
|
{[
|
||||||
|
{ label: 'Total cards', value: data.total_cards.toLocaleString() },
|
||||||
|
{ label: 'Unique cards', value: data.unique_cards.toLocaleString() },
|
||||||
|
{ label: 'Foil', value: data.foil_cards.toLocaleString() },
|
||||||
|
{
|
||||||
|
label: 'Est. value',
|
||||||
|
value: data.estimated_value != null ? `$${data.estimated_value.toFixed(2)}` : '—',
|
||||||
|
},
|
||||||
|
].map((s) => (
|
||||||
|
<Panel key={s.label} className="p-3 text-center">
|
||||||
|
<p className="text-xl font-semibold text-text-primary">{s.value}</p>
|
||||||
|
<p className="text-xs text-text-muted mt-0.5">{s.label}</p>
|
||||||
|
</Panel>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Card row ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function CollectionCardRow({
|
||||||
|
card,
|
||||||
|
onDelete,
|
||||||
|
}: {
|
||||||
|
card: CollectionCard
|
||||||
|
onDelete: (id: number) => void
|
||||||
|
}) {
|
||||||
|
const img = card.scryfall_data?.image_uris?.small
|
||||||
|
const price = card.scryfall_data?.prices?.usd
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3 py-2.5 px-3 hover:bg-bg-elevated rounded-md transition-colors group">
|
||||||
|
{img ? (
|
||||||
|
<img src={img} alt={card.card_name} className="w-7 h-9 rounded object-cover flex-shrink-0" />
|
||||||
|
) : (
|
||||||
|
<div className="w-7 h-9 bg-bg-border rounded flex-shrink-0" />
|
||||||
|
)}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm text-text-primary truncate">{card.card_name}</p>
|
||||||
|
<div className="flex gap-2 items-center mt-0.5">
|
||||||
|
{card.set_code && (
|
||||||
|
<span className="text-xs text-text-muted uppercase">{card.set_code}</span>
|
||||||
|
)}
|
||||||
|
{card.collector_number && (
|
||||||
|
<span className="text-xs text-text-muted">#{card.collector_number}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 flex-shrink-0">
|
||||||
|
<div className="text-right">
|
||||||
|
{card.quantity > 0 && (
|
||||||
|
<span className="text-xs text-text-secondary">{card.quantity}×</span>
|
||||||
|
)}
|
||||||
|
{card.foil_quantity > 0 && (
|
||||||
|
<span className="text-xs text-unowned ml-1.5">{card.foil_quantity}✦</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{price && <span className="text-xs text-text-muted">${price}</span>}
|
||||||
|
<button
|
||||||
|
onClick={() => onDelete(card.id)}
|
||||||
|
className="text-text-muted hover:text-danger opacity-0 group-hover:opacity-100 transition-all"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main page ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function CollectionPage() {
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['collection', search, page],
|
||||||
|
queryFn: async () => (await collectionApi.list(search, page)).data,
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: number) => collectionApi.deleteCard(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['collection'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['collection-stats'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const clearMutation = useMutation({
|
||||||
|
mutationFn: () => collectionApi.clear(),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['collection'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['collection-stats'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const invalidate = () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['collection'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['collection-stats'] })
|
||||||
|
}
|
||||||
|
|
||||||
|
const cards: CollectionCard[] = data?.items ?? []
|
||||||
|
const total: number = data?.total ?? 0
|
||||||
|
const pages: number = data?.pages ?? 1
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-3xl mx-auto px-4 py-8">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h1 className="font-display text-2xl text-text-primary">Collection</h1>
|
||||||
|
{total > 0 && (
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
size="sm"
|
||||||
|
icon={<Trash2 className="w-4 h-4" />}
|
||||||
|
loading={clearMutation.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm('Clear your entire collection? This cannot be undone.')) {
|
||||||
|
clearMutation.mutate()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clear all
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<ImportPanel onSuccess={invalidate} />
|
||||||
|
<StatsPanel />
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-muted" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => { setSearch(e.target.value); setPage(1) }}
|
||||||
|
placeholder="Search collection…"
|
||||||
|
className="w-full bg-bg-elevated border border-bg-border rounded-md pl-9 pr-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-accent-violet focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Card list */}
|
||||||
|
<Panel className="overflow-hidden">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="p-4 flex flex-col gap-2">
|
||||||
|
{[1, 2, 3, 4, 5].map((n) => (
|
||||||
|
<div key={n} className="flex gap-3 items-center">
|
||||||
|
<Skeleton className="w-7 h-9 rounded" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<Skeleton className="h-4 w-40 mb-1.5" />
|
||||||
|
<Skeleton className="h-3 w-20" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : cards.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={<Package className="w-full h-full" />}
|
||||||
|
title={search ? 'No matching cards' : 'Collection is empty'}
|
||||||
|
description={search ? 'Try a different search.' : 'Import your collection above.'}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-bg-border px-1">
|
||||||
|
{cards.map((card) => (
|
||||||
|
<CollectionCardRow
|
||||||
|
key={card.id}
|
||||||
|
card={card}
|
||||||
|
onDelete={(id) => deleteMutation.mutate(id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{pages > 1 && (
|
||||||
|
<div className="flex items-center justify-between text-sm text-text-muted">
|
||||||
|
<span>{total} cards</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
|
disabled={page === 1}
|
||||||
|
className="p-1 hover:text-text-primary disabled:opacity-30"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<span>Page {page} of {pages}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setPage((p) => Math.min(pages, p + 1))}
|
||||||
|
disabled={page === pages}
|
||||||
|
className="p-1 hover:text-text-primary disabled:opacity-30"
|
||||||
|
>
|
||||||
|
<ChevronRight className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,378 @@
|
|||||||
|
import React, { useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { Wand2, Puzzle, Scissors, Plus, X, ChevronDown, ChevronUp, AlertCircle } from 'lucide-react'
|
||||||
|
import { useDeckBuilderStore } from '@/store/deckBuilderStore'
|
||||||
|
import { decksApi } from '@/api'
|
||||||
|
import { Button, Input, Textarea, Toggle, Panel, ErrorMessage, Badge, Select } from '@/components/ui'
|
||||||
|
import type { DeckMode, CardSlot } from '@/types'
|
||||||
|
import { AxiosError } from 'axios'
|
||||||
|
|
||||||
|
// ─── Mode config ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const MODES: { id: DeckMode; label: string; icon: React.ReactNode; description: string }[] = [
|
||||||
|
{
|
||||||
|
id: 'generate',
|
||||||
|
label: 'Generate',
|
||||||
|
icon: <Wand2 className="w-5 h-5" />,
|
||||||
|
description: 'Build a full 99-card deck from scratch',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'complete',
|
||||||
|
label: 'Complete',
|
||||||
|
icon: <Puzzle className="w-5 h-5" />,
|
||||||
|
description: 'Fill the remaining slots in a partial decklist',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cull',
|
||||||
|
label: 'Cull',
|
||||||
|
icon: <Scissors className="w-5 h-5" />,
|
||||||
|
description: 'Cut an oversized deck down to 99',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const CARD_SLOTS: CardSlot[] = [
|
||||||
|
'creature', 'instant', 'sorcery', 'enchantment', 'artifact', 'planeswalker', 'land', 'battle',
|
||||||
|
]
|
||||||
|
|
||||||
|
const PLAYSTYLE_SUGGESTIONS = [
|
||||||
|
'Aggro', 'Control', 'Combo', 'Midrange', 'Stax', 'Pillowfort',
|
||||||
|
'Tokens', 'Reanimator', 'Aristocrats', 'Voltron', 'Group Hug', 'Superfriends',
|
||||||
|
]
|
||||||
|
|
||||||
|
// ─── Existing card input (Complete / Cull modes) ──────────────────────────────
|
||||||
|
|
||||||
|
function ExistingCardsInput() {
|
||||||
|
const { existingCards, setExistingCards } = useDeckBuilderStore()
|
||||||
|
const [raw, setRaw] = useState('')
|
||||||
|
const [pasteMode, setPasteMode] = useState(true)
|
||||||
|
|
||||||
|
const parseList = () => {
|
||||||
|
const lines = raw.split('\n').map((l) => l.trim()).filter(Boolean)
|
||||||
|
const parsed = lines.map((line) => {
|
||||||
|
// Handle "1x Card Name" or "Card Name" or "1 Card Name"
|
||||||
|
const match = line.match(/^(\d+)[x\s]+(.+)$/)
|
||||||
|
if (match) return { card_name: match[2].trim(), quantity: parseInt(match[1]) }
|
||||||
|
return { card_name: line, quantity: 1 }
|
||||||
|
})
|
||||||
|
setExistingCards(parsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeCard = (idx: number) => {
|
||||||
|
setExistingCards(existingCards.filter((_, i) => i !== idx))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setPasteMode(true)}
|
||||||
|
className={`text-xs px-3 py-1.5 rounded ${pasteMode ? 'bg-accent-violet text-white' : 'bg-bg-elevated text-text-muted hover:text-text-primary'}`}
|
||||||
|
>
|
||||||
|
Paste list
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setPasteMode(false)}
|
||||||
|
className={`text-xs px-3 py-1.5 rounded ${!pasteMode ? 'bg-accent-violet text-white' : 'bg-bg-elevated text-text-muted hover:text-text-primary'}`}
|
||||||
|
>
|
||||||
|
View cards ({existingCards.length})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{pasteMode ? (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Textarea
|
||||||
|
value={raw}
|
||||||
|
onChange={(e) => setRaw(e.target.value)}
|
||||||
|
placeholder={"1x Sol Ring\n1x Arcane Signet\n2x Island\n..."}
|
||||||
|
rows={8}
|
||||||
|
hint="One card per line. Quantities like '1x' or '1 ' are optional."
|
||||||
|
/>
|
||||||
|
<Button variant="secondary" size="sm" onClick={parseList} disabled={!raw.trim()}>
|
||||||
|
Import {raw.split('\n').filter((l) => l.trim()).length} cards
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-1.5 max-h-64 overflow-y-auto pr-1">
|
||||||
|
{existingCards.length === 0 ? (
|
||||||
|
<p className="text-text-muted text-sm py-4 text-center">No cards added yet</p>
|
||||||
|
) : (
|
||||||
|
existingCards.map((card, idx) => (
|
||||||
|
<div key={idx} className="flex items-center justify-between bg-bg-elevated rounded px-3 py-2">
|
||||||
|
<span className="text-sm text-text-primary truncate">
|
||||||
|
{card.quantity && card.quantity > 1 && (
|
||||||
|
<span className="text-text-muted mr-2">{card.quantity}×</span>
|
||||||
|
)}
|
||||||
|
{card.card_name}
|
||||||
|
</span>
|
||||||
|
<button onClick={() => removeCard(idx)} className="text-text-muted hover:text-danger ml-2 flex-shrink-0">
|
||||||
|
<X className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Constraints panel ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function ConstraintsPanel() {
|
||||||
|
const { constraints, setConstraints } = useDeckBuilderStore()
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border border-bg-border rounded-lg overflow-hidden">
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen((o) => !o)}
|
||||||
|
className="w-full flex items-center justify-between px-4 py-3 bg-bg-elevated hover:bg-bg-border/50 transition-colors"
|
||||||
|
>
|
||||||
|
<span className="text-sm font-medium text-text-primary">Constraints</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{constraints.prefer_owned && <Badge variant="owned">Prefer owned</Badge>}
|
||||||
|
{constraints.budget_enabled && (
|
||||||
|
<Badge variant="unowned">${constraints.budget_amount}</Badge>
|
||||||
|
)}
|
||||||
|
{open ? <ChevronUp className="w-4 h-4 text-text-muted" /> : <ChevronDown className="w-4 h-4 text-text-muted" />}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div className="px-4 py-4 flex flex-col gap-4 bg-bg-surface border-t border-bg-border animate-fade-in">
|
||||||
|
<Toggle
|
||||||
|
checked={constraints.prefer_owned}
|
||||||
|
onChange={(v) => setConstraints({ prefer_owned: v })}
|
||||||
|
label="Prefer owned cards"
|
||||||
|
description="Cards you own will be prioritised; others marked [UNOWNED]"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<Toggle
|
||||||
|
checked={constraints.budget_enabled}
|
||||||
|
onChange={(v) => setConstraints({ budget_enabled: v })}
|
||||||
|
label="Budget limit"
|
||||||
|
description="Cap the total card cost"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{constraints.budget_enabled && (
|
||||||
|
<div className="flex gap-3 pl-13 animate-fade-in">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
placeholder="100"
|
||||||
|
value={constraints.budget_amount ?? ''}
|
||||||
|
onChange={(e) => setConstraints({ budget_amount: e.target.value ? Number(e.target.value) : null })}
|
||||||
|
className="w-32"
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
value={constraints.budget_scope}
|
||||||
|
onChange={(e) => setConstraints({ budget_scope: e.target.value as 'purchase' | 'total' })}
|
||||||
|
>
|
||||||
|
<option value="purchase">Purchases only</option>
|
||||||
|
<option value="total">Total deck</option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main page ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function BuildDeckPage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const {
|
||||||
|
mode, setMode, commander, setCommander, playstyle, setPlaystyle,
|
||||||
|
deckName, setDeckName, existingCards, targetCount, setTargetCount,
|
||||||
|
isBuilding, setBuilding, lastError, setError,
|
||||||
|
} = useDeckBuilderStore()
|
||||||
|
|
||||||
|
const handleBuild = async () => {
|
||||||
|
if (!commander.trim()) {
|
||||||
|
setError('Commander name is required.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setError(null)
|
||||||
|
setBuilding(true)
|
||||||
|
try {
|
||||||
|
const constraints = useDeckBuilderStore.getState().constraints
|
||||||
|
let res
|
||||||
|
|
||||||
|
if (mode === 'generate') {
|
||||||
|
res = await decksApi.generate({
|
||||||
|
commander: commander.trim(),
|
||||||
|
playstyle: playstyle || undefined,
|
||||||
|
name: deckName || undefined,
|
||||||
|
constraints,
|
||||||
|
})
|
||||||
|
} else if (mode === 'complete') {
|
||||||
|
res = await decksApi.complete({
|
||||||
|
commander: commander.trim(),
|
||||||
|
playstyle: playstyle || undefined,
|
||||||
|
name: deckName || undefined,
|
||||||
|
existing_cards: existingCards,
|
||||||
|
constraints,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
res = await decksApi.cull({
|
||||||
|
commander: commander.trim(),
|
||||||
|
name: deckName || undefined,
|
||||||
|
existing_cards: existingCards,
|
||||||
|
target_count: targetCount,
|
||||||
|
constraints,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
navigate(`/decks/${res.data.id}`)
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof AxiosError) {
|
||||||
|
const detail = err.response?.data?.detail
|
||||||
|
setError(typeof detail === 'string' ? detail : 'Build failed. Please try again.')
|
||||||
|
} else {
|
||||||
|
setError('Build failed. Please try again.')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setBuilding(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl mx-auto px-4 py-8">
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="font-display text-2xl text-text-primary mb-1">Deck Builder</h1>
|
||||||
|
<p className="text-text-muted text-sm">Forge your Commander deck with AI assistance</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
{/* Mode selector */}
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-text-secondary uppercase tracking-wider mb-3">Mode</p>
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
{MODES.map((m) => (
|
||||||
|
<button
|
||||||
|
key={m.id}
|
||||||
|
onClick={() => setMode(m.id)}
|
||||||
|
className={`
|
||||||
|
flex flex-col items-center gap-2 p-4 rounded-lg border text-center transition-all
|
||||||
|
${mode === m.id
|
||||||
|
? 'border-accent-violet bg-accent-violet/10 text-accent-violet-light shadow-glow'
|
||||||
|
: 'border-bg-border bg-bg-surface text-text-secondary hover:border-accent-violet/50 hover:text-text-primary'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{m.icon}
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">{m.label}</p>
|
||||||
|
<p className="text-xs mt-0.5 opacity-70 leading-tight">{m.description}</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Panel className="p-5 flex flex-col gap-5">
|
||||||
|
{/* Commander */}
|
||||||
|
<Input
|
||||||
|
label="Commander"
|
||||||
|
value={commander}
|
||||||
|
onChange={(e) => setCommander(e.target.value)}
|
||||||
|
placeholder="e.g. Atraxa, Praetors' Voice"
|
||||||
|
hint="Enter the exact card name of your commander"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Playstyle (not for cull) */}
|
||||||
|
{mode !== 'cull' && (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Input
|
||||||
|
label="Playstyle (optional)"
|
||||||
|
value={playstyle}
|
||||||
|
onChange={(e) => setPlaystyle(e.target.value)}
|
||||||
|
placeholder="e.g. Proliferate counters, Superfriends..."
|
||||||
|
/>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{PLAYSTYLE_SUGGESTIONS.map((s) => (
|
||||||
|
<button
|
||||||
|
key={s}
|
||||||
|
onClick={() => setPlaystyle(s)}
|
||||||
|
className={`text-xs px-2.5 py-1 rounded-full border transition-all ${
|
||||||
|
playstyle === s
|
||||||
|
? 'bg-accent-violet/20 border-accent-violet/50 text-accent-violet-light'
|
||||||
|
: 'bg-bg-elevated border-bg-border text-text-muted hover:text-text-secondary hover:border-bg-border'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{s}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Deck name */}
|
||||||
|
<Input
|
||||||
|
label="Deck name (optional)"
|
||||||
|
value={deckName}
|
||||||
|
onChange={(e) => setDeckName(e.target.value)}
|
||||||
|
placeholder="AI will name it if left blank"
|
||||||
|
/>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
{/* Existing cards input for complete/cull */}
|
||||||
|
{(mode === 'complete' || mode === 'cull') && (
|
||||||
|
<Panel className="p-5">
|
||||||
|
<p className="text-xs font-medium text-text-secondary uppercase tracking-wider mb-3">
|
||||||
|
{mode === 'cull' ? 'Current decklist' : 'Existing cards'}
|
||||||
|
</p>
|
||||||
|
<ExistingCardsInput />
|
||||||
|
{mode === 'cull' && (
|
||||||
|
<div className="mt-4 flex items-center gap-3">
|
||||||
|
<p className="text-sm text-text-secondary">Trim to</p>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={targetCount}
|
||||||
|
onChange={(e) => setTargetCount(Number(e.target.value))}
|
||||||
|
className="w-20"
|
||||||
|
min={1}
|
||||||
|
max={99}
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-text-secondary">cards</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Constraints */}
|
||||||
|
<ConstraintsPanel />
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
|
{lastError && <ErrorMessage message={lastError} />}
|
||||||
|
|
||||||
|
{/* Build button */}
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Button
|
||||||
|
size="lg"
|
||||||
|
loading={isBuilding}
|
||||||
|
onClick={handleBuild}
|
||||||
|
className="w-full"
|
||||||
|
icon={!isBuilding ? <Wand2 className="w-5 h-5" /> : undefined}
|
||||||
|
>
|
||||||
|
{isBuilding
|
||||||
|
? 'Forging your deck…'
|
||||||
|
: mode === 'generate'
|
||||||
|
? 'Generate deck'
|
||||||
|
: mode === 'complete'
|
||||||
|
? 'Complete deck'
|
||||||
|
: 'Cull deck'}
|
||||||
|
</Button>
|
||||||
|
{isBuilding && (
|
||||||
|
<p className="text-center text-xs text-text-muted animate-pulse">
|
||||||
|
This usually takes 30–60 seconds…
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { Link, useNavigate } from 'react-router-dom'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { Plus, Layers, Trash2, ChevronRight } from 'lucide-react'
|
||||||
|
import { decksApi } from '@/api'
|
||||||
|
import { Button, Badge, Spinner, Panel, EmptyState, Skeleton } from '@/components/ui'
|
||||||
|
import type { DeckSummary, DeckMode } from '@/types'
|
||||||
|
|
||||||
|
const MODE_LABELS: Record<DeckMode, string> = {
|
||||||
|
generate: 'Generated',
|
||||||
|
complete: 'Completed',
|
||||||
|
cull: 'Culled',
|
||||||
|
}
|
||||||
|
|
||||||
|
function DeckCard({ deck }: { deck: DeckSummary }) {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const date = new Date(deck.created_at).toLocaleDateString(undefined, {
|
||||||
|
month: 'short', day: 'numeric', year: 'numeric',
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(`/decks/${deck.id}`)}
|
||||||
|
className="group w-full text-left bg-bg-surface border border-bg-border hover:border-accent-violet/50 rounded-lg p-4 transition-all hover:shadow-glow"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="font-display text-base text-text-primary group-hover:text-accent-violet-light transition-colors truncate">
|
||||||
|
{deck.name}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-text-secondary mt-0.5 truncate">{deck.commander}</p>
|
||||||
|
</div>
|
||||||
|
<ChevronRight className="w-4 h-4 text-text-muted group-hover:text-accent-violet-light transition-colors flex-shrink-0 mt-1" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2 mt-3">
|
||||||
|
<Badge variant="mode">{MODE_LABELS[deck.mode]}</Badge>
|
||||||
|
{deck.playstyle && (
|
||||||
|
<Badge variant="default">{deck.playstyle}</Badge>
|
||||||
|
)}
|
||||||
|
<span className="text-xs text-text-muted ml-auto">{date}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{deck.card_count != null && (
|
||||||
|
<p className="text-xs text-text-muted mt-2">{deck.card_count} cards</p>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DeckCardSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="bg-bg-surface border border-bg-border rounded-lg p-4">
|
||||||
|
<Skeleton className="h-5 w-48 mb-2" />
|
||||||
|
<Skeleton className="h-4 w-32 mb-4" />
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Skeleton className="h-5 w-20 rounded-full" />
|
||||||
|
<Skeleton className="h-5 w-16 rounded-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DeckListPage() {
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['decks'],
|
||||||
|
queryFn: async () => (await decksApi.list()).data,
|
||||||
|
})
|
||||||
|
|
||||||
|
const decks: DeckSummary[] = data?.items ?? []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-3xl mx-auto px-4 py-8">
|
||||||
|
<div className="flex items-center justify-between mb-8">
|
||||||
|
<div>
|
||||||
|
<h1 className="font-display text-2xl text-text-primary">My Decks</h1>
|
||||||
|
{!isLoading && (
|
||||||
|
<p className="text-text-muted text-sm mt-1">{decks.length} deck{decks.length !== 1 ? 's' : ''}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Link to="/build">
|
||||||
|
<Button icon={<Plus className="w-4 h-4" />}>New deck</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{[1, 2, 3].map((n) => <DeckCardSkeleton key={n} />)}
|
||||||
|
</div>
|
||||||
|
) : decks.length === 0 ? (
|
||||||
|
<Panel className="py-4">
|
||||||
|
<EmptyState
|
||||||
|
icon={<Layers className="w-full h-full" />}
|
||||||
|
title="No decks yet"
|
||||||
|
description="Build your first Commander deck with AI assistance."
|
||||||
|
action={
|
||||||
|
<Link to="/build">
|
||||||
|
<Button icon={<Plus className="w-4 h-4" />}>Build a deck</Button>
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Panel>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{decks.map((deck) => <DeckCard key={deck.id} deck={deck} />)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,378 @@
|
|||||||
|
import React, { useState } from 'react'
|
||||||
|
import { useParams, useNavigate, Link } from 'react-router-dom'
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import {
|
||||||
|
ArrowLeft, Trash2, ChevronDown, ChevronUp, ExternalLink,
|
||||||
|
Swords, Zap, Sparkles, Shield, Cog, Star, Mountain, X
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { decksApi } from '@/api'
|
||||||
|
import { Button, Badge, Spinner, Panel, EmptyState } from '@/components/ui'
|
||||||
|
import type { Deck, DeckCard, CardSlot } from '@/types'
|
||||||
|
|
||||||
|
// ─── Slot metadata ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const SLOT_META: Record<CardSlot, { label: string; icon: React.ReactNode; color: string }> = {
|
||||||
|
creature: { label: 'Creatures', icon: <Swords className="w-4 h-4" />, color: 'text-green-400' },
|
||||||
|
instant: { label: 'Instants', icon: <Zap className="w-4 h-4" />, color: 'text-blue-400' },
|
||||||
|
sorcery: { label: 'Sorceries', icon: <Sparkles className="w-4 h-4" />, color: 'text-purple-400' },
|
||||||
|
enchantment: { label: 'Enchantments', icon: <Star className="w-4 h-4" />, color: 'text-yellow-400' },
|
||||||
|
artifact: { label: 'Artifacts', icon: <Cog className="w-4 h-4" />, color: 'text-slate-400' },
|
||||||
|
planeswalker: { label: 'Planeswalkers', icon: <Shield className="w-4 h-4" />, color: 'text-pink-400' },
|
||||||
|
land: { label: 'Lands', icon: <Mountain className="w-4 h-4" />, color: 'text-amber-600' },
|
||||||
|
battle: { label: 'Battles', icon: <Swords className="w-4 h-4" />, color: 'text-red-400' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const SLOT_ORDER: CardSlot[] = [
|
||||||
|
'creature', 'instant', 'sorcery', 'enchantment', 'artifact', 'planeswalker', 'land', 'battle',
|
||||||
|
]
|
||||||
|
|
||||||
|
// ─── Card image helper ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function cardImageUrl(card: DeckCard, size: 'small' | 'normal' = 'small'): string | null {
|
||||||
|
const sf = card.scryfall_data
|
||||||
|
if (!sf) return null
|
||||||
|
const uris = sf.image_uris
|
||||||
|
if (uris) return uris[size] ?? null
|
||||||
|
const faces = sf.card_faces
|
||||||
|
if (faces?.length) return faces[0].image_uris?.[size] ?? null
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Reasoning drawer ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function ReasoningDrawer({
|
||||||
|
card,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
card: DeckCard
|
||||||
|
onClose: () => void
|
||||||
|
}) {
|
||||||
|
const img = cardImageUrl(card, 'normal')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-end md:items-center justify-center p-4">
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-black/70 backdrop-blur-sm"
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
<div className="relative bg-bg-surface border border-bg-border rounded-xl shadow-card w-full max-w-lg animate-slide-up">
|
||||||
|
<div className="flex items-start gap-4 p-5">
|
||||||
|
{img && (
|
||||||
|
<img
|
||||||
|
src={img}
|
||||||
|
alt={card.card_name}
|
||||||
|
className="w-24 rounded-lg shadow-card flex-shrink-0 hidden sm:block"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-start justify-between gap-2 mb-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-display text-base text-text-primary">{card.card_name}</h3>
|
||||||
|
{card.scryfall_data?.type_line && (
|
||||||
|
<p className="text-xs text-text-muted mt-0.5">{card.scryfall_data.type_line}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button onClick={onClose} className="text-text-muted hover:text-text-primary flex-shrink-0">
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2 mb-3">
|
||||||
|
<Badge variant={card.is_owned ? 'owned' : 'unowned'}>
|
||||||
|
{card.is_owned ? 'Owned' : 'Unowned'}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="slot">{SLOT_META[card.slot]?.label ?? card.slot}</Badge>
|
||||||
|
{card.scryfall_data?.prices?.usd && (
|
||||||
|
<Badge variant="default">${card.scryfall_data.prices.usd}</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{card.ai_reasoning && (
|
||||||
|
<div className="bg-bg-elevated rounded-md p-3 text-sm text-text-secondary leading-relaxed">
|
||||||
|
{card.ai_reasoning}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{card.scryfall_id && (
|
||||||
|
<div className="px-5 pb-4">
|
||||||
|
<a
|
||||||
|
href={`https://scryfall.com/card/${card.scryfall_id}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-xs text-text-muted hover:text-accent-violet-light flex items-center gap-1"
|
||||||
|
>
|
||||||
|
View on Scryfall <ExternalLink className="w-3 h-3" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Card tile ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function CardTile({ card, onClick }: { card: DeckCard; onClick: () => void }) {
|
||||||
|
const img = cardImageUrl(card, 'small')
|
||||||
|
const price = card.scryfall_data?.prices?.usd
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className="group flex items-center gap-3 bg-bg-elevated hover:bg-bg-border/50 border border-bg-border hover:border-accent-violet/40 rounded-md px-3 py-2 text-left transition-all w-full"
|
||||||
|
>
|
||||||
|
{img ? (
|
||||||
|
<img
|
||||||
|
src={img}
|
||||||
|
alt={card.card_name}
|
||||||
|
className="w-8 h-10 rounded object-cover flex-shrink-0 opacity-90 group-hover:opacity-100"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-8 h-10 rounded bg-bg-border flex-shrink-0" />
|
||||||
|
)}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm text-text-primary truncate group-hover:text-accent-violet-light transition-colors">
|
||||||
|
{card.quantity > 1 && <span className="text-text-muted mr-1.5">{card.quantity}×</span>}
|
||||||
|
{card.card_name}
|
||||||
|
</p>
|
||||||
|
{card.scryfall_data?.mana_cost && (
|
||||||
|
<p className="text-xs text-text-muted truncate">{card.scryfall_data.mana_cost}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-end gap-1 flex-shrink-0">
|
||||||
|
{!card.is_owned && (
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-unowned" title="Unowned" />
|
||||||
|
)}
|
||||||
|
{price && <span className="text-xs text-text-muted">${price}</span>}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Slot group ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function SlotGroup({ slot, cards }: { slot: CardSlot; cards: DeckCard[] }) {
|
||||||
|
const [open, setOpen] = useState(true)
|
||||||
|
const [selectedCard, setSelectedCard] = useState<DeckCard | null>(null)
|
||||||
|
const meta = SLOT_META[slot]
|
||||||
|
const totalCount = cards.reduce((s, c) => s + (c.quantity || 1), 0)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-4">
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen((o) => !o)}
|
||||||
|
className="flex items-center gap-2 w-full mb-2 group"
|
||||||
|
>
|
||||||
|
<span className={`${meta.color}`}>{meta.icon}</span>
|
||||||
|
<span className="text-sm font-semibold text-text-primary">{meta.label}</span>
|
||||||
|
<span className="text-xs text-text-muted">({totalCount})</span>
|
||||||
|
<div className="flex-1 h-px bg-bg-border ml-2" />
|
||||||
|
{open ? (
|
||||||
|
<ChevronUp className="w-3.5 h-3.5 text-text-muted" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="w-3.5 h-3.5 text-text-muted" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div className="flex flex-col gap-1 animate-fade-in">
|
||||||
|
{cards.map((card) => (
|
||||||
|
<CardTile key={card.id} card={card} onClick={() => setSelectedCard(card)} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedCard && (
|
||||||
|
<ReasoningDrawer card={selectedCard} onClose={() => setSelectedCard(null)} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Cuts section ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function CutsSection({ cuts }: { cuts: { name: string; reasoning: string }[] }) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
|
||||||
|
if (!cuts.length) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Panel className="p-5">
|
||||||
|
<button onClick={() => setOpen((o) => !o)} className="flex items-center justify-between w-full mb-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<X className="w-4 h-4 text-danger" />
|
||||||
|
<span className="font-display text-sm text-text-primary">Suggested cuts ({cuts.length})</span>
|
||||||
|
</div>
|
||||||
|
{open ? <ChevronUp className="w-4 h-4 text-text-muted" /> : <ChevronDown className="w-4 h-4 text-text-muted" />}
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className="flex flex-col gap-2 mt-3 animate-fade-in">
|
||||||
|
{cuts.map((cut, i) => (
|
||||||
|
<div key={i} className="flex gap-3 bg-danger/5 border border-danger/20 rounded-md p-3">
|
||||||
|
<span className="text-xs text-danger font-medium mt-0.5 flex-shrink-0">#{i + 1}</span>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-text-primary">{cut.name}</p>
|
||||||
|
<p className="text-xs text-text-secondary mt-0.5 leading-relaxed">{cut.reasoning}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main page ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function DeckViewPage() {
|
||||||
|
const { id } = useParams<{ id: string }>()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
const { data: deck, isLoading, error } = useQuery<Deck>({
|
||||||
|
queryKey: ['deck', id],
|
||||||
|
queryFn: async () => (await decksApi.get(Number(id))).data,
|
||||||
|
enabled: !!id,
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: () => decksApi.delete(Number(id)),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['decks'] })
|
||||||
|
navigate('/decks')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<Spinner size="lg" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !deck) {
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl mx-auto px-4 py-8">
|
||||||
|
<EmptyState title="Deck not found" description="This deck may have been deleted." />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group cards by slot (excluding commander)
|
||||||
|
const nonCommanderCards = deck.cards.filter((c) => !c.is_commander)
|
||||||
|
const commander = deck.cards.find((c) => c.is_commander)
|
||||||
|
const bySlot = SLOT_ORDER.reduce<Record<CardSlot, DeckCard[]>>(
|
||||||
|
(acc, slot) => {
|
||||||
|
acc[slot] = nonCommanderCards.filter((c) => c.slot === slot)
|
||||||
|
return acc
|
||||||
|
},
|
||||||
|
{} as Record<CardSlot, DeckCard[]>
|
||||||
|
)
|
||||||
|
|
||||||
|
const ownedCount = nonCommanderCards.filter((c) => c.is_owned).length
|
||||||
|
const totalNonBasics = nonCommanderCards.filter((c) => c.slot !== 'land').length
|
||||||
|
const totalCards = nonCommanderCards.reduce((s, c) => s + (c.quantity || 1), 0)
|
||||||
|
|
||||||
|
const cuts = deck.ai_reasoning?.cuts ?? []
|
||||||
|
const unresolved = deck.ai_reasoning?.unresolved_cards ?? []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-3xl mx-auto px-4 py-8">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-start justify-between gap-4 mb-6">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Link to="/decks" className="text-text-muted hover:text-text-primary mt-1">
|
||||||
|
<ArrowLeft className="w-5 h-5" />
|
||||||
|
</Link>
|
||||||
|
<div>
|
||||||
|
<h1 className="font-display text-2xl text-text-primary">{deck.name}</h1>
|
||||||
|
<div className="flex flex-wrap items-center gap-2 mt-1">
|
||||||
|
<Badge variant="mode">{deck.mode}</Badge>
|
||||||
|
<span className="text-text-muted text-sm">•</span>
|
||||||
|
<span className="text-text-secondary text-sm">{deck.commander}</span>
|
||||||
|
{deck.playstyle && (
|
||||||
|
<>
|
||||||
|
<span className="text-text-muted text-sm">•</span>
|
||||||
|
<span className="text-text-muted text-sm">{deck.playstyle}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
size="sm"
|
||||||
|
icon={<Trash2 className="w-4 h-4" />}
|
||||||
|
loading={deleteMutation.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm('Delete this deck?')) deleteMutation.mutate()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats bar */}
|
||||||
|
<div className="grid grid-cols-3 gap-3 mb-6">
|
||||||
|
{[
|
||||||
|
{ label: 'Total cards', value: totalCards + 1 },
|
||||||
|
{ label: 'Owned', value: `${ownedCount}/${totalCards}` },
|
||||||
|
{ label: 'Non-lands', value: totalNonBasics },
|
||||||
|
].map((stat) => (
|
||||||
|
<Panel key={stat.label} className="p-3 text-center">
|
||||||
|
<p className="text-xl font-semibold text-text-primary">{stat.value}</p>
|
||||||
|
<p className="text-xs text-text-muted mt-0.5">{stat.label}</p>
|
||||||
|
</Panel>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Strategy summary */}
|
||||||
|
{deck.ai_reasoning?.strategy_summary && (
|
||||||
|
<Panel className="p-5 mb-6">
|
||||||
|
<p className="text-xs font-medium text-text-secondary uppercase tracking-wider mb-2">Strategy</p>
|
||||||
|
<p className="text-sm text-text-secondary leading-relaxed">{deck.ai_reasoning.strategy_summary}</p>
|
||||||
|
</Panel>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Commander */}
|
||||||
|
{commander && (
|
||||||
|
<div className="mb-6">
|
||||||
|
<p className="text-xs font-medium text-text-secondary uppercase tracking-wider mb-2">Commander</p>
|
||||||
|
<div className="bg-accent-violet/10 border border-accent-violet/30 rounded-lg px-4 py-3 flex items-center gap-3">
|
||||||
|
<span className="font-display text-base text-accent-violet-light">{commander.card_name}</span>
|
||||||
|
<Badge variant={commander.is_owned ? 'owned' : 'unowned'}>
|
||||||
|
{commander.is_owned ? 'Owned' : 'Unowned'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Unresolved cards warning */}
|
||||||
|
{unresolved.length > 0 && (
|
||||||
|
<div className="bg-unowned/10 border border-unowned/30 rounded-lg p-4 mb-6 flex gap-3">
|
||||||
|
<span className="text-unowned flex-shrink-0">⚠</span>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-unowned">Unresolved cards ({unresolved.length})</p>
|
||||||
|
<p className="text-xs text-text-secondary mt-1">
|
||||||
|
These card names could not be verified on Scryfall:{' '}
|
||||||
|
{unresolved.join(', ')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Card grid by slot */}
|
||||||
|
<div>
|
||||||
|
{SLOT_ORDER.map((slot) => {
|
||||||
|
const cards = bySlot[slot]
|
||||||
|
if (!cards.length) return null
|
||||||
|
return <SlotGroup key={slot} slot={slot} cards={cards} />
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cuts */}
|
||||||
|
{cuts.length > 0 && <CutsSection cuts={cuts} />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
import { authApi, tokenStorage, usersApi } from '@/api'
|
||||||
|
import type { User } from '@/types'
|
||||||
|
|
||||||
|
interface AuthState {
|
||||||
|
user: User | null
|
||||||
|
loading: boolean
|
||||||
|
initialized: boolean
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
login: (email: string, password: string) => Promise<void>
|
||||||
|
register: (email: string, password: string, displayName?: string) => Promise<void>
|
||||||
|
logout: () => void
|
||||||
|
fetchMe: () => Promise<void>
|
||||||
|
initialize: () => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAuthStore = create<AuthState>((set) => ({
|
||||||
|
user: null,
|
||||||
|
loading: false,
|
||||||
|
initialized: false,
|
||||||
|
|
||||||
|
initialize: async () => {
|
||||||
|
const token = tokenStorage.getAccess()
|
||||||
|
if (!token) {
|
||||||
|
set({ initialized: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await usersApi.me()
|
||||||
|
set({ user: res.data, initialized: true })
|
||||||
|
} catch {
|
||||||
|
tokenStorage.clear()
|
||||||
|
set({ initialized: true })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
login: async (email, password) => {
|
||||||
|
set({ loading: true })
|
||||||
|
try {
|
||||||
|
const res = await authApi.login(email, password)
|
||||||
|
tokenStorage.set(res.data)
|
||||||
|
const meRes = await usersApi.me()
|
||||||
|
set({ user: meRes.data, loading: false })
|
||||||
|
} catch (err) {
|
||||||
|
set({ loading: false })
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
register: async (email, password, displayName) => {
|
||||||
|
set({ loading: true })
|
||||||
|
try {
|
||||||
|
await authApi.register(email, password, displayName)
|
||||||
|
set({ loading: false })
|
||||||
|
} catch (err) {
|
||||||
|
set({ loading: false })
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
logout: () => {
|
||||||
|
tokenStorage.clear()
|
||||||
|
set({ user: null })
|
||||||
|
},
|
||||||
|
|
||||||
|
fetchMe: async () => {
|
||||||
|
try {
|
||||||
|
const res = await usersApi.me()
|
||||||
|
set({ user: res.data })
|
||||||
|
} catch {
|
||||||
|
// silently fail — interceptor handles 401
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}))
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
import type { DeckMode, DeckConstraints, ExistingCard, BudgetScope } from '@/types'
|
||||||
|
|
||||||
|
const DEFAULT_CONSTRAINTS: DeckConstraints = {
|
||||||
|
prefer_owned: false,
|
||||||
|
budget_enabled: false,
|
||||||
|
budget_amount: null,
|
||||||
|
budget_scope: 'purchase',
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DeckBuilderState {
|
||||||
|
mode: DeckMode
|
||||||
|
commander: string
|
||||||
|
playstyle: string
|
||||||
|
deckName: string
|
||||||
|
constraints: DeckConstraints
|
||||||
|
existingCards: ExistingCard[] // for complete/cull modes
|
||||||
|
targetCount: number // for cull mode
|
||||||
|
isBuilding: boolean
|
||||||
|
lastError: string | null
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
setMode: (mode: DeckMode) => void
|
||||||
|
setCommander: (name: string) => void
|
||||||
|
setPlaystyle: (style: string) => void
|
||||||
|
setDeckName: (name: string) => void
|
||||||
|
setConstraints: (partial: Partial<DeckConstraints>) => void
|
||||||
|
setBudgetScope: (scope: BudgetScope) => void
|
||||||
|
setExistingCards: (cards: ExistingCard[]) => void
|
||||||
|
setTargetCount: (n: number) => void
|
||||||
|
setBuilding: (v: boolean) => void
|
||||||
|
setError: (msg: string | null) => void
|
||||||
|
reset: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useDeckBuilderStore = create<DeckBuilderState>((set) => ({
|
||||||
|
mode: 'generate',
|
||||||
|
commander: '',
|
||||||
|
playstyle: '',
|
||||||
|
deckName: '',
|
||||||
|
constraints: { ...DEFAULT_CONSTRAINTS },
|
||||||
|
existingCards: [],
|
||||||
|
targetCount: 99,
|
||||||
|
isBuilding: false,
|
||||||
|
lastError: null,
|
||||||
|
|
||||||
|
setMode: (mode) => set({ mode }),
|
||||||
|
setCommander: (commander) => set({ commander }),
|
||||||
|
setPlaystyle: (playstyle) => set({ playstyle }),
|
||||||
|
setDeckName: (deckName) => set({ deckName }),
|
||||||
|
setConstraints: (partial) =>
|
||||||
|
set((s) => ({ constraints: { ...s.constraints, ...partial } })),
|
||||||
|
setBudgetScope: (scope) =>
|
||||||
|
set((s) => ({ constraints: { ...s.constraints, budget_scope: scope } })),
|
||||||
|
setExistingCards: (existingCards) => set({ existingCards }),
|
||||||
|
setTargetCount: (targetCount) => set({ targetCount }),
|
||||||
|
setBuilding: (isBuilding) => set({ isBuilding }),
|
||||||
|
setError: (lastError) => set({ lastError }),
|
||||||
|
reset: () =>
|
||||||
|
set({
|
||||||
|
mode: 'generate',
|
||||||
|
commander: '',
|
||||||
|
playstyle: '',
|
||||||
|
deckName: '',
|
||||||
|
constraints: { ...DEFAULT_CONSTRAINTS },
|
||||||
|
existingCards: [],
|
||||||
|
targetCount: 99,
|
||||||
|
isBuilding: false,
|
||||||
|
lastError: null,
|
||||||
|
}),
|
||||||
|
}))
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
// ─── Auth & Users ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type UserRole = 'pending' | 'approved' | 'admin'
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
id: number
|
||||||
|
email: string
|
||||||
|
display_name: string | null
|
||||||
|
role: UserRole
|
||||||
|
is_active: boolean
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthTokens {
|
||||||
|
access_token: string
|
||||||
|
refresh_token: string
|
||||||
|
token_type: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Decks ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type DeckMode = 'generate' | 'complete' | 'cull'
|
||||||
|
|
||||||
|
export type CardSlot =
|
||||||
|
| 'creature'
|
||||||
|
| 'instant'
|
||||||
|
| 'sorcery'
|
||||||
|
| 'enchantment'
|
||||||
|
| 'artifact'
|
||||||
|
| 'planeswalker'
|
||||||
|
| 'land'
|
||||||
|
| 'battle'
|
||||||
|
|
||||||
|
export type BudgetScope = 'purchase' | 'total'
|
||||||
|
|
||||||
|
export interface DeckConstraints {
|
||||||
|
prefer_owned: boolean
|
||||||
|
budget_enabled: boolean
|
||||||
|
budget_amount: number | null
|
||||||
|
budget_scope: BudgetScope
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeckCard {
|
||||||
|
id: number
|
||||||
|
deck_id: number
|
||||||
|
card_name: string
|
||||||
|
slot: CardSlot
|
||||||
|
quantity: number
|
||||||
|
is_owned: boolean
|
||||||
|
is_commander: boolean
|
||||||
|
ai_reasoning: string | null
|
||||||
|
scryfall_id: string
|
||||||
|
scryfall_data: ScryfallCard | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeckCut {
|
||||||
|
name: string
|
||||||
|
reasoning: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Deck {
|
||||||
|
id: number
|
||||||
|
owner_id: number
|
||||||
|
name: string
|
||||||
|
commander: string
|
||||||
|
description: string | null
|
||||||
|
mode: DeckMode
|
||||||
|
playstyle: string | null
|
||||||
|
prefer_owned: boolean
|
||||||
|
budget_enabled: boolean
|
||||||
|
budget_amount: number | null
|
||||||
|
budget_scope: BudgetScope
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
cards: DeckCard[]
|
||||||
|
ai_reasoning: {
|
||||||
|
strategy_summary: string
|
||||||
|
unresolved_cards: string[]
|
||||||
|
cuts: DeckCut[]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeckSummary {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
commander: string
|
||||||
|
mode: DeckMode
|
||||||
|
playstyle: string | null
|
||||||
|
created_at: string
|
||||||
|
card_count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Deck Builder Requests ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface ExistingCard {
|
||||||
|
card_name: string
|
||||||
|
slot?: CardSlot
|
||||||
|
quantity?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GenerateRequest {
|
||||||
|
commander: string
|
||||||
|
playstyle?: string
|
||||||
|
name?: string
|
||||||
|
description?: string
|
||||||
|
constraints: DeckConstraints
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompleteRequest {
|
||||||
|
commander: string
|
||||||
|
playstyle?: string
|
||||||
|
name?: string
|
||||||
|
existing_cards: ExistingCard[]
|
||||||
|
constraints: DeckConstraints
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CullRequest {
|
||||||
|
commander: string
|
||||||
|
name?: string
|
||||||
|
existing_cards: ExistingCard[]
|
||||||
|
target_count: number
|
||||||
|
constraints: DeckConstraints
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Collection ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface CollectionCard {
|
||||||
|
id: number
|
||||||
|
card_name: string
|
||||||
|
set_code: string
|
||||||
|
collector_number: string
|
||||||
|
quantity: number
|
||||||
|
foil_quantity: number
|
||||||
|
scryfall_id: string
|
||||||
|
scryfall_data: ScryfallCard | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CollectionStats {
|
||||||
|
total_cards: number
|
||||||
|
unique_cards: number
|
||||||
|
foil_cards: number
|
||||||
|
estimated_value: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Scryfall ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface ScryfallCard {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
mana_cost?: string
|
||||||
|
cmc?: number
|
||||||
|
type_line?: string
|
||||||
|
oracle_text?: string
|
||||||
|
colors?: string[]
|
||||||
|
color_identity?: string[]
|
||||||
|
legalities?: Record<string, string>
|
||||||
|
prices?: { usd?: string; usd_foil?: string }
|
||||||
|
image_uris?: { small?: string; normal?: string; large?: string; art_crop?: string }
|
||||||
|
card_faces?: Array<{ image_uris?: { small?: string; normal?: string; art_crop?: string } }>
|
||||||
|
set: string
|
||||||
|
set_name?: string
|
||||||
|
collector_number?: string
|
||||||
|
rarity?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── API Responses ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface PaginatedResponse<T> {
|
||||||
|
items: T[]
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
page_size: number
|
||||||
|
pages: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiError {
|
||||||
|
detail: string | Array<{ loc: string[]; msg: string; type: string }>
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
bg: {
|
||||||
|
base: '#0D1117',
|
||||||
|
surface: '#161B22',
|
||||||
|
elevated: '#1F2937',
|
||||||
|
border: '#2D3748',
|
||||||
|
},
|
||||||
|
accent: {
|
||||||
|
violet: '#7C3AED',
|
||||||
|
'violet-light': '#9F67FF',
|
||||||
|
'violet-dim': '#4C1D95',
|
||||||
|
},
|
||||||
|
owned: '#10B981',
|
||||||
|
unowned: '#F59E0B',
|
||||||
|
danger: '#EF4444',
|
||||||
|
text: {
|
||||||
|
primary: '#E2E8F0',
|
||||||
|
secondary: '#94A3B8',
|
||||||
|
muted: '#475569',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
display: ['"Cinzel"', 'serif'],
|
||||||
|
body: ['"Inter"', 'sans-serif'],
|
||||||
|
},
|
||||||
|
boxShadow: {
|
||||||
|
card: '0 0 0 1px #2D3748, 0 4px 16px rgba(0,0,0,0.4)',
|
||||||
|
glow: '0 0 20px rgba(124,58,237,0.3)',
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
'fade-in': 'fadeIn 0.2s ease-out',
|
||||||
|
'slide-up': 'slideUp 0.25s ease-out',
|
||||||
|
shimmer: 'shimmer 1.8s infinite linear',
|
||||||
|
},
|
||||||
|
keyframes: {
|
||||||
|
fadeIn: { from: { opacity: '0' }, to: { opacity: '1' } },
|
||||||
|
slideUp: { from: { transform: 'translateY(8px)', opacity: '0' }, to: { transform: 'translateY(0)', opacity: '1' } },
|
||||||
|
shimmer: { '0%': { backgroundPosition: '-200% 0' }, '100%': { backgroundPosition: '200% 0' } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": { "@/*": ["./src/*"] }
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
resolve: {
|
||||||
|
alias: { '@': path.resolve(__dirname, './src') },
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 3000,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:8000',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
location / {
|
||||||
|
proxy_pass http://frontend:80;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Backend API — longer timeout for AI calls
|
||||||
|
location /api {
|
||||||
|
proxy_pass http://backend:8000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_read_timeout 120s;
|
||||||
|
proxy_connect_timeout 10s;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user