Restructure into full project layout

This commit is contained in:
2026-06-16 23:06:16 -06:00
parent de4862b2d1
commit 57765496a6
74 changed files with 4441 additions and 3 deletions
+28
View File
@@ -0,0 +1,28 @@
# ── Security ──────────────────────────────────────────────────────────────────
# Generate with: openssl rand -hex 32
SECRET_KEY=changeme
# ── Anthropic ─────────────────────────────────────────────────────────────────
ANTHROPIC_API_KEY=sk-ant-...
ANTHROPIC_MODEL=claude-sonnet-4-6
# ── Admin bootstrap ───────────────────────────────────────────────────────────
ADMIN_EMAIL=admin@example.com
ADMIN_PASSWORD=changeme
# ── Database ──────────────────────────────────────────────────────────────────
POSTGRES_USER=mtg
POSTGRES_PASSWORD=changeme
POSTGRES_DB=mtg
DATABASE_URL=postgresql+asyncpg://mtg:changeme@db:5432/mtg
# ── Redis ─────────────────────────────────────────────────────────────────────
REDIS_URL=redis://cache:6379
# ── Scryfall ──────────────────────────────────────────────────────────────────
SCRYFALL_RATE_LIMIT_RPS=8
SCRYFALL_CACHE_TTL_SECONDS=86400
# ── Domain (for SSL setup) ────────────────────────────────────────────────────
DOMAIN=yourdomain.com
CERTBOT_EMAIL=you@example.com
+6 -2
View File
@@ -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/
# OS
.DS_Store
Thumbs.db
+24
View File
@@ -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.
+19
View File
@@ -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"]
+38
View File
@@ -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
View File
+52
View File
@@ -0,0 +1,52 @@
import asyncio
from logging.config import fileConfig
from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
from alembic import context
from app.core.config import settings
from app.core.database import Base
import app.models # noqa: F401 — ensure all models are imported
config = context.config
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL)
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
url = config.get_main_option("sqlalchemy.url")
context.configure(url=url, target_metadata=target_metadata, literal_binds=True)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection: Connection) -> None:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations() -> None:
connectable = async_engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
def run_migrations_online() -> None:
asyncio.run(run_async_migrations())
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
@@ -0,0 +1,95 @@
"""initial schema
Revision ID: 0001
Revises:
Create Date: 2024-01-01 00:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
revision = '0001'
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
'users',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('email', sa.String(255), nullable=False),
sa.Column('hashed_password', sa.String(255), nullable=False),
sa.Column('display_name', sa.String(100), nullable=True),
sa.Column('role', sa.Enum('pending', 'approved', 'admin', name='userrole'), nullable=False, server_default='pending'),
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true'),
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('email'),
)
op.create_index('ix_users_email', 'users', ['email'])
op.create_table(
'decks',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('owner_id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(200), nullable=False),
sa.Column('commander', sa.String(200), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('mode', sa.Enum('generate', 'complete', 'cull', name='deckmode'), nullable=False),
sa.Column('playstyle', sa.String(100), nullable=True),
sa.Column('prefer_owned', sa.Boolean(), nullable=False, server_default='false'),
sa.Column('budget_enabled', sa.Boolean(), nullable=False, server_default='false'),
sa.Column('budget_amount', sa.Float(), nullable=True),
sa.Column('budget_scope', sa.String(20), nullable=False, server_default='purchase'),
sa.Column('ai_reasoning', sa.JSON(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.ForeignKeyConstraint(['owner_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
)
op.create_index('ix_decks_owner_id', 'decks', ['owner_id'])
op.create_table(
'deck_cards',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('deck_id', sa.Integer(), nullable=False),
sa.Column('scryfall_id', sa.String(36), nullable=False),
sa.Column('card_name', sa.String(200), nullable=False),
sa.Column('slot', sa.Enum('creature', 'instant', 'sorcery', 'enchantment', 'artifact', 'planeswalker', 'land', 'battle', name='cardslot'), nullable=False),
sa.Column('quantity', sa.Integer(), nullable=False, server_default='1'),
sa.Column('is_owned', sa.Boolean(), nullable=False, server_default='false'),
sa.Column('is_commander', sa.Boolean(), nullable=False, server_default='false'),
sa.Column('ai_reasoning', sa.Text(), nullable=True),
sa.Column('scryfall_data', sa.JSON(), nullable=True),
sa.ForeignKeyConstraint(['deck_id'], ['decks.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
)
op.create_index('ix_deck_cards_deck_id', 'deck_cards', ['deck_id'])
op.create_table(
'user_collection',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('owner_id', sa.Integer(), nullable=False),
sa.Column('card_name', sa.String(200), nullable=False),
sa.Column('set_code', sa.String(10), nullable=False, server_default=''),
sa.Column('collector_number', sa.String(20), nullable=False, server_default=''),
sa.Column('quantity', sa.Integer(), nullable=False, server_default='0'),
sa.Column('foil_quantity', sa.Integer(), nullable=False, server_default='0'),
sa.Column('scryfall_id', sa.String(36), nullable=False, server_default=''),
sa.Column('scryfall_data', sa.JSON(), nullable=True),
sa.ForeignKeyConstraint(['owner_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('owner_id', 'scryfall_id', name='uq_owner_scryfall'),
)
op.create_index('ix_user_collection_owner_id', 'user_collection', ['owner_id'])
def downgrade() -> None:
op.drop_table('user_collection')
op.drop_table('deck_cards')
op.drop_table('decks')
op.drop_table('users')
op.execute("DROP TYPE IF EXISTS userrole")
op.execute("DROP TYPE IF EXISTS deckmode")
op.execute("DROP TYPE IF EXISTS cardslot")
View File
View File
View File
+98
View File
@@ -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()
+60
View File
@@ -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),
)
+129
View File
@@ -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()
+110
View File
@@ -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()
+30
View File
@@ -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
View File
+23
View File
@@ -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}")
+26
View File
@@ -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()
+16
View File
@@ -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
+37
View File
@@ -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
+11
View File
@@ -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
+43
View File
@@ -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
+36
View File
@@ -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"}
+3
View File
@@ -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
+21
View File
@@ -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"),
)
+56
View File
@@ -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)
+24
View File
@@ -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)
View File
+29
View File
@@ -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
+87
View File
@@ -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}
+45
View File
@@ -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
View File
View File
+25
View File
@@ -0,0 +1,25 @@
from app.schemas.deck import DeckConstraints
def build_constraint_context(constraints: DeckConstraints, owned_names: list[str] | None) -> str:
lines = []
if constraints.prefer_owned and owned_names:
lines.append("- Prefer cards the user already owns (marked [OWNED] in the list below)")
lines.append("- Mark any recommended card the user does NOT own with [UNOWNED] suffix")
if constraints.budget_enabled and constraints.budget_amount:
scope = "total deck" if constraints.budget_scope == "total" else "cards to purchase"
lines.append(f"- Budget limit: ${constraints.budget_amount:.2f} for {scope}")
if not lines:
lines.append("- No special constraints — recommend the strongest cards available")
return "\n".join(lines)
def build_owned_card_list(owned_names: list[str]) -> str:
if not owned_names:
return ""
card_list = "\n".join(f"- {name}" for name in sorted(owned_names))
return f"\nOWNED CARDS:\n{card_list}\n"
@@ -0,0 +1,63 @@
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from sqlalchemy.dialects.postgresql import insert
from app.models.collection import CollectionCard
from app.services.imports.models import RawCardRow
from app.services import scryfall
async def enrich_and_upsert(rows: list[RawCardRow], user_id: int, db: AsyncSession) -> int:
# Try set+collector first for exact matches
enriched = {}
name_lookup = []
for row in rows:
if row.set_code and row.collector_number:
card = await scryfall.get_card_by_set_and_collector(row.set_code, row.collector_number)
if card:
enriched[row.card_name.lower()] = card
continue
name_lookup.append(row.card_name)
# Batch enrich remaining by name
if name_lookup:
name_map = await scryfall.batch_enrich_by_name(name_lookup)
enriched.update(name_map)
count = 0
for row in rows:
sf_data = enriched.get(row.card_name.lower())
if not sf_data:
continue
scryfall_id = sf_data["id"]
# Upsert — add quantities if card already exists
result = await db.execute(
select(CollectionCard).where(
CollectionCard.owner_id == user_id,
CollectionCard.scryfall_id == scryfall_id,
)
)
existing = result.scalar_one_or_none()
if existing:
existing.quantity += row.quantity
existing.foil_quantity += row.foil_quantity
existing.scryfall_data = sf_data
else:
db.add(CollectionCard(
owner_id=user_id,
card_name=sf_data.get("name", row.card_name),
set_code=sf_data.get("set", row.set_code),
collector_number=sf_data.get("collector_number", row.collector_number),
quantity=row.quantity,
foil_quantity=row.foil_quantity,
scryfall_id=scryfall_id,
scryfall_data=sf_data,
))
count += 1
await db.commit()
return count
+12
View File
@@ -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)
+15
View File
@@ -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
+43
View File
@@ -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:
+14
View File
@@ -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;"]
+16
View File
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Commander Forge</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Cinzel:wght@400;600;700&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+20
View File
@@ -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;
}
+36
View File
@@ -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"
}
}
+3
View File
@@ -0,0 +1,3 @@
export default {
plugins: { tailwindcss: {}, autoprefixer: {} },
}
+43
View File
@@ -0,0 +1,43 @@
import React from 'react'
import { Routes, Route, Navigate } from 'react-router-dom'
import { RequireAuth, RequireAdmin, GuestOnly } from '@/components/layout/Guards'
import { LoginPage, RegisterPage, PendingPage } from '@/pages/auth/AuthPages'
import { DeckListPage } from '@/pages/decks/DeckListPage'
import { DeckViewPage } from '@/pages/decks/DeckViewPage'
import { BuildDeckPage } from '@/pages/decks/BuildDeckPage'
import { CollectionPage } from '@/pages/collection/CollectionPage'
import { AdminPage } from '@/pages/admin/AdminPage'
import { ProfilePage } from '@/pages/ProfilePage'
export function App() {
return (
<Routes>
{/* Public / guest routes */}
<Route element={<GuestOnly />}>
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
</Route>
{/* Pending holding page — accessible when logged in but pending */}
<Route path="/pending" element={<PendingPage />} />
{/* Protected routes */}
<Route element={<RequireAuth />}>
<Route path="/decks" element={<DeckListPage />} />
<Route path="/decks/:id" element={<DeckViewPage />} />
<Route path="/build" element={<BuildDeckPage />} />
<Route path="/collection" element={<CollectionPage />} />
<Route path="/profile" element={<ProfilePage />} />
{/* Admin-only */}
<Route element={<RequireAdmin />}>
<Route path="/admin" element={<AdminPage />} />
</Route>
</Route>
{/* Default redirect */}
<Route path="/" element={<Navigate to="/decks" replace />} />
<Route path="*" element={<Navigate to="/decks" replace />} />
</Routes>
)
}
+145
View File
@@ -0,0 +1,145 @@
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios'
import type { AuthTokens } from '@/types'
const BASE_URL = '/api'
// ─── Token storage ────────────────────────────────────────────────────────────
const ACCESS_KEY = 'mtg_access_token'
const REFRESH_KEY = 'mtg_refresh_token'
export const tokenStorage = {
getAccess: () => localStorage.getItem(ACCESS_KEY),
getRefresh: () => localStorage.getItem(REFRESH_KEY),
set: (tokens: AuthTokens) => {
localStorage.setItem(ACCESS_KEY, tokens.access_token)
localStorage.setItem(REFRESH_KEY, tokens.refresh_token)
},
clear: () => {
localStorage.removeItem(ACCESS_KEY)
localStorage.removeItem(REFRESH_KEY)
},
}
// ─── Axios instance ───────────────────────────────────────────────────────────
export const api = axios.create({
baseURL: BASE_URL,
headers: { 'Content-Type': 'application/json' },
})
// Attach access token to every request
api.interceptors.request.use((config: InternalAxiosRequestConfig) => {
const token = tokenStorage.getAccess()
if (token && config.headers) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
// Track in-flight refresh to avoid multiple simultaneous refresh calls
let refreshPromise: Promise<string> | null = null
api.interceptors.response.use(
(res) => res,
async (error: AxiosError) => {
const original = error.config as InternalAxiosRequestConfig & { _retry?: boolean }
if (error.response?.status !== 401 || original._retry) {
return Promise.reject(error)
}
original._retry = true
try {
if (!refreshPromise) {
refreshPromise = (async () => {
const refreshToken = tokenStorage.getRefresh()
if (!refreshToken) throw new Error('No refresh token')
const res = await axios.post<AuthTokens>(`${BASE_URL}/auth/refresh`, {
refresh_token: refreshToken,
})
tokenStorage.set(res.data)
return res.data.access_token
})().finally(() => { refreshPromise = null })
}
const newToken = await refreshPromise
if (original.headers) original.headers.Authorization = `Bearer ${newToken}`
return api(original)
} catch {
tokenStorage.clear()
window.location.href = '/login'
return Promise.reject(error)
}
}
)
// ─── Auth endpoints ───────────────────────────────────────────────────────────
export const authApi = {
register: (email: string, password: string, display_name?: string) =>
api.post('/auth/register', { email, password, display_name }),
login: (email: string, password: string) =>
api.post<AuthTokens>('/auth/login', { email, password }),
refresh: (refresh_token: string) =>
api.post<AuthTokens>('/auth/refresh', { refresh_token }),
}
// ─── User endpoints ───────────────────────────────────────────────────────────
export const usersApi = {
me: () => api.get('/users/me'),
updateMe: (data: { display_name?: string; password?: string }) =>
api.patch('/users/me', data),
}
// ─── Deck endpoints ───────────────────────────────────────────────────────────
export const decksApi = {
list: (page = 1, pageSize = 20) =>
api.get('/decks/', { params: { page, page_size: pageSize } }),
get: (id: number) => api.get(`/decks/${id}`),
generate: (data: object) => api.post('/decks/generate', data),
complete: (data: object) => api.post('/decks/complete', data),
cull: (data: object) => api.post('/decks/cull', data),
delete: (id: number) => api.delete(`/decks/${id}`),
}
// ─── Collection endpoints ─────────────────────────────────────────────────────
export const collectionApi = {
import: (source: 'archidekt' | 'manabox', file: File, replace = false) => {
const form = new FormData()
form.append('file', file)
return api.post(`/collection/import/${source}?replace=${replace}`, form, {
headers: { 'Content-Type': 'multipart/form-data' },
})
},
list: (search = '', page = 1, pageSize = 50) =>
api.get('/collection/', { params: { search, page, page_size: pageSize } }),
stats: () => api.get('/collection/stats'),
clear: () => api.delete('/collection/'),
deleteCard: (id: number) => api.delete(`/collection/${id}`),
}
// ─── Admin endpoints ──────────────────────────────────────────────────────────
export const adminApi = {
queue: () => api.get('/admin/queue'),
users: (role?: string) => api.get('/admin/users', { params: { role } }),
approve: (id: number) => api.post(`/admin/users/${id}/approve`),
deny: (id: number) => api.post(`/admin/users/${id}/deny`),
updateUser: (id: number, data: object) => api.patch(`/admin/users/${id}`, data),
deleteUser: (id: number) => api.delete(`/admin/users/${id}`),
}
@@ -0,0 +1,196 @@
import React, { useState } from 'react'
import { Link, useLocation, useNavigate } from 'react-router-dom'
import {
Layers,
Package,
ShieldCheck,
LogOut,
User,
ChevronLeft,
ChevronRight,
Menu,
X,
} from 'lucide-react'
import { useAuthStore } from '@/store/authStore'
// Mana pentagon SVG — the signature UI element
function ManaPentagon({ className = '' }: { className?: string }) {
return (
<svg viewBox="0 0 40 40" className={className} fill="none" xmlns="http://www.w3.org/2000/svg">
{/* Pentagon segments representing 5 mana colors */}
<polygon points="20,2 38,14 31,34 9,34 2,14" stroke="#7C3AED" strokeWidth="1.5" fill="none" opacity="0.6" />
<circle cx="20" cy="14" r="3" fill="#F9D71C" opacity="0.8" /> {/* White */}
<circle cx="29" cy="21" r="3" fill="#4A90D9" opacity="0.8" /> {/* Blue */}
<circle cx="25" cy="30" r="3" fill="#1a1a1a" stroke="#555" strokeWidth="1" /> {/* Black */}
<circle cx="15" cy="30" r="3" fill="#C0392B" opacity="0.8" /> {/* Red */}
<circle cx="11" cy="21" r="3" fill="#27AE60" opacity="0.8" /> {/* Green */}
</svg>
)
}
interface NavItem {
path: string
label: string
icon: React.ReactNode
adminOnly?: boolean
}
const NAV_ITEMS: NavItem[] = [
{ path: '/decks', label: 'My Decks', icon: <Layers className="w-5 h-5" /> },
{ path: '/build', label: 'Build Deck', icon: <span className="w-5 h-5 flex items-center justify-center text-lg leading-none"></span> },
{ path: '/collection', label: 'Collection', icon: <Package className="w-5 h-5" /> },
{ path: '/admin', label: 'Admin', icon: <ShieldCheck className="w-5 h-5" />, adminOnly: true },
]
export function AppLayout({ children }: { children: React.ReactNode }) {
const [collapsed, setCollapsed] = useState(false)
const [mobileOpen, setMobileOpen] = useState(false)
const location = useLocation()
const navigate = useNavigate()
const { user, logout } = useAuthStore()
const handleLogout = () => {
logout()
navigate('/login')
}
const visibleItems = NAV_ITEMS.filter(
(item) => !item.adminOnly || user?.role === 'admin'
)
const Sidebar = ({ mobile = false }: { mobile?: boolean }) => (
<aside
className={`
flex flex-col bg-bg-surface border-r border-bg-border h-full
transition-all duration-300
${mobile ? 'w-64' : collapsed ? 'w-16' : 'w-56'}
`}
>
{/* Logo */}
<div className={`flex items-center gap-3 p-4 border-b border-bg-border ${collapsed && !mobile ? 'justify-center' : ''}`}>
<ManaPentagon className="w-8 h-8 flex-shrink-0" />
{(!collapsed || mobile) && (
<span className="font-display text-sm font-semibold text-text-primary tracking-wide whitespace-nowrap">
Commander Forge
</span>
)}
</div>
{/* Nav */}
<nav className="flex-1 py-4 px-2 flex flex-col gap-1">
{visibleItems.map((item) => {
const active = location.pathname.startsWith(item.path)
return (
<Link
key={item.path}
to={item.path}
onClick={() => setMobileOpen(false)}
className={`
flex items-center gap-3 rounded-md px-3 py-2.5 text-sm font-medium
transition-all duration-150
${active
? 'bg-accent-violet/20 text-accent-violet-light border border-accent-violet/30'
: 'text-text-secondary hover:text-text-primary hover:bg-bg-elevated'
}
${collapsed && !mobile ? 'justify-center px-0' : ''}
`}
title={collapsed && !mobile ? item.label : undefined}
>
<span className={`flex-shrink-0 ${active ? 'text-accent-violet-light' : ''}`}>
{item.icon}
</span>
{(!collapsed || mobile) && <span>{item.label}</span>}
</Link>
)
})}
</nav>
{/* User section */}
<div className={`p-3 border-t border-bg-border flex flex-col gap-1 ${collapsed && !mobile ? 'items-center' : ''}`}>
<Link
to="/profile"
onClick={() => setMobileOpen(false)}
className={`
flex items-center gap-3 rounded-md px-3 py-2 text-sm
text-text-secondary hover:text-text-primary hover:bg-bg-elevated transition-all
${collapsed && !mobile ? 'justify-center px-0' : ''}
`}
title={collapsed && !mobile ? 'Profile' : undefined}
>
<User className="w-4 h-4 flex-shrink-0" />
{(!collapsed || mobile) && (
<span className="truncate">{user?.display_name || user?.email}</span>
)}
</Link>
<button
onClick={handleLogout}
className={`
flex items-center gap-3 rounded-md px-3 py-2 text-sm w-full
text-text-secondary hover:text-danger hover:bg-danger/10 transition-all
${collapsed && !mobile ? 'justify-center px-0' : ''}
`}
title={collapsed && !mobile ? 'Sign out' : undefined}
>
<LogOut className="w-4 h-4 flex-shrink-0" />
{(!collapsed || mobile) && <span>Sign out</span>}
</button>
{!mobile && (
<button
onClick={() => setCollapsed((c) => !c)}
className="mt-1 flex items-center justify-center w-full py-1.5 rounded text-text-muted hover:text-text-secondary hover:bg-bg-elevated transition-all"
>
{collapsed ? <ChevronRight className="w-4 h-4" /> : <ChevronLeft className="w-4 h-4" />}
</button>
)}
</div>
</aside>
)
return (
<div className="flex h-screen bg-bg-base overflow-hidden">
{/* Desktop sidebar */}
<div className="hidden md:flex flex-shrink-0">
<Sidebar />
</div>
{/* Mobile overlay */}
{mobileOpen && (
<div className="md:hidden fixed inset-0 z-50 flex">
<div
className="fixed inset-0 bg-black/60 backdrop-blur-sm"
onClick={() => setMobileOpen(false)}
/>
<div className="relative z-10 animate-slide-up">
<Sidebar mobile />
</div>
<button
onClick={() => setMobileOpen(false)}
className="absolute top-4 right-4 z-20 text-text-secondary hover:text-text-primary"
>
<X className="w-6 h-6" />
</button>
</div>
)}
{/* Main content */}
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
{/* Mobile topbar */}
<header className="md:hidden flex items-center gap-3 px-4 py-3 border-b border-bg-border bg-bg-surface">
<button onClick={() => setMobileOpen(true)} className="text-text-secondary hover:text-text-primary">
<Menu className="w-5 h-5" />
</button>
<div className="flex items-center gap-2">
<ManaPentagon className="w-6 h-6" />
<span className="font-display text-sm font-semibold text-text-primary">Commander Forge</span>
</div>
</header>
{/* Page content */}
<main className="flex-1 overflow-y-auto">
{children}
</main>
</div>
</div>
)
}
+46
View File
@@ -0,0 +1,46 @@
import React from 'react'
import { Navigate, Outlet, useLocation } from 'react-router-dom'
import { useAuthStore } from '@/store/authStore'
import { AppLayout } from '@/components/layout/AppLayout'
import { Spinner } from '@/components/ui'
// ─── Auth guard: must be logged in and approved ───────────────────────────────
export function RequireAuth() {
const { user, initialized } = useAuthStore()
const location = useLocation()
if (!initialized) {
return (
<div className="min-h-screen bg-bg-base flex items-center justify-center">
<Spinner size="lg" />
</div>
)
}
if (!user) return <Navigate to="/login" state={{ from: location }} replace />
if (user.role === 'pending') return <Navigate to="/pending" replace />
return (
<AppLayout>
<Outlet />
</AppLayout>
)
}
// ─── Admin guard ──────────────────────────────────────────────────────────────
export function RequireAdmin() {
const { user } = useAuthStore()
if (user?.role !== 'admin') return <Navigate to="/decks" replace />
return <Outlet />
}
// ─── Redirect if already logged in ───────────────────────────────────────────
export function GuestOnly() {
const { user, initialized } = useAuthStore()
if (!initialized) return null
if (user && user.role !== 'pending') return <Navigate to="/decks" replace />
return <Outlet />
}
+297
View File
@@ -0,0 +1,297 @@
import React from 'react'
import { Loader2 } from 'lucide-react'
// ─── Button ───────────────────────────────────────────────────────────────────
type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger'
type ButtonSize = 'sm' | 'md' | 'lg'
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant
size?: ButtonSize
loading?: boolean
icon?: React.ReactNode
}
const variantClasses: Record<ButtonVariant, string> = {
primary:
'bg-accent-violet hover:bg-accent-violet-light text-white shadow-glow hover:shadow-lg transition-all',
secondary:
'bg-bg-elevated border border-bg-border text-text-primary hover:border-accent-violet hover:text-accent-violet-light transition-all',
ghost: 'text-text-secondary hover:text-text-primary hover:bg-bg-elevated transition-all',
danger: 'bg-danger/20 border border-danger/50 text-danger hover:bg-danger/30 transition-all',
}
const sizeClasses: Record<ButtonSize, string> = {
sm: 'px-3 py-1.5 text-sm',
md: 'px-4 py-2 text-sm',
lg: 'px-6 py-3 text-base',
}
export function Button({
variant = 'primary',
size = 'md',
loading,
icon,
children,
disabled,
className = '',
...props
}: ButtonProps) {
return (
<button
{...props}
disabled={disabled || loading}
className={`
inline-flex items-center gap-2 rounded-md font-medium
disabled:opacity-50 disabled:cursor-not-allowed
${variantClasses[variant]} ${sizeClasses[size]} ${className}
`}
>
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : icon}
{children}
</button>
)
}
// ─── Input ────────────────────────────────────────────────────────────────────
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
label?: string
error?: string
hint?: string
}
export function Input({ label, error, hint, className = '', ...props }: InputProps) {
return (
<div className="flex flex-col gap-1">
{label && (
<label className="text-xs font-medium text-text-secondary uppercase tracking-wider">
{label}
</label>
)}
<input
{...props}
className={`
bg-bg-elevated border rounded-md px-3 py-2 text-sm text-text-primary
placeholder:text-text-muted
focus:outline-none focus:ring-2 focus:ring-accent-violet focus:border-transparent
${error ? 'border-danger' : 'border-bg-border'}
${className}
`}
/>
{error && <p className="text-xs text-danger">{error}</p>}
{hint && !error && <p className="text-xs text-text-muted">{hint}</p>}
</div>
)
}
// ─── Textarea ─────────────────────────────────────────────────────────────────
interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
label?: string
error?: string
hint?: string
}
export function Textarea({ label, error, hint, className = '', ...props }: TextareaProps) {
return (
<div className="flex flex-col gap-1">
{label && (
<label className="text-xs font-medium text-text-secondary uppercase tracking-wider">
{label}
</label>
)}
<textarea
{...props}
className={`
bg-bg-elevated border rounded-md px-3 py-2 text-sm text-text-primary
placeholder:text-text-muted resize-none
focus:outline-none focus:ring-2 focus:ring-accent-violet focus:border-transparent
${error ? 'border-danger' : 'border-bg-border'}
${className}
`}
/>
{error && <p className="text-xs text-danger">{error}</p>}
{hint && !error && <p className="text-xs text-text-muted">{hint}</p>}
</div>
)
}
// ─── Select ───────────────────────────────────────────────────────────────────
interface SelectProps extends React.SelectHTMLAttributes<HTMLSelectElement> {
label?: string
error?: string
}
export function Select({ label, error, className = '', children, ...props }: SelectProps) {
return (
<div className="flex flex-col gap-1">
{label && (
<label className="text-xs font-medium text-text-secondary uppercase tracking-wider">
{label}
</label>
)}
<select
{...props}
className={`
bg-bg-elevated border rounded-md px-3 py-2 text-sm text-text-primary
focus:outline-none focus:ring-2 focus:ring-accent-violet focus:border-transparent
${error ? 'border-danger' : 'border-bg-border'}
${className}
`}
>
{children}
</select>
{error && <p className="text-xs text-danger">{error}</p>}
</div>
)
}
// ─── Toggle ───────────────────────────────────────────────────────────────────
interface ToggleProps {
checked: boolean
onChange: (checked: boolean) => void
label?: string
description?: string
disabled?: boolean
}
export function Toggle({ checked, onChange, label, description, disabled }: ToggleProps) {
return (
<label className="flex items-start gap-3 cursor-pointer select-none group">
<div className="relative mt-0.5 flex-shrink-0">
<input
type="checkbox"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
disabled={disabled}
className="sr-only"
/>
<div
className={`
w-10 h-6 rounded-full transition-colors duration-200
${checked ? 'bg-accent-violet' : 'bg-bg-border'}
${disabled ? 'opacity-50' : ''}
`}
/>
<div
className={`
absolute top-1 left-1 w-4 h-4 rounded-full bg-white transition-transform duration-200
${checked ? 'translate-x-4' : 'translate-x-0'}
`}
/>
</div>
{(label || description) && (
<div className="flex flex-col">
{label && <span className="text-sm font-medium text-text-primary">{label}</span>}
{description && <span className="text-xs text-text-muted">{description}</span>}
</div>
)}
</label>
)
}
// ─── Badge ────────────────────────────────────────────────────────────────────
type BadgeVariant = 'owned' | 'unowned' | 'default' | 'mode' | 'slot'
interface BadgeProps {
variant?: BadgeVariant
children: React.ReactNode
className?: string
}
const badgeVariants: Record<BadgeVariant, string> = {
owned: 'bg-owned/20 text-owned border border-owned/30',
unowned: 'bg-unowned/20 text-unowned border border-unowned/30',
default: 'bg-bg-elevated text-text-secondary border border-bg-border',
mode: 'bg-accent-violet/20 text-accent-violet-light border border-accent-violet/30',
slot: 'bg-bg-surface text-text-secondary border border-bg-border',
}
export function Badge({ variant = 'default', children, className = '' }: BadgeProps) {
return (
<span
className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${badgeVariants[variant]} ${className}`}
>
{children}
</span>
)
}
// ─── Card (panel) ─────────────────────────────────────────────────────────────
interface PanelProps {
children: React.ReactNode
className?: string
glow?: boolean
}
export function Panel({ children, className = '', glow }: PanelProps) {
return (
<div
className={`
bg-bg-surface rounded-lg border border-bg-border
${glow ? 'shadow-glow' : 'shadow-card'}
${className}
`}
>
{children}
</div>
)
}
// ─── Spinner ──────────────────────────────────────────────────────────────────
export function Spinner({ size = 'md', className = '' }: { size?: 'sm' | 'md' | 'lg'; className?: string }) {
const s = { sm: 'w-4 h-4', md: 'w-6 h-6', lg: 'w-10 h-10' }[size]
return <Loader2 className={`${s} animate-spin text-accent-violet ${className}`} />
}
// ─── Empty state ──────────────────────────────────────────────────────────────
export function EmptyState({
icon,
title,
description,
action,
}: {
icon?: React.ReactNode
title: string
description?: string
action?: React.ReactNode
}) {
return (
<div className="flex flex-col items-center justify-center py-16 gap-4 text-center">
{icon && <div className="text-text-muted w-12 h-12">{icon}</div>}
<div className="flex flex-col gap-1">
<p className="text-text-primary font-medium">{title}</p>
{description && <p className="text-text-muted text-sm max-w-sm">{description}</p>}
</div>
{action}
</div>
)
}
// ─── Error message ────────────────────────────────────────────────────────────
export function ErrorMessage({ message }: { message: string }) {
return (
<div className="bg-danger/10 border border-danger/30 rounded-md p-3 text-sm text-danger">
{message}
</div>
)
}
// ─── Skeleton loader ──────────────────────────────────────────────────────────
export function Skeleton({ className = '' }: { className?: string }) {
return (
<div
className={`rounded bg-gradient-to-r from-bg-elevated via-bg-border to-bg-elevated bg-[length:200%_100%] animate-shimmer ${className}`}
/>
)
}
+57
View File
@@ -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;
}
}
+36
View File
@@ -0,0 +1,36 @@
import React, { useEffect } from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { App } from './App'
import { useAuthStore } from './store/authStore'
import './index.css'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 2, // 2 minutes
retry: 1,
},
},
})
function Root() {
const initialize = useAuthStore((s) => s.initialize)
useEffect(() => {
initialize()
}, [initialize])
return <App />
}
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<Root />
</BrowserRouter>
</QueryClientProvider>
</React.StrictMode>
)
+96
View File
@@ -0,0 +1,96 @@
import React, { useState } from 'react'
import { useMutation } from '@tanstack/react-query'
import { useAuthStore } from '@/store/authStore'
import { usersApi } from '@/api'
import { Button, Input, Panel, ErrorMessage } from '@/components/ui'
import { AxiosError } from 'axios'
export function ProfilePage() {
const { user, fetchMe } = useAuthStore()
const [displayName, setDisplayName] = useState(user?.display_name ?? '')
const [currentPw, setCurrentPw] = useState('')
const [newPw, setNewPw] = useState('')
const [success, setSuccess] = useState('')
const [error, setError] = useState('')
const updateMutation = useMutation({
mutationFn: (data: { display_name?: string; password?: string }) =>
usersApi.updateMe(data),
onSuccess: () => {
setSuccess('Profile updated.')
setCurrentPw('')
setNewPw('')
fetchMe()
},
onError: (err) => {
if (err instanceof AxiosError) {
setError(err.response?.data?.detail ?? 'Update failed.')
}
},
})
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
setSuccess('')
setError('')
const payload: { display_name?: string; password?: string } = {}
if (displayName !== user?.display_name) payload.display_name = displayName
if (newPw) payload.password = newPw
if (!Object.keys(payload).length) return
updateMutation.mutate(payload)
}
return (
<div className="max-w-lg mx-auto px-4 py-8">
<h1 className="font-display text-2xl text-text-primary mb-8">Profile</h1>
<Panel className="p-6">
<div className="mb-6 pb-6 border-b border-bg-border">
<p className="text-xs font-medium text-text-secondary uppercase tracking-wider mb-1">Account</p>
<p className="text-sm text-text-primary">{user?.email}</p>
<p className="text-xs text-text-muted mt-1 capitalize">{user?.role}</p>
</div>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
{error && <ErrorMessage message={error} />}
{success && (
<div className="bg-owned/10 border border-owned/30 rounded-md p-3 text-sm text-owned">
{success}
</div>
)}
<Input
label="Display name"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="Your name"
/>
<div className="pt-2 border-t border-bg-border">
<p className="text-xs font-medium text-text-secondary uppercase tracking-wider mb-3">
Change password
</p>
<div className="flex flex-col gap-3">
<Input
label="New password"
type="password"
value={newPw}
onChange={(e) => setNewPw(e.target.value)}
placeholder="Leave blank to keep current"
autoComplete="new-password"
/>
</div>
</div>
<Button
type="submit"
loading={updateMutation.isPending}
className="w-full mt-2"
>
Save changes
</Button>
</form>
</Panel>
</div>
)
}
+258
View File
@@ -0,0 +1,258 @@
import React, { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { CheckCircle, XCircle, ShieldCheck, Trash2, Users, Clock } from 'lucide-react'
import { adminApi } from '@/api'
import { Button, Badge, Panel, Spinner, EmptyState } from '@/components/ui'
import type { User, UserRole } from '@/types'
// ─── Approval queue ───────────────────────────────────────────────────────────
function ApprovalQueue() {
const queryClient = useQueryClient()
const { data: queue, isLoading } = useQuery<User[]>({
queryKey: ['admin-queue'],
queryFn: async () => (await adminApi.queue()).data,
refetchInterval: 30_000,
})
const approveMutation = useMutation({
mutationFn: (id: number) => adminApi.approve(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-queue'] })
queryClient.invalidateQueries({ queryKey: ['admin-users'] })
},
})
const denyMutation = useMutation({
mutationFn: (id: number) => adminApi.deny(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-queue'] })
queryClient.invalidateQueries({ queryKey: ['admin-users'] })
},
})
const pending = queue ?? []
return (
<div>
<div className="flex items-center gap-2 mb-4">
<Clock className="w-4 h-4 text-unowned" />
<h2 className="font-display text-base text-text-primary">Pending approvals</h2>
{pending.length > 0 && (
<span className="bg-unowned/20 text-unowned text-xs px-2 py-0.5 rounded-full">
{pending.length}
</span>
)}
</div>
{isLoading ? (
<div className="flex justify-center py-8"><Spinner /></div>
) : pending.length === 0 ? (
<Panel className="py-2">
<EmptyState
icon={<CheckCircle className="w-full h-full text-owned" />}
title="No pending approvals"
description="All registrations have been reviewed."
/>
</Panel>
) : (
<div className="flex flex-col gap-2">
{pending.map((user) => (
<Panel key={user.id} className="p-4 flex items-center justify-between gap-4">
<div>
<p className="text-sm font-medium text-text-primary">
{user.display_name || user.email}
</p>
{user.display_name && (
<p className="text-xs text-text-muted">{user.email}</p>
)}
<p className="text-xs text-text-muted mt-0.5">
Registered {new Date(user.created_at).toLocaleDateString()}
</p>
</div>
<div className="flex gap-2 flex-shrink-0">
<Button
variant="secondary"
size="sm"
icon={<CheckCircle className="w-4 h-4 text-owned" />}
loading={approveMutation.isPending}
onClick={() => approveMutation.mutate(user.id)}
>
Approve
</Button>
<Button
variant="danger"
size="sm"
icon={<XCircle className="w-4 h-4" />}
loading={denyMutation.isPending}
onClick={() => denyMutation.mutate(user.id)}
>
Deny
</Button>
</div>
</Panel>
))}
</div>
)}
</div>
)
}
// ─── Role badge ───────────────────────────────────────────────────────────────
const ROLE_STYLE: Record<UserRole, string> = {
admin: 'bg-accent-violet/20 text-accent-violet-light border-accent-violet/30',
approved: 'bg-owned/20 text-owned border-owned/30',
pending: 'bg-unowned/20 text-unowned border-unowned/30',
}
function RoleBadge({ role }: { role: UserRole }) {
return (
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium border capitalize ${ROLE_STYLE[role]}`}>
{role}
</span>
)
}
// ─── User table ───────────────────────────────────────────────────────────────
function UserTable() {
const queryClient = useQueryClient()
const [roleFilter, setRoleFilter] = useState<string>('all')
const { data: users, isLoading } = useQuery<User[]>({
queryKey: ['admin-users', roleFilter],
queryFn: async () =>
(await adminApi.users(roleFilter === 'all' ? undefined : roleFilter)).data,
})
const deleteMutation = useMutation({
mutationFn: (id: number) => adminApi.deleteUser(id),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['admin-users'] }),
})
const promoteMutation = useMutation({
mutationFn: ({ id, role }: { id: number; role: string }) =>
adminApi.updateUser(id, { role }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['admin-users'] }),
})
const allUsers = users ?? []
return (
<div>
<div className="flex items-center justify-between gap-4 mb-4">
<div className="flex items-center gap-2">
<Users className="w-4 h-4 text-text-muted" />
<h2 className="font-display text-base text-text-primary">All users</h2>
<span className="text-sm text-text-muted">({allUsers.length})</span>
</div>
<div className="flex gap-1.5">
{['all', 'pending', 'approved', 'admin'].map((r) => (
<button
key={r}
onClick={() => setRoleFilter(r)}
className={`text-xs px-3 py-1.5 rounded capitalize ${
roleFilter === r
? 'bg-accent-violet text-white'
: 'bg-bg-elevated text-text-muted hover:text-text-primary border border-bg-border'
}`}
>
{r}
</button>
))}
</div>
</div>
{isLoading ? (
<div className="flex justify-center py-8"><Spinner /></div>
) : allUsers.length === 0 ? (
<Panel className="py-2">
<EmptyState title="No users found" />
</Panel>
) : (
<Panel className="overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-bg-border">
{['User', 'Role', 'Joined', 'Status', ''].map((h) => (
<th
key={h}
className="px-4 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider"
>
{h}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-bg-border">
{allUsers.map((user) => (
<tr key={user.id} className="hover:bg-bg-elevated transition-colors">
<td className="px-4 py-3">
<p className="font-medium text-text-primary">{user.display_name || '—'}</p>
<p className="text-xs text-text-muted">{user.email}</p>
</td>
<td className="px-4 py-3">
<RoleBadge role={user.role} />
</td>
<td className="px-4 py-3 text-text-muted text-xs">
{new Date(user.created_at).toLocaleDateString()}
</td>
<td className="px-4 py-3">
<span className={`text-xs ${user.is_active ? 'text-owned' : 'text-danger'}`}>
{user.is_active ? 'Active' : 'Inactive'}
</span>
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2 justify-end">
{user.role !== 'admin' && (
<button
onClick={() => promoteMutation.mutate({ id: user.id, role: 'admin' })}
className="text-xs text-text-muted hover:text-accent-violet-light transition-colors"
title="Promote to admin"
>
<ShieldCheck className="w-4 h-4" />
</button>
)}
<button
onClick={() => {
if (confirm(`Delete ${user.email}? This removes all their data.`)) {
deleteMutation.mutate(user.id)
}
}}
className="text-text-muted hover:text-danger transition-colors"
title="Delete user"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</Panel>
)}
</div>
)
}
// ─── Main page ────────────────────────────────────────────────────────────────
export function AdminPage() {
return (
<div className="max-w-4xl mx-auto px-4 py-8">
<div className="mb-8">
<h1 className="font-display text-2xl text-text-primary">Admin panel</h1>
<p className="text-text-muted text-sm mt-1">Manage user access and permissions</p>
</div>
<div className="flex flex-col gap-10">
<ApprovalQueue />
<UserTable />
</div>
</div>
)
}
+228
View File
@@ -0,0 +1,228 @@
import React, { useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { useAuthStore } from '@/store/authStore'
import { Button, Input, ErrorMessage } from '@/components/ui'
import { AxiosError } from 'axios'
// ─── Shared card wrapper ──────────────────────────────────────────────────────
function AuthCard({ children }: { children: React.ReactNode }) {
return (
<div className="min-h-screen bg-bg-base flex items-center justify-center p-4">
{/* Subtle background texture */}
<div
className="absolute inset-0 opacity-5"
style={{
backgroundImage:
'radial-gradient(circle at 25% 25%, #7C3AED 0%, transparent 50%), radial-gradient(circle at 75% 75%, #4C1D95 0%, transparent 50%)',
}}
/>
<div className="relative w-full max-w-md">
{/* Logo */}
<div className="flex flex-col items-center mb-8 gap-2">
<svg viewBox="0 0 60 60" className="w-14 h-14" fill="none">
<polygon
points="30,3 57,21 46.5,51 13.5,51 3,21"
stroke="#7C3AED"
strokeWidth="2"
fill="none"
/>
<polygon
points="30,12 48,24 41,44 19,44 12,24"
stroke="#9F67FF"
strokeWidth="1"
fill="rgba(124,58,237,0.1)"
/>
<circle cx="30" cy="28" r="5" fill="#7C3AED" opacity="0.8" />
</svg>
<h1 className="font-display text-2xl text-text-primary tracking-wide">Commander Forge</h1>
<p className="text-text-muted text-sm">AI-powered deck building for Commander</p>
</div>
<div className="bg-bg-surface border border-bg-border rounded-xl p-8 shadow-card">
{children}
</div>
</div>
</div>
)
}
function getErrorMessage(err: unknown): string {
if (err instanceof AxiosError) {
const detail = err.response?.data?.detail
if (typeof detail === 'string') return detail
if (Array.isArray(detail)) return detail.map((d) => d.msg).join('. ')
}
return 'Something went wrong. Please try again.'
}
// ─── Login ────────────────────────────────────────────────────────────────────
export function LoginPage() {
const navigate = useNavigate()
const { login, loading } = useAuthStore()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
try {
await login(email, password)
navigate('/decks')
} catch (err) {
setError(getErrorMessage(err))
}
}
return (
<AuthCard>
<h2 className="font-display text-lg text-text-primary mb-6">Sign in</h2>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
{error && <ErrorMessage message={error} />}
<Input
label="Email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
autoComplete="email"
required
/>
<Input
label="Password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
autoComplete="current-password"
required
/>
<Button type="submit" loading={loading} className="w-full mt-2" size="lg">
Sign in
</Button>
</form>
<p className="text-center text-sm text-text-muted mt-6">
No account?{' '}
<Link to="/register" className="text-accent-violet-light hover:underline">
Request access
</Link>
</p>
</AuthCard>
)
}
// ─── Register ─────────────────────────────────────────────────────────────────
export function RegisterPage() {
const navigate = useNavigate()
const { register, loading } = useAuthStore()
const [email, setEmail] = useState('')
const [displayName, setDisplayName] = useState('')
const [password, setPassword] = useState('')
const [confirm, setConfirm] = useState('')
const [error, setError] = useState('')
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
if (password !== confirm) {
setError('Passwords do not match.')
return
}
try {
await register(email, password, displayName || undefined)
navigate('/pending')
} catch (err) {
setError(getErrorMessage(err))
}
}
return (
<AuthCard>
<h2 className="font-display text-lg text-text-primary mb-1">Request access</h2>
<p className="text-text-muted text-sm mb-6">
Accounts require admin approval before you can start building.
</p>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
{error && <ErrorMessage message={error} />}
<Input
label="Email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
autoComplete="email"
required
/>
<Input
label="Display name (optional)"
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="Your name"
/>
<Input
label="Password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="At least 8 characters"
autoComplete="new-password"
required
/>
<Input
label="Confirm password"
type="password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
placeholder="••••••••"
autoComplete="new-password"
required
/>
<Button type="submit" loading={loading} className="w-full mt-2" size="lg">
Request access
</Button>
</form>
<p className="text-center text-sm text-text-muted mt-6">
Already have an account?{' '}
<Link to="/login" className="text-accent-violet-light hover:underline">
Sign in
</Link>
</p>
</AuthCard>
)
}
// ─── Pending ──────────────────────────────────────────────────────────────────
export function PendingPage() {
const { logout } = useAuthStore()
const navigate = useNavigate()
return (
<AuthCard>
<div className="text-center flex flex-col items-center gap-4">
<div className="w-16 h-16 rounded-full bg-accent-violet/20 border border-accent-violet/40 flex items-center justify-center">
<svg className="w-8 h-8 text-accent-violet-light" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
d="M12 6v6l4 2M12 2a10 10 0 100 20 10 10 0 000-20z" />
</svg>
</div>
<div>
<h2 className="font-display text-lg text-text-primary mb-2">Access pending</h2>
<p className="text-text-secondary text-sm leading-relaxed">
Your account is awaiting admin approval. You'll be able to sign in once approved.
</p>
</div>
<button
onClick={() => { logout(); navigate('/login') }}
className="text-sm text-text-muted hover:text-text-secondary transition-colors"
>
Back to sign in
</button>
</div>
</AuthCard>
)
}
@@ -0,0 +1,333 @@
import React, { useState, useRef } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Upload, Package, Search, Trash2, RefreshCw, X, ChevronLeft, ChevronRight } from 'lucide-react'
import { collectionApi } from '@/api'
import { Button, Input, Panel, Badge, Spinner, EmptyState, Skeleton, ErrorMessage } from '@/components/ui'
import type { CollectionCard, CollectionStats } from '@/types'
import { AxiosError } from 'axios'
// ─── Import panel ─────────────────────────────────────────────────────────────
function ImportPanel({ onSuccess }: { onSuccess: () => void }) {
const [source, setSource] = useState<'archidekt' | 'manabox'>('archidekt')
const [replace, setReplace] = useState(false)
const [file, setFile] = useState<File | null>(null)
const [error, setError] = useState('')
const [success, setSuccess] = useState('')
const fileRef = useRef<HTMLInputElement>(null)
const importMutation = useMutation({
mutationFn: () => collectionApi.import(source, file!, replace),
onSuccess: (res) => {
setSuccess(`Imported ${res.data.imported ?? 'cards'} successfully.`)
setFile(null)
setError('')
onSuccess()
},
onError: (err) => {
if (err instanceof AxiosError) {
setError(err.response?.data?.detail ?? 'Import failed.')
} else {
setError('Import failed.')
}
},
})
return (
<Panel className="p-5 flex flex-col gap-4">
<h2 className="font-display text-base text-text-primary">Import collection</h2>
{/* Source selector */}
<div className="flex gap-2">
{(['archidekt', 'manabox'] as const).map((s) => (
<button
key={s}
onClick={() => setSource(s)}
className={`flex-1 py-2 rounded text-sm font-medium transition-all capitalize ${
source === s
? 'bg-accent-violet text-white'
: 'bg-bg-elevated text-text-muted hover:text-text-primary border border-bg-border'
}`}
>
{s}
</button>
))}
</div>
{/* File drop zone */}
<div
onClick={() => fileRef.current?.click()}
className={`
border-2 border-dashed rounded-lg p-6 text-center cursor-pointer transition-all
${file ? 'border-accent-violet/50 bg-accent-violet/5' : 'border-bg-border hover:border-accent-violet/40 hover:bg-bg-elevated'}
`}
>
<input
ref={fileRef}
type="file"
accept=".csv,.json"
className="hidden"
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
/>
{file ? (
<div className="flex items-center justify-center gap-2">
<span className="text-sm text-accent-violet-light">{file.name}</span>
<button
onClick={(e) => { e.stopPropagation(); setFile(null) }}
className="text-text-muted hover:text-danger"
>
<X className="w-4 h-4" />
</button>
</div>
) : (
<div className="flex flex-col items-center gap-2 text-text-muted">
<Upload className="w-6 h-6" />
<p className="text-sm">Click to choose {source === 'archidekt' ? 'CSV or JSON' : 'CSV'} file</p>
</div>
)}
</div>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={replace}
onChange={(e) => setReplace(e.target.checked)}
className="rounded border-bg-border"
/>
<span className="text-sm text-text-secondary">Replace existing collection</span>
</label>
{error && <ErrorMessage message={error} />}
{success && (
<div className="bg-owned/10 border border-owned/30 rounded-md p-3 text-sm text-owned">
{success}
</div>
)}
<Button
loading={importMutation.isPending}
disabled={!file}
icon={<Upload className="w-4 h-4" />}
onClick={() => importMutation.mutate()}
>
Import
</Button>
</Panel>
)
}
// ─── Stats panel ──────────────────────────────────────────────────────────────
function StatsPanel() {
const { data } = useQuery<CollectionStats>({
queryKey: ['collection-stats'],
queryFn: async () => (await collectionApi.stats()).data,
})
if (!data) return null
return (
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{[
{ label: 'Total cards', value: data.total_cards.toLocaleString() },
{ label: 'Unique cards', value: data.unique_cards.toLocaleString() },
{ label: 'Foil', value: data.foil_cards.toLocaleString() },
{
label: 'Est. value',
value: data.estimated_value != null ? `$${data.estimated_value.toFixed(2)}` : '—',
},
].map((s) => (
<Panel key={s.label} className="p-3 text-center">
<p className="text-xl font-semibold text-text-primary">{s.value}</p>
<p className="text-xs text-text-muted mt-0.5">{s.label}</p>
</Panel>
))}
</div>
)
}
// ─── Card row ─────────────────────────────────────────────────────────────────
function CollectionCardRow({
card,
onDelete,
}: {
card: CollectionCard
onDelete: (id: number) => void
}) {
const img = card.scryfall_data?.image_uris?.small
const price = card.scryfall_data?.prices?.usd
return (
<div className="flex items-center gap-3 py-2.5 px-3 hover:bg-bg-elevated rounded-md transition-colors group">
{img ? (
<img src={img} alt={card.card_name} className="w-7 h-9 rounded object-cover flex-shrink-0" />
) : (
<div className="w-7 h-9 bg-bg-border rounded flex-shrink-0" />
)}
<div className="flex-1 min-w-0">
<p className="text-sm text-text-primary truncate">{card.card_name}</p>
<div className="flex gap-2 items-center mt-0.5">
{card.set_code && (
<span className="text-xs text-text-muted uppercase">{card.set_code}</span>
)}
{card.collector_number && (
<span className="text-xs text-text-muted">#{card.collector_number}</span>
)}
</div>
</div>
<div className="flex items-center gap-3 flex-shrink-0">
<div className="text-right">
{card.quantity > 0 && (
<span className="text-xs text-text-secondary">{card.quantity}×</span>
)}
{card.foil_quantity > 0 && (
<span className="text-xs text-unowned ml-1.5">{card.foil_quantity}</span>
)}
</div>
{price && <span className="text-xs text-text-muted">${price}</span>}
<button
onClick={() => onDelete(card.id)}
className="text-text-muted hover:text-danger opacity-0 group-hover:opacity-100 transition-all"
>
<X className="w-4 h-4" />
</button>
</div>
</div>
)
}
// ─── Main page ────────────────────────────────────────────────────────────────
export function CollectionPage() {
const [search, setSearch] = useState('')
const [page, setPage] = useState(1)
const queryClient = useQueryClient()
const { data, isLoading } = useQuery({
queryKey: ['collection', search, page],
queryFn: async () => (await collectionApi.list(search, page)).data,
})
const deleteMutation = useMutation({
mutationFn: (id: number) => collectionApi.deleteCard(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['collection'] })
queryClient.invalidateQueries({ queryKey: ['collection-stats'] })
},
})
const clearMutation = useMutation({
mutationFn: () => collectionApi.clear(),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['collection'] })
queryClient.invalidateQueries({ queryKey: ['collection-stats'] })
},
})
const invalidate = () => {
queryClient.invalidateQueries({ queryKey: ['collection'] })
queryClient.invalidateQueries({ queryKey: ['collection-stats'] })
}
const cards: CollectionCard[] = data?.items ?? []
const total: number = data?.total ?? 0
const pages: number = data?.pages ?? 1
return (
<div className="max-w-3xl mx-auto px-4 py-8">
<div className="flex items-center justify-between mb-6">
<h1 className="font-display text-2xl text-text-primary">Collection</h1>
{total > 0 && (
<Button
variant="danger"
size="sm"
icon={<Trash2 className="w-4 h-4" />}
loading={clearMutation.isPending}
onClick={() => {
if (confirm('Clear your entire collection? This cannot be undone.')) {
clearMutation.mutate()
}
}}
>
Clear all
</Button>
)}
</div>
<div className="flex flex-col gap-6">
<ImportPanel onSuccess={invalidate} />
<StatsPanel />
{/* Search */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-muted" />
<input
type="text"
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(1) }}
placeholder="Search collection…"
className="w-full bg-bg-elevated border border-bg-border rounded-md pl-9 pr-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-accent-violet focus:border-transparent"
/>
</div>
{/* Card list */}
<Panel className="overflow-hidden">
{isLoading ? (
<div className="p-4 flex flex-col gap-2">
{[1, 2, 3, 4, 5].map((n) => (
<div key={n} className="flex gap-3 items-center">
<Skeleton className="w-7 h-9 rounded" />
<div className="flex-1">
<Skeleton className="h-4 w-40 mb-1.5" />
<Skeleton className="h-3 w-20" />
</div>
</div>
))}
</div>
) : cards.length === 0 ? (
<EmptyState
icon={<Package className="w-full h-full" />}
title={search ? 'No matching cards' : 'Collection is empty'}
description={search ? 'Try a different search.' : 'Import your collection above.'}
/>
) : (
<div className="divide-y divide-bg-border px-1">
{cards.map((card) => (
<CollectionCardRow
key={card.id}
card={card}
onDelete={(id) => deleteMutation.mutate(id)}
/>
))}
</div>
)}
</Panel>
{/* Pagination */}
{pages > 1 && (
<div className="flex items-center justify-between text-sm text-text-muted">
<span>{total} cards</span>
<div className="flex items-center gap-2">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
className="p-1 hover:text-text-primary disabled:opacity-30"
>
<ChevronLeft className="w-4 h-4" />
</button>
<span>Page {page} of {pages}</span>
<button
onClick={() => setPage((p) => Math.min(pages, p + 1))}
disabled={page === pages}
className="p-1 hover:text-text-primary disabled:opacity-30"
>
<ChevronRight className="w-4 h-4" />
</button>
</div>
</div>
)}
</div>
</div>
)
}
+378
View File
@@ -0,0 +1,378 @@
import React, { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Wand2, Puzzle, Scissors, Plus, X, ChevronDown, ChevronUp, AlertCircle } from 'lucide-react'
import { useDeckBuilderStore } from '@/store/deckBuilderStore'
import { decksApi } from '@/api'
import { Button, Input, Textarea, Toggle, Panel, ErrorMessage, Badge, Select } from '@/components/ui'
import type { DeckMode, CardSlot } from '@/types'
import { AxiosError } from 'axios'
// ─── Mode config ──────────────────────────────────────────────────────────────
const MODES: { id: DeckMode; label: string; icon: React.ReactNode; description: string }[] = [
{
id: 'generate',
label: 'Generate',
icon: <Wand2 className="w-5 h-5" />,
description: 'Build a full 99-card deck from scratch',
},
{
id: 'complete',
label: 'Complete',
icon: <Puzzle className="w-5 h-5" />,
description: 'Fill the remaining slots in a partial decklist',
},
{
id: 'cull',
label: 'Cull',
icon: <Scissors className="w-5 h-5" />,
description: 'Cut an oversized deck down to 99',
},
]
const CARD_SLOTS: CardSlot[] = [
'creature', 'instant', 'sorcery', 'enchantment', 'artifact', 'planeswalker', 'land', 'battle',
]
const PLAYSTYLE_SUGGESTIONS = [
'Aggro', 'Control', 'Combo', 'Midrange', 'Stax', 'Pillowfort',
'Tokens', 'Reanimator', 'Aristocrats', 'Voltron', 'Group Hug', 'Superfriends',
]
// ─── Existing card input (Complete / Cull modes) ──────────────────────────────
function ExistingCardsInput() {
const { existingCards, setExistingCards } = useDeckBuilderStore()
const [raw, setRaw] = useState('')
const [pasteMode, setPasteMode] = useState(true)
const parseList = () => {
const lines = raw.split('\n').map((l) => l.trim()).filter(Boolean)
const parsed = lines.map((line) => {
// Handle "1x Card Name" or "Card Name" or "1 Card Name"
const match = line.match(/^(\d+)[x\s]+(.+)$/)
if (match) return { card_name: match[2].trim(), quantity: parseInt(match[1]) }
return { card_name: line, quantity: 1 }
})
setExistingCards(parsed)
}
const removeCard = (idx: number) => {
setExistingCards(existingCards.filter((_, i) => i !== idx))
}
return (
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2">
<button
onClick={() => setPasteMode(true)}
className={`text-xs px-3 py-1.5 rounded ${pasteMode ? 'bg-accent-violet text-white' : 'bg-bg-elevated text-text-muted hover:text-text-primary'}`}
>
Paste list
</button>
<button
onClick={() => setPasteMode(false)}
className={`text-xs px-3 py-1.5 rounded ${!pasteMode ? 'bg-accent-violet text-white' : 'bg-bg-elevated text-text-muted hover:text-text-primary'}`}
>
View cards ({existingCards.length})
</button>
</div>
{pasteMode ? (
<div className="flex flex-col gap-2">
<Textarea
value={raw}
onChange={(e) => setRaw(e.target.value)}
placeholder={"1x Sol Ring\n1x Arcane Signet\n2x Island\n..."}
rows={8}
hint="One card per line. Quantities like '1x' or '1 ' are optional."
/>
<Button variant="secondary" size="sm" onClick={parseList} disabled={!raw.trim()}>
Import {raw.split('\n').filter((l) => l.trim()).length} cards
</Button>
</div>
) : (
<div className="flex flex-col gap-1.5 max-h-64 overflow-y-auto pr-1">
{existingCards.length === 0 ? (
<p className="text-text-muted text-sm py-4 text-center">No cards added yet</p>
) : (
existingCards.map((card, idx) => (
<div key={idx} className="flex items-center justify-between bg-bg-elevated rounded px-3 py-2">
<span className="text-sm text-text-primary truncate">
{card.quantity && card.quantity > 1 && (
<span className="text-text-muted mr-2">{card.quantity}×</span>
)}
{card.card_name}
</span>
<button onClick={() => removeCard(idx)} className="text-text-muted hover:text-danger ml-2 flex-shrink-0">
<X className="w-3.5 h-3.5" />
</button>
</div>
))
)}
</div>
)}
</div>
)
}
// ─── Constraints panel ────────────────────────────────────────────────────────
function ConstraintsPanel() {
const { constraints, setConstraints } = useDeckBuilderStore()
const [open, setOpen] = useState(false)
return (
<div className="border border-bg-border rounded-lg overflow-hidden">
<button
onClick={() => setOpen((o) => !o)}
className="w-full flex items-center justify-between px-4 py-3 bg-bg-elevated hover:bg-bg-border/50 transition-colors"
>
<span className="text-sm font-medium text-text-primary">Constraints</span>
<div className="flex items-center gap-2">
{constraints.prefer_owned && <Badge variant="owned">Prefer owned</Badge>}
{constraints.budget_enabled && (
<Badge variant="unowned">${constraints.budget_amount}</Badge>
)}
{open ? <ChevronUp className="w-4 h-4 text-text-muted" /> : <ChevronDown className="w-4 h-4 text-text-muted" />}
</div>
</button>
{open && (
<div className="px-4 py-4 flex flex-col gap-4 bg-bg-surface border-t border-bg-border animate-fade-in">
<Toggle
checked={constraints.prefer_owned}
onChange={(v) => setConstraints({ prefer_owned: v })}
label="Prefer owned cards"
description="Cards you own will be prioritised; others marked [UNOWNED]"
/>
<div className="flex flex-col gap-3">
<Toggle
checked={constraints.budget_enabled}
onChange={(v) => setConstraints({ budget_enabled: v })}
label="Budget limit"
description="Cap the total card cost"
/>
{constraints.budget_enabled && (
<div className="flex gap-3 pl-13 animate-fade-in">
<Input
type="number"
placeholder="100"
value={constraints.budget_amount ?? ''}
onChange={(e) => setConstraints({ budget_amount: e.target.value ? Number(e.target.value) : null })}
className="w-32"
/>
<Select
value={constraints.budget_scope}
onChange={(e) => setConstraints({ budget_scope: e.target.value as 'purchase' | 'total' })}
>
<option value="purchase">Purchases only</option>
<option value="total">Total deck</option>
</Select>
</div>
)}
</div>
</div>
)}
</div>
)
}
// ─── Main page ────────────────────────────────────────────────────────────────
export function BuildDeckPage() {
const navigate = useNavigate()
const {
mode, setMode, commander, setCommander, playstyle, setPlaystyle,
deckName, setDeckName, existingCards, targetCount, setTargetCount,
isBuilding, setBuilding, lastError, setError,
} = useDeckBuilderStore()
const handleBuild = async () => {
if (!commander.trim()) {
setError('Commander name is required.')
return
}
setError(null)
setBuilding(true)
try {
const constraints = useDeckBuilderStore.getState().constraints
let res
if (mode === 'generate') {
res = await decksApi.generate({
commander: commander.trim(),
playstyle: playstyle || undefined,
name: deckName || undefined,
constraints,
})
} else if (mode === 'complete') {
res = await decksApi.complete({
commander: commander.trim(),
playstyle: playstyle || undefined,
name: deckName || undefined,
existing_cards: existingCards,
constraints,
})
} else {
res = await decksApi.cull({
commander: commander.trim(),
name: deckName || undefined,
existing_cards: existingCards,
target_count: targetCount,
constraints,
})
}
navigate(`/decks/${res.data.id}`)
} catch (err) {
if (err instanceof AxiosError) {
const detail = err.response?.data?.detail
setError(typeof detail === 'string' ? detail : 'Build failed. Please try again.')
} else {
setError('Build failed. Please try again.')
}
} finally {
setBuilding(false)
}
}
return (
<div className="max-w-2xl mx-auto px-4 py-8">
<div className="mb-8">
<h1 className="font-display text-2xl text-text-primary mb-1">Deck Builder</h1>
<p className="text-text-muted text-sm">Forge your Commander deck with AI assistance</p>
</div>
<div className="flex flex-col gap-6">
{/* Mode selector */}
<div>
<p className="text-xs font-medium text-text-secondary uppercase tracking-wider mb-3">Mode</p>
<div className="grid grid-cols-3 gap-3">
{MODES.map((m) => (
<button
key={m.id}
onClick={() => setMode(m.id)}
className={`
flex flex-col items-center gap-2 p-4 rounded-lg border text-center transition-all
${mode === m.id
? 'border-accent-violet bg-accent-violet/10 text-accent-violet-light shadow-glow'
: 'border-bg-border bg-bg-surface text-text-secondary hover:border-accent-violet/50 hover:text-text-primary'
}
`}
>
{m.icon}
<div>
<p className="text-sm font-medium">{m.label}</p>
<p className="text-xs mt-0.5 opacity-70 leading-tight">{m.description}</p>
</div>
</button>
))}
</div>
</div>
<Panel className="p-5 flex flex-col gap-5">
{/* Commander */}
<Input
label="Commander"
value={commander}
onChange={(e) => setCommander(e.target.value)}
placeholder="e.g. Atraxa, Praetors' Voice"
hint="Enter the exact card name of your commander"
/>
{/* Playstyle (not for cull) */}
{mode !== 'cull' && (
<div className="flex flex-col gap-2">
<Input
label="Playstyle (optional)"
value={playstyle}
onChange={(e) => setPlaystyle(e.target.value)}
placeholder="e.g. Proliferate counters, Superfriends..."
/>
<div className="flex flex-wrap gap-1.5">
{PLAYSTYLE_SUGGESTIONS.map((s) => (
<button
key={s}
onClick={() => setPlaystyle(s)}
className={`text-xs px-2.5 py-1 rounded-full border transition-all ${
playstyle === s
? 'bg-accent-violet/20 border-accent-violet/50 text-accent-violet-light'
: 'bg-bg-elevated border-bg-border text-text-muted hover:text-text-secondary hover:border-bg-border'
}`}
>
{s}
</button>
))}
</div>
</div>
)}
{/* Deck name */}
<Input
label="Deck name (optional)"
value={deckName}
onChange={(e) => setDeckName(e.target.value)}
placeholder="AI will name it if left blank"
/>
</Panel>
{/* Existing cards input for complete/cull */}
{(mode === 'complete' || mode === 'cull') && (
<Panel className="p-5">
<p className="text-xs font-medium text-text-secondary uppercase tracking-wider mb-3">
{mode === 'cull' ? 'Current decklist' : 'Existing cards'}
</p>
<ExistingCardsInput />
{mode === 'cull' && (
<div className="mt-4 flex items-center gap-3">
<p className="text-sm text-text-secondary">Trim to</p>
<Input
type="number"
value={targetCount}
onChange={(e) => setTargetCount(Number(e.target.value))}
className="w-20"
min={1}
max={99}
/>
<p className="text-sm text-text-secondary">cards</p>
</div>
)}
</Panel>
)}
{/* Constraints */}
<ConstraintsPanel />
{/* Error */}
{lastError && <ErrorMessage message={lastError} />}
{/* Build button */}
<div className="flex flex-col gap-2">
<Button
size="lg"
loading={isBuilding}
onClick={handleBuild}
className="w-full"
icon={!isBuilding ? <Wand2 className="w-5 h-5" /> : undefined}
>
{isBuilding
? 'Forging your deck…'
: mode === 'generate'
? 'Generate deck'
: mode === 'complete'
? 'Complete deck'
: 'Cull deck'}
</Button>
{isBuilding && (
<p className="text-center text-xs text-text-muted animate-pulse">
This usually takes 3060 seconds
</p>
)}
</div>
</div>
</div>
)
}
+110
View File
@@ -0,0 +1,110 @@
import React from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { Plus, Layers, Trash2, ChevronRight } from 'lucide-react'
import { decksApi } from '@/api'
import { Button, Badge, Spinner, Panel, EmptyState, Skeleton } from '@/components/ui'
import type { DeckSummary, DeckMode } from '@/types'
const MODE_LABELS: Record<DeckMode, string> = {
generate: 'Generated',
complete: 'Completed',
cull: 'Culled',
}
function DeckCard({ deck }: { deck: DeckSummary }) {
const navigate = useNavigate()
const date = new Date(deck.created_at).toLocaleDateString(undefined, {
month: 'short', day: 'numeric', year: 'numeric',
})
return (
<button
onClick={() => navigate(`/decks/${deck.id}`)}
className="group w-full text-left bg-bg-surface border border-bg-border hover:border-accent-violet/50 rounded-lg p-4 transition-all hover:shadow-glow"
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<h3 className="font-display text-base text-text-primary group-hover:text-accent-violet-light transition-colors truncate">
{deck.name}
</h3>
<p className="text-sm text-text-secondary mt-0.5 truncate">{deck.commander}</p>
</div>
<ChevronRight className="w-4 h-4 text-text-muted group-hover:text-accent-violet-light transition-colors flex-shrink-0 mt-1" />
</div>
<div className="flex flex-wrap items-center gap-2 mt-3">
<Badge variant="mode">{MODE_LABELS[deck.mode]}</Badge>
{deck.playstyle && (
<Badge variant="default">{deck.playstyle}</Badge>
)}
<span className="text-xs text-text-muted ml-auto">{date}</span>
</div>
{deck.card_count != null && (
<p className="text-xs text-text-muted mt-2">{deck.card_count} cards</p>
)}
</button>
)
}
function DeckCardSkeleton() {
return (
<div className="bg-bg-surface border border-bg-border rounded-lg p-4">
<Skeleton className="h-5 w-48 mb-2" />
<Skeleton className="h-4 w-32 mb-4" />
<div className="flex gap-2">
<Skeleton className="h-5 w-20 rounded-full" />
<Skeleton className="h-5 w-16 rounded-full" />
</div>
</div>
)
}
export function DeckListPage() {
const { data, isLoading } = useQuery({
queryKey: ['decks'],
queryFn: async () => (await decksApi.list()).data,
})
const decks: DeckSummary[] = data?.items ?? []
return (
<div className="max-w-3xl mx-auto px-4 py-8">
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="font-display text-2xl text-text-primary">My Decks</h1>
{!isLoading && (
<p className="text-text-muted text-sm mt-1">{decks.length} deck{decks.length !== 1 ? 's' : ''}</p>
)}
</div>
<Link to="/build">
<Button icon={<Plus className="w-4 h-4" />}>New deck</Button>
</Link>
</div>
{isLoading ? (
<div className="flex flex-col gap-3">
{[1, 2, 3].map((n) => <DeckCardSkeleton key={n} />)}
</div>
) : decks.length === 0 ? (
<Panel className="py-4">
<EmptyState
icon={<Layers className="w-full h-full" />}
title="No decks yet"
description="Build your first Commander deck with AI assistance."
action={
<Link to="/build">
<Button icon={<Plus className="w-4 h-4" />}>Build a deck</Button>
</Link>
}
/>
</Panel>
) : (
<div className="flex flex-col gap-3">
{decks.map((deck) => <DeckCard key={deck.id} deck={deck} />)}
</div>
)}
</div>
)
}
+378
View File
@@ -0,0 +1,378 @@
import React, { useState } from 'react'
import { useParams, useNavigate, Link } from 'react-router-dom'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import {
ArrowLeft, Trash2, ChevronDown, ChevronUp, ExternalLink,
Swords, Zap, Sparkles, Shield, Cog, Star, Mountain, X
} from 'lucide-react'
import { decksApi } from '@/api'
import { Button, Badge, Spinner, Panel, EmptyState } from '@/components/ui'
import type { Deck, DeckCard, CardSlot } from '@/types'
// ─── Slot metadata ────────────────────────────────────────────────────────────
const SLOT_META: Record<CardSlot, { label: string; icon: React.ReactNode; color: string }> = {
creature: { label: 'Creatures', icon: <Swords className="w-4 h-4" />, color: 'text-green-400' },
instant: { label: 'Instants', icon: <Zap className="w-4 h-4" />, color: 'text-blue-400' },
sorcery: { label: 'Sorceries', icon: <Sparkles className="w-4 h-4" />, color: 'text-purple-400' },
enchantment: { label: 'Enchantments', icon: <Star className="w-4 h-4" />, color: 'text-yellow-400' },
artifact: { label: 'Artifacts', icon: <Cog className="w-4 h-4" />, color: 'text-slate-400' },
planeswalker: { label: 'Planeswalkers', icon: <Shield className="w-4 h-4" />, color: 'text-pink-400' },
land: { label: 'Lands', icon: <Mountain className="w-4 h-4" />, color: 'text-amber-600' },
battle: { label: 'Battles', icon: <Swords className="w-4 h-4" />, color: 'text-red-400' },
}
const SLOT_ORDER: CardSlot[] = [
'creature', 'instant', 'sorcery', 'enchantment', 'artifact', 'planeswalker', 'land', 'battle',
]
// ─── Card image helper ────────────────────────────────────────────────────────
function cardImageUrl(card: DeckCard, size: 'small' | 'normal' = 'small'): string | null {
const sf = card.scryfall_data
if (!sf) return null
const uris = sf.image_uris
if (uris) return uris[size] ?? null
const faces = sf.card_faces
if (faces?.length) return faces[0].image_uris?.[size] ?? null
return null
}
// ─── Reasoning drawer ─────────────────────────────────────────────────────────
function ReasoningDrawer({
card,
onClose,
}: {
card: DeckCard
onClose: () => void
}) {
const img = cardImageUrl(card, 'normal')
return (
<div className="fixed inset-0 z-50 flex items-end md:items-center justify-center p-4">
<div
className="absolute inset-0 bg-black/70 backdrop-blur-sm"
onClick={onClose}
/>
<div className="relative bg-bg-surface border border-bg-border rounded-xl shadow-card w-full max-w-lg animate-slide-up">
<div className="flex items-start gap-4 p-5">
{img && (
<img
src={img}
alt={card.card_name}
className="w-24 rounded-lg shadow-card flex-shrink-0 hidden sm:block"
/>
)}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2 mb-3">
<div>
<h3 className="font-display text-base text-text-primary">{card.card_name}</h3>
{card.scryfall_data?.type_line && (
<p className="text-xs text-text-muted mt-0.5">{card.scryfall_data.type_line}</p>
)}
</div>
<button onClick={onClose} className="text-text-muted hover:text-text-primary flex-shrink-0">
<X className="w-5 h-5" />
</button>
</div>
<div className="flex flex-wrap gap-2 mb-3">
<Badge variant={card.is_owned ? 'owned' : 'unowned'}>
{card.is_owned ? 'Owned' : 'Unowned'}
</Badge>
<Badge variant="slot">{SLOT_META[card.slot]?.label ?? card.slot}</Badge>
{card.scryfall_data?.prices?.usd && (
<Badge variant="default">${card.scryfall_data.prices.usd}</Badge>
)}
</div>
{card.ai_reasoning && (
<div className="bg-bg-elevated rounded-md p-3 text-sm text-text-secondary leading-relaxed">
{card.ai_reasoning}
</div>
)}
</div>
</div>
{card.scryfall_id && (
<div className="px-5 pb-4">
<a
href={`https://scryfall.com/card/${card.scryfall_id}`}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-text-muted hover:text-accent-violet-light flex items-center gap-1"
>
View on Scryfall <ExternalLink className="w-3 h-3" />
</a>
</div>
)}
</div>
</div>
)
}
// ─── Card tile ────────────────────────────────────────────────────────────────
function CardTile({ card, onClick }: { card: DeckCard; onClick: () => void }) {
const img = cardImageUrl(card, 'small')
const price = card.scryfall_data?.prices?.usd
return (
<button
onClick={onClick}
className="group flex items-center gap-3 bg-bg-elevated hover:bg-bg-border/50 border border-bg-border hover:border-accent-violet/40 rounded-md px-3 py-2 text-left transition-all w-full"
>
{img ? (
<img
src={img}
alt={card.card_name}
className="w-8 h-10 rounded object-cover flex-shrink-0 opacity-90 group-hover:opacity-100"
/>
) : (
<div className="w-8 h-10 rounded bg-bg-border flex-shrink-0" />
)}
<div className="flex-1 min-w-0">
<p className="text-sm text-text-primary truncate group-hover:text-accent-violet-light transition-colors">
{card.quantity > 1 && <span className="text-text-muted mr-1.5">{card.quantity}×</span>}
{card.card_name}
</p>
{card.scryfall_data?.mana_cost && (
<p className="text-xs text-text-muted truncate">{card.scryfall_data.mana_cost}</p>
)}
</div>
<div className="flex flex-col items-end gap-1 flex-shrink-0">
{!card.is_owned && (
<span className="w-1.5 h-1.5 rounded-full bg-unowned" title="Unowned" />
)}
{price && <span className="text-xs text-text-muted">${price}</span>}
</div>
</button>
)
}
// ─── Slot group ───────────────────────────────────────────────────────────────
function SlotGroup({ slot, cards }: { slot: CardSlot; cards: DeckCard[] }) {
const [open, setOpen] = useState(true)
const [selectedCard, setSelectedCard] = useState<DeckCard | null>(null)
const meta = SLOT_META[slot]
const totalCount = cards.reduce((s, c) => s + (c.quantity || 1), 0)
return (
<div className="mb-4">
<button
onClick={() => setOpen((o) => !o)}
className="flex items-center gap-2 w-full mb-2 group"
>
<span className={`${meta.color}`}>{meta.icon}</span>
<span className="text-sm font-semibold text-text-primary">{meta.label}</span>
<span className="text-xs text-text-muted">({totalCount})</span>
<div className="flex-1 h-px bg-bg-border ml-2" />
{open ? (
<ChevronUp className="w-3.5 h-3.5 text-text-muted" />
) : (
<ChevronDown className="w-3.5 h-3.5 text-text-muted" />
)}
</button>
{open && (
<div className="flex flex-col gap-1 animate-fade-in">
{cards.map((card) => (
<CardTile key={card.id} card={card} onClick={() => setSelectedCard(card)} />
))}
</div>
)}
{selectedCard && (
<ReasoningDrawer card={selectedCard} onClose={() => setSelectedCard(null)} />
)}
</div>
)
}
// ─── Cuts section ─────────────────────────────────────────────────────────────
function CutsSection({ cuts }: { cuts: { name: string; reasoning: string }[] }) {
const [open, setOpen] = useState(false)
if (!cuts.length) return null
return (
<Panel className="p-5">
<button onClick={() => setOpen((o) => !o)} className="flex items-center justify-between w-full mb-1">
<div className="flex items-center gap-2">
<X className="w-4 h-4 text-danger" />
<span className="font-display text-sm text-text-primary">Suggested cuts ({cuts.length})</span>
</div>
{open ? <ChevronUp className="w-4 h-4 text-text-muted" /> : <ChevronDown className="w-4 h-4 text-text-muted" />}
</button>
{open && (
<div className="flex flex-col gap-2 mt-3 animate-fade-in">
{cuts.map((cut, i) => (
<div key={i} className="flex gap-3 bg-danger/5 border border-danger/20 rounded-md p-3">
<span className="text-xs text-danger font-medium mt-0.5 flex-shrink-0">#{i + 1}</span>
<div>
<p className="text-sm font-medium text-text-primary">{cut.name}</p>
<p className="text-xs text-text-secondary mt-0.5 leading-relaxed">{cut.reasoning}</p>
</div>
</div>
))}
</div>
)}
</Panel>
)
}
// ─── Main page ────────────────────────────────────────────────────────────────
export function DeckViewPage() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const queryClient = useQueryClient()
const { data: deck, isLoading, error } = useQuery<Deck>({
queryKey: ['deck', id],
queryFn: async () => (await decksApi.get(Number(id))).data,
enabled: !!id,
})
const deleteMutation = useMutation({
mutationFn: () => decksApi.delete(Number(id)),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['decks'] })
navigate('/decks')
},
})
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<Spinner size="lg" />
</div>
)
}
if (error || !deck) {
return (
<div className="max-w-2xl mx-auto px-4 py-8">
<EmptyState title="Deck not found" description="This deck may have been deleted." />
</div>
)
}
// Group cards by slot (excluding commander)
const nonCommanderCards = deck.cards.filter((c) => !c.is_commander)
const commander = deck.cards.find((c) => c.is_commander)
const bySlot = SLOT_ORDER.reduce<Record<CardSlot, DeckCard[]>>(
(acc, slot) => {
acc[slot] = nonCommanderCards.filter((c) => c.slot === slot)
return acc
},
{} as Record<CardSlot, DeckCard[]>
)
const ownedCount = nonCommanderCards.filter((c) => c.is_owned).length
const totalNonBasics = nonCommanderCards.filter((c) => c.slot !== 'land').length
const totalCards = nonCommanderCards.reduce((s, c) => s + (c.quantity || 1), 0)
const cuts = deck.ai_reasoning?.cuts ?? []
const unresolved = deck.ai_reasoning?.unresolved_cards ?? []
return (
<div className="max-w-3xl mx-auto px-4 py-8">
{/* Header */}
<div className="flex items-start justify-between gap-4 mb-6">
<div className="flex items-start gap-3">
<Link to="/decks" className="text-text-muted hover:text-text-primary mt-1">
<ArrowLeft className="w-5 h-5" />
</Link>
<div>
<h1 className="font-display text-2xl text-text-primary">{deck.name}</h1>
<div className="flex flex-wrap items-center gap-2 mt-1">
<Badge variant="mode">{deck.mode}</Badge>
<span className="text-text-muted text-sm"></span>
<span className="text-text-secondary text-sm">{deck.commander}</span>
{deck.playstyle && (
<>
<span className="text-text-muted text-sm"></span>
<span className="text-text-muted text-sm">{deck.playstyle}</span>
</>
)}
</div>
</div>
</div>
<Button
variant="danger"
size="sm"
icon={<Trash2 className="w-4 h-4" />}
loading={deleteMutation.isPending}
onClick={() => {
if (confirm('Delete this deck?')) deleteMutation.mutate()
}}
>
Delete
</Button>
</div>
{/* Stats bar */}
<div className="grid grid-cols-3 gap-3 mb-6">
{[
{ label: 'Total cards', value: totalCards + 1 },
{ label: 'Owned', value: `${ownedCount}/${totalCards}` },
{ label: 'Non-lands', value: totalNonBasics },
].map((stat) => (
<Panel key={stat.label} className="p-3 text-center">
<p className="text-xl font-semibold text-text-primary">{stat.value}</p>
<p className="text-xs text-text-muted mt-0.5">{stat.label}</p>
</Panel>
))}
</div>
{/* Strategy summary */}
{deck.ai_reasoning?.strategy_summary && (
<Panel className="p-5 mb-6">
<p className="text-xs font-medium text-text-secondary uppercase tracking-wider mb-2">Strategy</p>
<p className="text-sm text-text-secondary leading-relaxed">{deck.ai_reasoning.strategy_summary}</p>
</Panel>
)}
{/* Commander */}
{commander && (
<div className="mb-6">
<p className="text-xs font-medium text-text-secondary uppercase tracking-wider mb-2">Commander</p>
<div className="bg-accent-violet/10 border border-accent-violet/30 rounded-lg px-4 py-3 flex items-center gap-3">
<span className="font-display text-base text-accent-violet-light">{commander.card_name}</span>
<Badge variant={commander.is_owned ? 'owned' : 'unowned'}>
{commander.is_owned ? 'Owned' : 'Unowned'}
</Badge>
</div>
</div>
)}
{/* Unresolved cards warning */}
{unresolved.length > 0 && (
<div className="bg-unowned/10 border border-unowned/30 rounded-lg p-4 mb-6 flex gap-3">
<span className="text-unowned flex-shrink-0"></span>
<div>
<p className="text-sm font-medium text-unowned">Unresolved cards ({unresolved.length})</p>
<p className="text-xs text-text-secondary mt-1">
These card names could not be verified on Scryfall:{' '}
{unresolved.join(', ')}
</p>
</div>
</div>
)}
{/* Card grid by slot */}
<div>
{SLOT_ORDER.map((slot) => {
const cards = bySlot[slot]
if (!cards.length) return null
return <SlotGroup key={slot} slot={slot} cards={cards} />
})}
</div>
{/* Cuts */}
{cuts.length > 0 && <CutsSection cuts={cuts} />}
</div>
)
}
+75
View File
@@ -0,0 +1,75 @@
import { create } from 'zustand'
import { authApi, tokenStorage, usersApi } from '@/api'
import type { User } from '@/types'
interface AuthState {
user: User | null
loading: boolean
initialized: boolean
// Actions
login: (email: string, password: string) => Promise<void>
register: (email: string, password: string, displayName?: string) => Promise<void>
logout: () => void
fetchMe: () => Promise<void>
initialize: () => Promise<void>
}
export const useAuthStore = create<AuthState>((set) => ({
user: null,
loading: false,
initialized: false,
initialize: async () => {
const token = tokenStorage.getAccess()
if (!token) {
set({ initialized: true })
return
}
try {
const res = await usersApi.me()
set({ user: res.data, initialized: true })
} catch {
tokenStorage.clear()
set({ initialized: true })
}
},
login: async (email, password) => {
set({ loading: true })
try {
const res = await authApi.login(email, password)
tokenStorage.set(res.data)
const meRes = await usersApi.me()
set({ user: meRes.data, loading: false })
} catch (err) {
set({ loading: false })
throw err
}
},
register: async (email, password, displayName) => {
set({ loading: true })
try {
await authApi.register(email, password, displayName)
set({ loading: false })
} catch (err) {
set({ loading: false })
throw err
}
},
logout: () => {
tokenStorage.clear()
set({ user: null })
},
fetchMe: async () => {
try {
const res = await usersApi.me()
set({ user: res.data })
} catch {
// silently fail — interceptor handles 401
}
},
}))
+71
View File
@@ -0,0 +1,71 @@
import { create } from 'zustand'
import type { DeckMode, DeckConstraints, ExistingCard, BudgetScope } from '@/types'
const DEFAULT_CONSTRAINTS: DeckConstraints = {
prefer_owned: false,
budget_enabled: false,
budget_amount: null,
budget_scope: 'purchase',
}
interface DeckBuilderState {
mode: DeckMode
commander: string
playstyle: string
deckName: string
constraints: DeckConstraints
existingCards: ExistingCard[] // for complete/cull modes
targetCount: number // for cull mode
isBuilding: boolean
lastError: string | null
// Actions
setMode: (mode: DeckMode) => void
setCommander: (name: string) => void
setPlaystyle: (style: string) => void
setDeckName: (name: string) => void
setConstraints: (partial: Partial<DeckConstraints>) => void
setBudgetScope: (scope: BudgetScope) => void
setExistingCards: (cards: ExistingCard[]) => void
setTargetCount: (n: number) => void
setBuilding: (v: boolean) => void
setError: (msg: string | null) => void
reset: () => void
}
export const useDeckBuilderStore = create<DeckBuilderState>((set) => ({
mode: 'generate',
commander: '',
playstyle: '',
deckName: '',
constraints: { ...DEFAULT_CONSTRAINTS },
existingCards: [],
targetCount: 99,
isBuilding: false,
lastError: null,
setMode: (mode) => set({ mode }),
setCommander: (commander) => set({ commander }),
setPlaystyle: (playstyle) => set({ playstyle }),
setDeckName: (deckName) => set({ deckName }),
setConstraints: (partial) =>
set((s) => ({ constraints: { ...s.constraints, ...partial } })),
setBudgetScope: (scope) =>
set((s) => ({ constraints: { ...s.constraints, budget_scope: scope } })),
setExistingCards: (existingCards) => set({ existingCards }),
setTargetCount: (targetCount) => set({ targetCount }),
setBuilding: (isBuilding) => set({ isBuilding }),
setError: (lastError) => set({ lastError }),
reset: () =>
set({
mode: 'generate',
commander: '',
playstyle: '',
deckName: '',
constraints: { ...DEFAULT_CONSTRAINTS },
existingCards: [],
targetCount: 99,
isBuilding: false,
lastError: null,
}),
}))
+178
View File
@@ -0,0 +1,178 @@
// ─── Auth & Users ────────────────────────────────────────────────────────────
export type UserRole = 'pending' | 'approved' | 'admin'
export interface User {
id: number
email: string
display_name: string | null
role: UserRole
is_active: boolean
created_at: string
}
export interface AuthTokens {
access_token: string
refresh_token: string
token_type: string
}
// ─── Decks ───────────────────────────────────────────────────────────────────
export type DeckMode = 'generate' | 'complete' | 'cull'
export type CardSlot =
| 'creature'
| 'instant'
| 'sorcery'
| 'enchantment'
| 'artifact'
| 'planeswalker'
| 'land'
| 'battle'
export type BudgetScope = 'purchase' | 'total'
export interface DeckConstraints {
prefer_owned: boolean
budget_enabled: boolean
budget_amount: number | null
budget_scope: BudgetScope
}
export interface DeckCard {
id: number
deck_id: number
card_name: string
slot: CardSlot
quantity: number
is_owned: boolean
is_commander: boolean
ai_reasoning: string | null
scryfall_id: string
scryfall_data: ScryfallCard | null
}
export interface DeckCut {
name: string
reasoning: string
}
export interface Deck {
id: number
owner_id: number
name: string
commander: string
description: string | null
mode: DeckMode
playstyle: string | null
prefer_owned: boolean
budget_enabled: boolean
budget_amount: number | null
budget_scope: BudgetScope
created_at: string
updated_at: string
cards: DeckCard[]
ai_reasoning: {
strategy_summary: string
unresolved_cards: string[]
cuts: DeckCut[]
}
}
export interface DeckSummary {
id: number
name: string
commander: string
mode: DeckMode
playstyle: string | null
created_at: string
card_count: number
}
// ─── Deck Builder Requests ───────────────────────────────────────────────────
export interface ExistingCard {
card_name: string
slot?: CardSlot
quantity?: number
}
export interface GenerateRequest {
commander: string
playstyle?: string
name?: string
description?: string
constraints: DeckConstraints
}
export interface CompleteRequest {
commander: string
playstyle?: string
name?: string
existing_cards: ExistingCard[]
constraints: DeckConstraints
}
export interface CullRequest {
commander: string
name?: string
existing_cards: ExistingCard[]
target_count: number
constraints: DeckConstraints
}
// ─── Collection ──────────────────────────────────────────────────────────────
export interface CollectionCard {
id: number
card_name: string
set_code: string
collector_number: string
quantity: number
foil_quantity: number
scryfall_id: string
scryfall_data: ScryfallCard | null
}
export interface CollectionStats {
total_cards: number
unique_cards: number
foil_cards: number
estimated_value: number | null
}
// ─── Scryfall ────────────────────────────────────────────────────────────────
export interface ScryfallCard {
id: string
name: string
mana_cost?: string
cmc?: number
type_line?: string
oracle_text?: string
colors?: string[]
color_identity?: string[]
legalities?: Record<string, string>
prices?: { usd?: string; usd_foil?: string }
image_uris?: { small?: string; normal?: string; large?: string; art_crop?: string }
card_faces?: Array<{ image_uris?: { small?: string; normal?: string; art_crop?: string } }>
set: string
set_name?: string
collector_number?: string
rarity?: string
}
// ─── API Responses ───────────────────────────────────────────────────────────
export interface PaginatedResponse<T> {
items: T[]
total: number
page: number
page_size: number
pages: number
}
export interface ApiError {
detail: string | Array<{ loc: string[]; msg: string; type: string }>
}
+48
View File
@@ -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: [],
}
+23
View File
@@ -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" }]
}
+10
View File
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}
+19
View File
@@ -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,
},
},
},
})
+22
View File
@@ -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;
}
}