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 ( + + {/* Pentagon segments representing 5 mana colors */} + + {/* White */} + {/* Blue */} + {/* Black */} + {/* Red */} + {/* Green */} + + ) +} + +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 && ( + + )} +