diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..aa7492b
--- /dev/null
+++ b/.env.example
@@ -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
diff --git a/.gitignore b/.gitignore
index a463347..6961d37 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,15 +7,19 @@ __pycache__/
*.pyo
.venv/
venv/
+*.egg-info/
# Frontend build
frontend/node_modules/
frontend/dist/
-# Logs & misc
+# Logs
*.log
-.DS_Store
# SSL certs (generated on server)
certbot/
-nginx/certbot/
\ No newline at end of file
+nginx/certbot/
+
+# OS
+.DS_Store
+Thumbs.db
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..b8e5784
--- /dev/null
+++ b/README.md
@@ -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.
diff --git a/backend/Dockerfile b/backend/Dockerfile
new file mode 100644
index 0000000..617a2e1
--- /dev/null
+++ b/backend/Dockerfile
@@ -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"]
diff --git a/backend/alembic.ini b/backend/alembic.ini
new file mode 100644
index 0000000..27e668c
--- /dev/null
+++ b/backend/alembic.ini
@@ -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
diff --git a/backend/alembic/__init__.py b/backend/alembic/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/alembic/env.py b/backend/alembic/env.py
new file mode 100644
index 0000000..5d0a281
--- /dev/null
+++ b/backend/alembic/env.py
@@ -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()
diff --git a/backend/alembic/versions/0001_initial_schema.py b/backend/alembic/versions/0001_initial_schema.py
new file mode 100644
index 0000000..1615178
--- /dev/null
+++ b/backend/alembic/versions/0001_initial_schema.py
@@ -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")
diff --git a/backend/alembic/versions/__init__.py b/backend/alembic/versions/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/app/__init__.py b/backend/app/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/app/api/routes/__init__.py b/backend/app/api/routes/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/app/api/routes/admin.py b/backend/app/api/routes/admin.py
new file mode 100644
index 0000000..d7b8ae8
--- /dev/null
+++ b/backend/app/api/routes/admin.py
@@ -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()
diff --git a/backend/app/api/routes/auth.py b/backend/app/api/routes/auth.py
new file mode 100644
index 0000000..6f8c809
--- /dev/null
+++ b/backend/app/api/routes/auth.py
@@ -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),
+ )
diff --git a/backend/app/api/routes/collection.py b/backend/app/api/routes/collection.py
new file mode 100644
index 0000000..9b68202
--- /dev/null
+++ b/backend/app/api/routes/collection.py
@@ -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()
diff --git a/backend/app/api/routes/decks.py b/backend/app/api/routes/decks.py
new file mode 100644
index 0000000..2986c33
--- /dev/null
+++ b/backend/app/api/routes/decks.py
@@ -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()
diff --git a/backend/app/api/routes/users.py b/backend/app/api/routes/users.py
new file mode 100644
index 0000000..47eaab7
--- /dev/null
+++ b/backend/app/api/routes/users.py
@@ -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
diff --git a/backend/app/core/__init__.py b/backend/app/core/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/app/core/admin_bootstrap.py b/backend/app/core/admin_bootstrap.py
new file mode 100644
index 0000000..a902ec1
--- /dev/null
+++ b/backend/app/core/admin_bootstrap.py
@@ -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}")
diff --git a/backend/app/core/config.py b/backend/app/core/config.py
new file mode 100644
index 0000000..2e7aae3
--- /dev/null
+++ b/backend/app/core/config.py
@@ -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()
diff --git a/backend/app/core/database.py b/backend/app/core/database.py
new file mode 100644
index 0000000..2ca77e1
--- /dev/null
+++ b/backend/app/core/database.py
@@ -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
diff --git a/backend/app/core/deps.py b/backend/app/core/deps.py
new file mode 100644
index 0000000..bcd481c
--- /dev/null
+++ b/backend/app/core/deps.py
@@ -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
diff --git a/backend/app/core/redis.py b/backend/app/core/redis.py
new file mode 100644
index 0000000..a9513cf
--- /dev/null
+++ b/backend/app/core/redis.py
@@ -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
diff --git a/backend/app/core/security.py b/backend/app/core/security.py
new file mode 100644
index 0000000..964f728
--- /dev/null
+++ b/backend/app/core/security.py
@@ -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
diff --git a/backend/app/main.py b/backend/app/main.py
new file mode 100644
index 0000000..4dc00c9
--- /dev/null
+++ b/backend/app/main.py
@@ -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"}
diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py
new file mode 100644
index 0000000..1a5a1fd
--- /dev/null
+++ b/backend/app/models/__init__.py
@@ -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
diff --git a/backend/app/models/collection.py b/backend/app/models/collection.py
new file mode 100644
index 0000000..01c911b
--- /dev/null
+++ b/backend/app/models/collection.py
@@ -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"),
+ )
diff --git a/backend/app/models/deck.py b/backend/app/models/deck.py
new file mode 100644
index 0000000..878ef77
--- /dev/null
+++ b/backend/app/models/deck.py
@@ -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)
diff --git a/backend/app/models/user.py b/backend/app/models/user.py
new file mode 100644
index 0000000..4ec9c20
--- /dev/null
+++ b/backend/app/models/user.py
@@ -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)
diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/app/schemas/collection.py b/backend/app/schemas/collection.py
new file mode 100644
index 0000000..ce67fa8
--- /dev/null
+++ b/backend/app/schemas/collection.py
@@ -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
diff --git a/backend/app/schemas/deck.py b/backend/app/schemas/deck.py
new file mode 100644
index 0000000..fab7ea5
--- /dev/null
+++ b/backend/app/schemas/deck.py
@@ -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}
diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py
new file mode 100644
index 0000000..674028b
--- /dev/null
+++ b/backend/app/schemas/user.py
@@ -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
diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/app/services/ai/__init__.py b/backend/app/services/ai/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/claude_client.py b/backend/app/services/ai/claude_client.py
similarity index 100%
rename from claude_client.py
rename to backend/app/services/ai/claude_client.py
diff --git a/backend/app/services/ai/constraints.py b/backend/app/services/ai/constraints.py
new file mode 100644
index 0000000..0849351
--- /dev/null
+++ b/backend/app/services/ai/constraints.py
@@ -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"
diff --git a/deck_service.py b/backend/app/services/ai/deck_service.py
similarity index 100%
rename from deck_service.py
rename to backend/app/services/ai/deck_service.py
diff --git a/prompts.py b/backend/app/services/ai/prompts.py
similarity index 100%
rename from prompts.py
rename to backend/app/services/ai/prompts.py
diff --git a/backend/app/services/imports/__init__.py b/backend/app/services/imports/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/archidekt.py b/backend/app/services/imports/archidekt.py
similarity index 100%
rename from archidekt.py
rename to backend/app/services/imports/archidekt.py
diff --git a/backend/app/services/imports/enrichment.py b/backend/app/services/imports/enrichment.py
new file mode 100644
index 0000000..6899ba9
--- /dev/null
+++ b/backend/app/services/imports/enrichment.py
@@ -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
diff --git a/manabox.py b/backend/app/services/imports/manabox.py
similarity index 100%
rename from manabox.py
rename to backend/app/services/imports/manabox.py
diff --git a/backend/app/services/imports/models.py b/backend/app/services/imports/models.py
new file mode 100644
index 0000000..d78ea61
--- /dev/null
+++ b/backend/app/services/imports/models.py
@@ -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)
diff --git a/scryfall.py b/backend/app/services/scryfall.py
similarity index 100%
rename from scryfall.py
rename to backend/app/services/scryfall.py
diff --git a/backend/requirements.txt b/backend/requirements.txt
new file mode 100644
index 0000000..4a032e7
--- /dev/null
+++ b/backend/requirements.txt
@@ -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
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..a5632e4
--- /dev/null
+++ b/docker-compose.yml
@@ -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:
diff --git a/frontend/Dockerfile b/frontend/Dockerfile
new file mode 100644
index 0000000..12fca8e
--- /dev/null
+++ b/frontend/Dockerfile
@@ -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;"]
diff --git a/frontend/index.html b/frontend/index.html
new file mode 100644
index 0000000..06efe34
--- /dev/null
+++ b/frontend/index.html
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+ Commander Forge
+
+
+
+
+
+
+
+
+
diff --git a/frontend/nginx.conf b/frontend/nginx.conf
new file mode 100644
index 0000000..2537821
--- /dev/null
+++ b/frontend/nginx.conf
@@ -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;
+}
diff --git a/frontend/package.json b/frontend/package.json
new file mode 100644
index 0000000..007a774
--- /dev/null
+++ b/frontend/package.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"
+ }
+}
diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js
new file mode 100644
index 0000000..be56e0e
--- /dev/null
+++ b/frontend/postcss.config.js
@@ -0,0 +1,3 @@
+export default {
+ plugins: { tailwindcss: {}, autoprefixer: {} },
+}
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
new file mode 100644
index 0000000..520676e
--- /dev/null
+++ b/frontend/src/App.tsx
@@ -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 (
+
+ {/* Public / guest routes */}
+ }>
+ } />
+ } />
+
+
+ {/* Pending holding page — accessible when logged in but pending */}
+ } />
+
+ {/* Protected routes */}
+ }>
+ } />
+ } />
+ } />
+ } />
+ } />
+
+ {/* Admin-only */}
+ }>
+ } />
+
+
+
+ {/* Default redirect */}
+ } />
+ } />
+
+ )
+}
diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts
new file mode 100644
index 0000000..506e827
--- /dev/null
+++ b/frontend/src/api/index.ts
@@ -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 | 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(`${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('/auth/login', { email, password }),
+
+ refresh: (refresh_token: string) =>
+ api.post('/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}`),
+}
diff --git a/frontend/src/components/layout/AppLayout.tsx b/frontend/src/components/layout/AppLayout.tsx
new file mode 100644
index 0000000..6bb8a2b
--- /dev/null
+++ b/frontend/src/components/layout/AppLayout.tsx
@@ -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 (
+
+ )
+}
+
+interface NavItem {
+ path: string
+ label: string
+ icon: React.ReactNode
+ adminOnly?: boolean
+}
+
+const NAV_ITEMS: NavItem[] = [
+ { path: '/decks', label: 'My Decks', icon: },
+ { path: '/build', label: 'Build Deck', icon: ✦ },
+ { path: '/collection', label: 'Collection', icon: },
+ { path: '/admin', label: 'Admin', icon: , 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 }) => (
+
+ )
+
+ return (
+
+ {/* Desktop sidebar */}
+
+
+
+
+ {/* Mobile overlay */}
+ {mobileOpen && (
+
+
setMobileOpen(false)}
+ />
+
+
+
+
+
+ )}
+
+ {/* Main content */}
+
+ {/* Mobile topbar */}
+
+
+
+
+ Commander Forge
+
+
+
+ {/* Page content */}
+
+ {children}
+
+
+
+ )
+}
diff --git a/frontend/src/components/layout/Guards.tsx b/frontend/src/components/layout/Guards.tsx
new file mode 100644
index 0000000..b4969cd
--- /dev/null
+++ b/frontend/src/components/layout/Guards.tsx
@@ -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 (
+
+
+
+ )
+ }
+
+ if (!user) return
+ if (user.role === 'pending') return
+
+ return (
+
+
+
+ )
+}
+
+// ─── Admin guard ──────────────────────────────────────────────────────────────
+
+export function RequireAdmin() {
+ const { user } = useAuthStore()
+ if (user?.role !== 'admin') return
+ return
+}
+
+// ─── Redirect if already logged in ───────────────────────────────────────────
+
+export function GuestOnly() {
+ const { user, initialized } = useAuthStore()
+ if (!initialized) return null
+ if (user && user.role !== 'pending') return
+ return
+}
diff --git a/frontend/src/components/ui/index.tsx b/frontend/src/components/ui/index.tsx
new file mode 100644
index 0000000..6662101
--- /dev/null
+++ b/frontend/src/components/ui/index.tsx
@@ -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
{
+ variant?: ButtonVariant
+ size?: ButtonSize
+ loading?: boolean
+ icon?: React.ReactNode
+}
+
+const variantClasses: Record = {
+ 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 = {
+ 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 (
+
+ )
+}
+
+// ─── Input ────────────────────────────────────────────────────────────────────
+
+interface InputProps extends React.InputHTMLAttributes {
+ label?: string
+ error?: string
+ hint?: string
+}
+
+export function Input({ label, error, hint, className = '', ...props }: InputProps) {
+ return (
+
+ {label && (
+
+ )}
+
+ {error &&
{error}
}
+ {hint && !error &&
{hint}
}
+
+ )
+}
+
+// ─── Textarea ─────────────────────────────────────────────────────────────────
+
+interface TextareaProps extends React.TextareaHTMLAttributes {
+ label?: string
+ error?: string
+ hint?: string
+}
+
+export function Textarea({ label, error, hint, className = '', ...props }: TextareaProps) {
+ return (
+
+ {label && (
+
+ )}
+
+ {error &&
{error}
}
+ {hint && !error &&
{hint}
}
+
+ )
+}
+
+// ─── Select ───────────────────────────────────────────────────────────────────
+
+interface SelectProps extends React.SelectHTMLAttributes {
+ label?: string
+ error?: string
+}
+
+export function Select({ label, error, className = '', children, ...props }: SelectProps) {
+ return (
+
+ {label && (
+
+ )}
+
+ {error &&
{error}
}
+
+ )
+}
+
+// ─── 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 (
+
+ )
+}
+
+// ─── Badge ────────────────────────────────────────────────────────────────────
+
+type BadgeVariant = 'owned' | 'unowned' | 'default' | 'mode' | 'slot'
+
+interface BadgeProps {
+ variant?: BadgeVariant
+ children: React.ReactNode
+ className?: string
+}
+
+const badgeVariants: Record = {
+ 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 (
+
+ {children}
+
+ )
+}
+
+// ─── Card (panel) ─────────────────────────────────────────────────────────────
+
+interface PanelProps {
+ children: React.ReactNode
+ className?: string
+ glow?: boolean
+}
+
+export function Panel({ children, className = '', glow }: PanelProps) {
+ return (
+
+ {children}
+
+ )
+}
+
+// ─── 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
+}
+
+// ─── Empty state ──────────────────────────────────────────────────────────────
+
+export function EmptyState({
+ icon,
+ title,
+ description,
+ action,
+}: {
+ icon?: React.ReactNode
+ title: string
+ description?: string
+ action?: React.ReactNode
+}) {
+ return (
+
+ {icon &&
{icon}
}
+
+
{title}
+ {description &&
{description}
}
+
+ {action}
+
+ )
+}
+
+// ─── Error message ────────────────────────────────────────────────────────────
+
+export function ErrorMessage({ message }: { message: string }) {
+ return (
+
+ {message}
+
+ )
+}
+
+// ─── Skeleton loader ──────────────────────────────────────────────────────────
+
+export function Skeleton({ className = '' }: { className?: string }) {
+ return (
+
+ )
+}
diff --git a/frontend/src/index.css b/frontend/src/index.css
new file mode 100644
index 0000000..8d37773
--- /dev/null
+++ b/frontend/src/index.css
@@ -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;
+ }
+}
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
new file mode 100644
index 0000000..eefd52c
--- /dev/null
+++ b/frontend/src/main.tsx
@@ -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
+}
+
+ReactDOM.createRoot(document.getElementById('root')!).render(
+
+
+
+
+
+
+
+)
diff --git a/frontend/src/pages/ProfilePage.tsx b/frontend/src/pages/ProfilePage.tsx
new file mode 100644
index 0000000..f68c16b
--- /dev/null
+++ b/frontend/src/pages/ProfilePage.tsx
@@ -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 (
+
+
Profile
+
+
+
+
Account
+
{user?.email}
+
{user?.role}
+
+
+
+
+
+ )
+}
diff --git a/frontend/src/pages/admin/AdminPage.tsx b/frontend/src/pages/admin/AdminPage.tsx
new file mode 100644
index 0000000..20e37bf
--- /dev/null
+++ b/frontend/src/pages/admin/AdminPage.tsx
@@ -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({
+ 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 (
+
+
+
+
Pending approvals
+ {pending.length > 0 && (
+
+ {pending.length}
+
+ )}
+
+
+ {isLoading ? (
+
+ ) : pending.length === 0 ? (
+
+ }
+ title="No pending approvals"
+ description="All registrations have been reviewed."
+ />
+
+ ) : (
+
+ {pending.map((user) => (
+
+
+
+ {user.display_name || user.email}
+
+ {user.display_name && (
+
{user.email}
+ )}
+
+ Registered {new Date(user.created_at).toLocaleDateString()}
+
+
+
+ }
+ loading={approveMutation.isPending}
+ onClick={() => approveMutation.mutate(user.id)}
+ >
+ Approve
+
+ }
+ loading={denyMutation.isPending}
+ onClick={() => denyMutation.mutate(user.id)}
+ >
+ Deny
+
+
+
+ ))}
+
+ )}
+
+ )
+}
+
+// ─── Role badge ───────────────────────────────────────────────────────────────
+
+const ROLE_STYLE: Record = {
+ 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 (
+
+ {role}
+
+ )
+}
+
+// ─── User table ───────────────────────────────────────────────────────────────
+
+function UserTable() {
+ const queryClient = useQueryClient()
+ const [roleFilter, setRoleFilter] = useState('all')
+
+ const { data: users, isLoading } = useQuery({
+ 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 (
+
+
+
+
+
All users
+ ({allUsers.length})
+
+
+ {['all', 'pending', 'approved', 'admin'].map((r) => (
+
+ ))}
+
+
+
+ {isLoading ? (
+
+ ) : allUsers.length === 0 ? (
+
+
+
+ ) : (
+
+
+
+
+
+ {['User', 'Role', 'Joined', 'Status', ''].map((h) => (
+ |
+ {h}
+ |
+ ))}
+
+
+
+ {allUsers.map((user) => (
+
+ |
+ {user.display_name || '—'}
+ {user.email}
+ |
+
+
+ |
+
+ {new Date(user.created_at).toLocaleDateString()}
+ |
+
+
+ {user.is_active ? 'Active' : 'Inactive'}
+
+ |
+
+
+ {user.role !== 'admin' && (
+
+ )}
+
+
+ |
+
+ ))}
+
+
+
+
+ )}
+
+ )
+}
+
+// ─── Main page ────────────────────────────────────────────────────────────────
+
+export function AdminPage() {
+ return (
+
+
+
Admin panel
+
Manage user access and permissions
+
+
+
+
+ )
+}
diff --git a/frontend/src/pages/auth/AuthPages.tsx b/frontend/src/pages/auth/AuthPages.tsx
new file mode 100644
index 0000000..39c94c8
--- /dev/null
+++ b/frontend/src/pages/auth/AuthPages.tsx
@@ -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 (
+
+ {/* Subtle background texture */}
+
+
+ {/* Logo */}
+
+
+
Commander Forge
+
AI-powered deck building for Commander
+
+
+
+ {children}
+
+
+
+ )
+}
+
+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 (
+
+ Sign in
+
+
+ No account?{' '}
+
+ Request access
+
+
+
+ )
+}
+
+// ─── 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 (
+
+ Request access
+
+ Accounts require admin approval before you can start building.
+
+
+
+ Already have an account?{' '}
+
+ Sign in
+
+
+
+ )
+}
+
+// ─── Pending ──────────────────────────────────────────────────────────────────
+
+export function PendingPage() {
+ const { logout } = useAuthStore()
+ const navigate = useNavigate()
+
+ return (
+
+
+
+
+
Access pending
+
+ Your account is awaiting admin approval. You'll be able to sign in once approved.
+
+
+
+
+
+ )
+}
diff --git a/frontend/src/pages/collection/CollectionPage.tsx b/frontend/src/pages/collection/CollectionPage.tsx
new file mode 100644
index 0000000..266fdfe
--- /dev/null
+++ b/frontend/src/pages/collection/CollectionPage.tsx
@@ -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(null)
+ const [error, setError] = useState('')
+ const [success, setSuccess] = useState('')
+ const fileRef = useRef(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 (
+
+ Import collection
+
+ {/* Source selector */}
+
+ {(['archidekt', 'manabox'] as const).map((s) => (
+
+ ))}
+
+
+ {/* File drop zone */}
+ 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'}
+ `}
+ >
+
setFile(e.target.files?.[0] ?? null)}
+ />
+ {file ? (
+
+ {file.name}
+
+
+ ) : (
+
+
+
Click to choose {source === 'archidekt' ? 'CSV or JSON' : 'CSV'} file
+
+ )}
+
+
+
+
+ {error && }
+ {success && (
+
+ {success}
+
+ )}
+
+ }
+ onClick={() => importMutation.mutate()}
+ >
+ Import
+
+
+ )
+}
+
+// ─── Stats panel ──────────────────────────────────────────────────────────────
+
+function StatsPanel() {
+ const { data } = useQuery({
+ queryKey: ['collection-stats'],
+ queryFn: async () => (await collectionApi.stats()).data,
+ })
+
+ if (!data) return null
+
+ return (
+
+ {[
+ { 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) => (
+
+ {s.value}
+ {s.label}
+
+ ))}
+
+ )
+}
+
+// ─── 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 (
+
+ {img ? (
+

+ ) : (
+
+ )}
+
+
{card.card_name}
+
+ {card.set_code && (
+ {card.set_code}
+ )}
+ {card.collector_number && (
+ #{card.collector_number}
+ )}
+
+
+
+
+ {card.quantity > 0 && (
+ {card.quantity}×
+ )}
+ {card.foil_quantity > 0 && (
+ {card.foil_quantity}✦
+ )}
+
+ {price &&
${price}}
+
+
+
+ )
+}
+
+// ─── 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 (
+
+
+
Collection
+ {total > 0 && (
+ }
+ loading={clearMutation.isPending}
+ onClick={() => {
+ if (confirm('Clear your entire collection? This cannot be undone.')) {
+ clearMutation.mutate()
+ }
+ }}
+ >
+ Clear all
+
+ )}
+
+
+
+
+
+
+ {/* Search */}
+
+
+ { 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"
+ />
+
+
+ {/* Card list */}
+
+ {isLoading ? (
+
+ {[1, 2, 3, 4, 5].map((n) => (
+
+ ))}
+
+ ) : cards.length === 0 ? (
+ }
+ title={search ? 'No matching cards' : 'Collection is empty'}
+ description={search ? 'Try a different search.' : 'Import your collection above.'}
+ />
+ ) : (
+
+ {cards.map((card) => (
+ deleteMutation.mutate(id)}
+ />
+ ))}
+
+ )}
+
+
+ {/* Pagination */}
+ {pages > 1 && (
+
+
{total} cards
+
+
+ Page {page} of {pages}
+
+
+
+ )}
+
+
+ )
+}
diff --git a/frontend/src/pages/decks/BuildDeckPage.tsx b/frontend/src/pages/decks/BuildDeckPage.tsx
new file mode 100644
index 0000000..116a53d
--- /dev/null
+++ b/frontend/src/pages/decks/BuildDeckPage.tsx
@@ -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: ,
+ description: 'Build a full 99-card deck from scratch',
+ },
+ {
+ id: 'complete',
+ label: 'Complete',
+ icon: ,
+ description: 'Fill the remaining slots in a partial decklist',
+ },
+ {
+ id: 'cull',
+ label: 'Cull',
+ icon: ,
+ 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 (
+
+
+
+
+
+
+ {pasteMode ? (
+
+
+ ) : (
+
+ {existingCards.length === 0 ? (
+
No cards added yet
+ ) : (
+ existingCards.map((card, idx) => (
+
+
+ {card.quantity && card.quantity > 1 && (
+ {card.quantity}×
+ )}
+ {card.card_name}
+
+
+
+ ))
+ )}
+
+ )}
+
+ )
+}
+
+// ─── Constraints panel ────────────────────────────────────────────────────────
+
+function ConstraintsPanel() {
+ const { constraints, setConstraints } = useDeckBuilderStore()
+ const [open, setOpen] = useState(false)
+
+ return (
+
+
+
+ {open && (
+
+
setConstraints({ prefer_owned: v })}
+ label="Prefer owned cards"
+ description="Cards you own will be prioritised; others marked [UNOWNED]"
+ />
+
+
+
+ )}
+
+ )
+}
+
+// ─── 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 (
+
+
+
Deck Builder
+
Forge your Commander deck with AI assistance
+
+
+
+ {/* Mode selector */}
+
+
Mode
+
+ {MODES.map((m) => (
+
+ ))}
+
+
+
+
+ {/* Commander */}
+ 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' && (
+
+
setPlaystyle(e.target.value)}
+ placeholder="e.g. Proliferate counters, Superfriends..."
+ />
+
+ {PLAYSTYLE_SUGGESTIONS.map((s) => (
+
+ ))}
+
+
+ )}
+
+ {/* Deck name */}
+ setDeckName(e.target.value)}
+ placeholder="AI will name it if left blank"
+ />
+
+
+ {/* Existing cards input for complete/cull */}
+ {(mode === 'complete' || mode === 'cull') && (
+
+
+ {mode === 'cull' ? 'Current decklist' : 'Existing cards'}
+
+
+ {mode === 'cull' && (
+
+
Trim to
+
setTargetCount(Number(e.target.value))}
+ className="w-20"
+ min={1}
+ max={99}
+ />
+
cards
+
+ )}
+
+ )}
+
+ {/* Constraints */}
+
+
+ {/* Error */}
+ {lastError &&
}
+
+ {/* Build button */}
+
+
: undefined}
+ >
+ {isBuilding
+ ? 'Forging your deck…'
+ : mode === 'generate'
+ ? 'Generate deck'
+ : mode === 'complete'
+ ? 'Complete deck'
+ : 'Cull deck'}
+
+ {isBuilding && (
+
+ This usually takes 30–60 seconds…
+
+ )}
+
+
+
+ )
+}
diff --git a/frontend/src/pages/decks/DeckListPage.tsx b/frontend/src/pages/decks/DeckListPage.tsx
new file mode 100644
index 0000000..551a616
--- /dev/null
+++ b/frontend/src/pages/decks/DeckListPage.tsx
@@ -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 = {
+ 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 (
+
+ )
+}
+
+function DeckCardSkeleton() {
+ return (
+
+ )
+}
+
+export function DeckListPage() {
+ const { data, isLoading } = useQuery({
+ queryKey: ['decks'],
+ queryFn: async () => (await decksApi.list()).data,
+ })
+
+ const decks: DeckSummary[] = data?.items ?? []
+
+ return (
+
+
+
+
My Decks
+ {!isLoading && (
+
{decks.length} deck{decks.length !== 1 ? 's' : ''}
+ )}
+
+
+
}>New deck
+
+
+
+ {isLoading ? (
+
+ {[1, 2, 3].map((n) => )}
+
+ ) : decks.length === 0 ? (
+
+ }
+ title="No decks yet"
+ description="Build your first Commander deck with AI assistance."
+ action={
+
+ }>Build a deck
+
+ }
+ />
+
+ ) : (
+
+ {decks.map((deck) => )}
+
+ )}
+
+ )
+}
diff --git a/frontend/src/pages/decks/DeckViewPage.tsx b/frontend/src/pages/decks/DeckViewPage.tsx
new file mode 100644
index 0000000..b8be905
--- /dev/null
+++ b/frontend/src/pages/decks/DeckViewPage.tsx
@@ -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 = {
+ creature: { label: 'Creatures', icon: , color: 'text-green-400' },
+ instant: { label: 'Instants', icon: , color: 'text-blue-400' },
+ sorcery: { label: 'Sorceries', icon: , color: 'text-purple-400' },
+ enchantment: { label: 'Enchantments', icon: , color: 'text-yellow-400' },
+ artifact: { label: 'Artifacts', icon: , color: 'text-slate-400' },
+ planeswalker: { label: 'Planeswalkers', icon: , color: 'text-pink-400' },
+ land: { label: 'Lands', icon: , color: 'text-amber-600' },
+ battle: { label: 'Battles', icon: , 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 (
+
+
+
+
+ {img && (
+

+ )}
+
+
+
+
{card.card_name}
+ {card.scryfall_data?.type_line && (
+
{card.scryfall_data.type_line}
+ )}
+
+
+
+
+
+ {card.is_owned ? 'Owned' : 'Unowned'}
+
+ {SLOT_META[card.slot]?.label ?? card.slot}
+ {card.scryfall_data?.prices?.usd && (
+ ${card.scryfall_data.prices.usd}
+ )}
+
+ {card.ai_reasoning && (
+
+ {card.ai_reasoning}
+
+ )}
+
+
+ {card.scryfall_id && (
+
+ )}
+
+
+ )
+}
+
+// ─── Card tile ────────────────────────────────────────────────────────────────
+
+function CardTile({ card, onClick }: { card: DeckCard; onClick: () => void }) {
+ const img = cardImageUrl(card, 'small')
+ const price = card.scryfall_data?.prices?.usd
+
+ return (
+
+ )
+}
+
+// ─── Slot group ───────────────────────────────────────────────────────────────
+
+function SlotGroup({ slot, cards }: { slot: CardSlot; cards: DeckCard[] }) {
+ const [open, setOpen] = useState(true)
+ const [selectedCard, setSelectedCard] = useState(null)
+ const meta = SLOT_META[slot]
+ const totalCount = cards.reduce((s, c) => s + (c.quantity || 1), 0)
+
+ return (
+
+
+
+ {open && (
+
+ {cards.map((card) => (
+ setSelectedCard(card)} />
+ ))}
+
+ )}
+
+ {selectedCard && (
+
setSelectedCard(null)} />
+ )}
+
+ )
+}
+
+// ─── Cuts section ─────────────────────────────────────────────────────────────
+
+function CutsSection({ cuts }: { cuts: { name: string; reasoning: string }[] }) {
+ const [open, setOpen] = useState(false)
+
+ if (!cuts.length) return null
+
+ return (
+
+
+ {open && (
+
+ {cuts.map((cut, i) => (
+
+
#{i + 1}
+
+
{cut.name}
+
{cut.reasoning}
+
+
+ ))}
+
+ )}
+
+ )
+}
+
+// ─── Main page ────────────────────────────────────────────────────────────────
+
+export function DeckViewPage() {
+ const { id } = useParams<{ id: string }>()
+ const navigate = useNavigate()
+ const queryClient = useQueryClient()
+
+ const { data: deck, isLoading, error } = useQuery({
+ 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 (
+
+
+
+ )
+ }
+
+ if (error || !deck) {
+ return (
+
+
+
+ )
+ }
+
+ // 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>(
+ (acc, slot) => {
+ acc[slot] = nonCommanderCards.filter((c) => c.slot === slot)
+ return acc
+ },
+ {} as Record
+ )
+
+ 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 (
+
+ {/* Header */}
+
+
+
+
+
+
+
{deck.name}
+
+ {deck.mode}
+ •
+ {deck.commander}
+ {deck.playstyle && (
+ <>
+ •
+ {deck.playstyle}
+ >
+ )}
+
+
+
+
+
}
+ loading={deleteMutation.isPending}
+ onClick={() => {
+ if (confirm('Delete this deck?')) deleteMutation.mutate()
+ }}
+ >
+ Delete
+
+
+
+ {/* Stats bar */}
+
+ {[
+ { label: 'Total cards', value: totalCards + 1 },
+ { label: 'Owned', value: `${ownedCount}/${totalCards}` },
+ { label: 'Non-lands', value: totalNonBasics },
+ ].map((stat) => (
+
+ {stat.value}
+ {stat.label}
+
+ ))}
+
+
+ {/* Strategy summary */}
+ {deck.ai_reasoning?.strategy_summary && (
+
+ Strategy
+ {deck.ai_reasoning.strategy_summary}
+
+ )}
+
+ {/* Commander */}
+ {commander && (
+
+
Commander
+
+ {commander.card_name}
+
+ {commander.is_owned ? 'Owned' : 'Unowned'}
+
+
+
+ )}
+
+ {/* Unresolved cards warning */}
+ {unresolved.length > 0 && (
+
+
⚠
+
+
Unresolved cards ({unresolved.length})
+
+ These card names could not be verified on Scryfall:{' '}
+ {unresolved.join(', ')}
+
+
+
+ )}
+
+ {/* Card grid by slot */}
+
+ {SLOT_ORDER.map((slot) => {
+ const cards = bySlot[slot]
+ if (!cards.length) return null
+ return
+ })}
+
+
+ {/* Cuts */}
+ {cuts.length > 0 &&
}
+
+ )
+}
diff --git a/frontend/src/store/authStore.ts b/frontend/src/store/authStore.ts
new file mode 100644
index 0000000..64be45c
--- /dev/null
+++ b/frontend/src/store/authStore.ts
@@ -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
+ register: (email: string, password: string, displayName?: string) => Promise
+ logout: () => void
+ fetchMe: () => Promise
+ initialize: () => Promise
+}
+
+export const useAuthStore = create((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
+ }
+ },
+}))
diff --git a/frontend/src/store/deckBuilderStore.ts b/frontend/src/store/deckBuilderStore.ts
new file mode 100644
index 0000000..45a63d8
--- /dev/null
+++ b/frontend/src/store/deckBuilderStore.ts
@@ -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) => 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((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,
+ }),
+}))
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts
new file mode 100644
index 0000000..ab891d3
--- /dev/null
+++ b/frontend/src/types/index.ts
@@ -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
+ 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 {
+ items: T[]
+ total: number
+ page: number
+ page_size: number
+ pages: number
+}
+
+export interface ApiError {
+ detail: string | Array<{ loc: string[]; msg: string; type: string }>
+}
diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js
new file mode 100644
index 0000000..b1ddd0b
--- /dev/null
+++ b/frontend/tailwind.config.js
@@ -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: [],
+}
diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json
new file mode 100644
index 0000000..3ec161d
--- /dev/null
+++ b/frontend/tsconfig.json
@@ -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" }]
+}
diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json
new file mode 100644
index 0000000..42872c5
--- /dev/null
+++ b/frontend/tsconfig.node.json
@@ -0,0 +1,10 @@
+{
+ "compilerOptions": {
+ "composite": true,
+ "skipLibCheck": true,
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "allowSyntheticDefaultImports": true
+ },
+ "include": ["vite.config.ts"]
+}
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
new file mode 100644
index 0000000..86e7daf
--- /dev/null
+++ b/frontend/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,
+ },
+ },
+ },
+})
diff --git a/nginx/nginx.conf b/nginx/nginx.conf
new file mode 100644
index 0000000..b09fa32
--- /dev/null
+++ b/nginx/nginx.conf
@@ -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;
+ }
+}