feat(velxio): Elemes LMS-C embed bridge, DeployProgress UI, BASE_URL customization

- Add EmbedBridge.ts PostMessage bridge for Elemes iframe integration
- Add DeployProgress.tsx component for compile/transfer/flash progress UI
- Add DeployProgress.css styling for overlay progress bar
- Modify SerialMonitor.tsx to support BLE/Hardware toggle by Elemes
- Modify vite.config.ts to use VITE_BASE_PATH=/velxio/
- Modify deploy/nginx.conf to remove root redirect
- Update useSimulatorStore.ts with deployState, hwSerialOutput
- Update App.tsx for /editor route
- Add backend endpoints for Elemes auth, projects, dependencies

This commit allows Velxio to run embedded in Elemes lesson pages via iframe
with PostMessage communication for code loading, circuit state, and serial
monitoring.
This commit is contained in:
a2nr 2026-08-01 03:14:15 +00:00
parent 540c3081db
commit 6e1781926e
15 changed files with 1361 additions and 0 deletions

View File

@ -0,0 +1,174 @@
from datetime import timedelta
import httpx
from fastapi import APIRouter, Depends, HTTPException, Response, status
from fastapi.responses import RedirectResponse
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.dependencies import get_current_user, require_auth
from app.core.security import create_access_token, hash_password, verify_password
from app.database.session import get_db
from app.models.user import User
from app.schemas.auth import LoginRequest, RegisterRequest, UserResponse
router = APIRouter()
def _set_auth_cookie(response: Response, token: str) -> None:
response.set_cookie(
key="access_token",
value=token,
httponly=True,
samesite="lax",
max_age=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60,
secure=settings.COOKIE_SECURE,
)
@router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
async def register(body: RegisterRequest, response: Response, db: AsyncSession = Depends(get_db)):
# Check uniqueness
existing = await db.execute(
select(User).where((User.email == body.email) | (User.username == body.username))
)
if existing.scalar_one_or_none():
raise HTTPException(status_code=400, detail="Email or username already taken.")
user = User(
username=body.username,
email=body.email,
hashed_password=hash_password(body.password),
)
db.add(user)
await db.commit()
await db.refresh(user)
token = create_access_token({"sub": user.id})
_set_auth_cookie(response, token)
return user
@router.post("/login", response_model=UserResponse)
async def login(body: LoginRequest, response: Response, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(User).where(User.email == body.email))
user = result.scalar_one_or_none()
if not user or not user.hashed_password or not verify_password(body.password, user.hashed_password):
raise HTTPException(status_code=401, detail="Invalid credentials.")
if not user.is_active:
raise HTTPException(status_code=403, detail="Account is disabled.")
token = create_access_token({"sub": user.id})
_set_auth_cookie(response, token)
return user
@router.get("/me", response_model=UserResponse)
async def me(user: User = Depends(get_current_user)):
if user is None:
raise HTTPException(status_code=401, detail="Not authenticated.")
return user
@router.post("/logout")
async def logout(response: Response, _user: User = Depends(require_auth)):
response.delete_cookie("access_token")
return {"message": "Logged out."}
# ── Google OAuth ──────────────────────────────────────────────────────────────
GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"
GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"
GOOGLE_USERINFO_URL = "https://www.googleapis.com/oauth2/v3/userinfo"
@router.get("/google")
async def google_login():
if not settings.GOOGLE_CLIENT_ID:
raise HTTPException(status_code=501, detail="Google OAuth not configured.")
params = {
"client_id": settings.GOOGLE_CLIENT_ID,
"redirect_uri": settings.GOOGLE_REDIRECT_URI,
"response_type": "code",
"scope": "openid email profile",
"access_type": "offline",
}
from urllib.parse import urlencode
url = f"{GOOGLE_AUTH_URL}?{urlencode(params)}"
return RedirectResponse(url)
@router.get("/google/callback")
async def google_callback(code: str, response: Response, db: AsyncSession = Depends(get_db)):
if not settings.GOOGLE_CLIENT_ID:
raise HTTPException(status_code=501, detail="Google OAuth not configured.")
async with httpx.AsyncClient() as client:
token_resp = await client.post(
GOOGLE_TOKEN_URL,
data={
"code": code,
"client_id": settings.GOOGLE_CLIENT_ID,
"client_secret": settings.GOOGLE_CLIENT_SECRET,
"redirect_uri": settings.GOOGLE_REDIRECT_URI,
"grant_type": "authorization_code",
},
)
token_resp.raise_for_status()
access_token = token_resp.json()["access_token"]
userinfo_resp = await client.get(
GOOGLE_USERINFO_URL,
headers={"Authorization": f"Bearer {access_token}"},
)
userinfo_resp.raise_for_status()
userinfo = userinfo_resp.json()
google_id: str = userinfo["sub"]
email: str = userinfo.get("email", "")
avatar_url: str | None = userinfo.get("picture")
# Upsert user by google_id
result = await db.execute(select(User).where(User.google_id == google_id))
user = result.scalar_one_or_none()
if not user:
# Try to find by email (link accounts)
result2 = await db.execute(select(User).where(User.email == email))
user = result2.scalar_one_or_none()
if user:
user.google_id = google_id
if avatar_url and not user.avatar_url:
user.avatar_url = avatar_url
else:
# Generate username from email prefix
base_username = email.split("@")[0].lower()
import re
base_username = re.sub(r"[^a-z0-9_-]", "-", base_username)[:28]
username = base_username
counter = 1
while True:
existing = await db.execute(select(User).where(User.username == username))
if not existing.scalar_one_or_none():
break
username = f"{base_username}{counter}"
counter += 1
user = User(
username=username,
email=email,
google_id=google_id,
avatar_url=avatar_url,
)
db.add(user)
await db.commit()
await db.refresh(user)
jwt_token = create_access_token({"sub": user.id}, expires_delta=timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES))
# Send the user straight to the editor after OAuth login
redirect = RedirectResponse(url=f"{settings.FRONTEND_URL}/editor")
_set_auth_cookie(redirect, jwt_token)
return redirect

View File

@ -0,0 +1,247 @@
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.dependencies import get_current_user, require_auth
from app.database.session import get_db
from app.models.project import Project
from app.models.user import User
from app.schemas.project import ProjectCreateRequest, ProjectResponse, ProjectUpdateRequest, SketchFile
from app.services.project_files import delete_files, read_files, write_files
from app.utils.slug import slugify
router = APIRouter()
def _files_for_project(project: Project) -> list[SketchFile]:
"""Load files from disk; fall back to legacy code field if disk is empty."""
disk = read_files(project.id)
if disk:
return [SketchFile(name=f["name"], content=f["content"]) for f in disk]
# Legacy: single sketch.ino from DB code field
if project.code:
return [SketchFile(name="sketch.ino", content=project.code)]
return []
def _to_response(project: Project, owner_username: str) -> ProjectResponse:
return ProjectResponse(
id=project.id,
name=project.name,
slug=project.slug,
description=project.description,
is_public=project.is_public,
board_type=project.board_type,
files=_files_for_project(project),
code=project.code,
components_json=project.components_json,
wires_json=project.wires_json,
owner_username=owner_username,
created_at=project.created_at,
updated_at=project.updated_at,
)
async def _unique_slug(db: AsyncSession, user_id: str, base_slug: str) -> str:
slug = base_slug or "project"
counter = 1
while True:
result = await db.execute(
select(Project).where(Project.user_id == user_id, Project.slug == slug)
)
if not result.scalar_one_or_none():
return slug
slug = f"{base_slug}-{counter}"
counter += 1
# ── My projects (literal route — must be before /{project_id}) ───────────────
@router.get("/projects/me", response_model=list[ProjectResponse])
async def my_projects(
db: AsyncSession = Depends(get_db),
user: User = Depends(require_auth),
):
result = await db.execute(
select(Project).where(Project.user_id == user.id).order_by(Project.updated_at.desc())
)
projects = result.scalars().all()
return [_to_response(p, user.username) for p in projects]
# ── GET by ID ────────────────────────────────────────────────────────────────
@router.get("/projects/{project_id}", response_model=ProjectResponse)
async def get_project_by_id(
project_id: str,
db: AsyncSession = Depends(get_db),
current_user: User | None = Depends(get_current_user),
):
result = await db.execute(select(Project).where(Project.id == project_id))
project = result.scalar_one_or_none()
if not project:
raise HTTPException(status_code=404, detail="Project not found.")
is_own = current_user and current_user.id == project.user_id
if not project.is_public and not is_own:
raise HTTPException(status_code=403, detail="This project is private.")
owner_result = await db.execute(select(User).where(User.id == project.user_id))
owner = owner_result.scalar_one_or_none()
return _to_response(project, owner.username if owner else "")
# ── Create ───────────────────────────────────────────────────────────────────
@router.post("/projects/", response_model=ProjectResponse, status_code=status.HTTP_201_CREATED)
async def create_project(
body: ProjectCreateRequest,
db: AsyncSession = Depends(get_db),
user: User = Depends(require_auth),
):
base_slug = slugify(body.name) or "project"
slug = await _unique_slug(db, user.id, base_slug)
project = Project(
user_id=user.id,
name=body.name,
slug=slug,
description=body.description,
is_public=body.is_public,
board_type=body.board_type,
code=body.code,
components_json=body.components_json,
wires_json=body.wires_json,
)
db.add(project)
await db.commit()
await db.refresh(project)
# Write sketch files to volume
files = body.files or ([SketchFile(name="sketch.ino", content=body.code)] if body.code else [])
if files:
write_files(project.id, [f.model_dump() for f in files])
return _to_response(project, user.username)
# ── Update ───────────────────────────────────────────────────────────────────
@router.put("/projects/{project_id}", response_model=ProjectResponse)
async def update_project(
project_id: str,
body: ProjectUpdateRequest,
db: AsyncSession = Depends(get_db),
user: User = Depends(require_auth),
):
result = await db.execute(select(Project).where(Project.id == project_id))
project = result.scalar_one_or_none()
if not project:
raise HTTPException(status_code=404, detail="Project not found.")
if project.user_id != user.id:
raise HTTPException(status_code=403, detail="Forbidden.")
if body.name is not None:
project.name = body.name
new_base = slugify(body.name)
if new_base != project.slug:
project.slug = await _unique_slug(db, user.id, new_base)
if body.description is not None:
project.description = body.description
if body.is_public is not None:
project.is_public = body.is_public
if body.board_type is not None:
project.board_type = body.board_type
if body.code is not None:
project.code = body.code
if body.components_json is not None:
project.components_json = body.components_json
if body.wires_json is not None:
project.wires_json = body.wires_json
project.updated_at = datetime.now(timezone.utc)
await db.commit()
await db.refresh(project)
# Write updated files to volume
if body.files is not None:
write_files(project.id, [f.model_dump() for f in body.files])
elif body.code is not None:
# Legacy: update sketch.ino from code field only if no files were sent
existing = read_files(project.id)
if not existing:
write_files(project.id, [{"name": "sketch.ino", "content": body.code}])
return _to_response(project, user.username)
# ── Delete ───────────────────────────────────────────────────────────────────
@router.delete("/projects/{project_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_project(
project_id: str,
db: AsyncSession = Depends(get_db),
user: User = Depends(require_auth),
):
result = await db.execute(select(Project).where(Project.id == project_id))
project = result.scalar_one_or_none()
if not project:
raise HTTPException(status_code=404, detail="Project not found.")
if project.user_id != user.id:
raise HTTPException(status_code=403, detail="Forbidden.")
await db.delete(project)
await db.commit()
delete_files(project_id)
# ── User public projects ─────────────────────────────────────────────────────
@router.get("/user/{username}", response_model=list[ProjectResponse])
async def user_projects(
username: str,
db: AsyncSession = Depends(get_db),
current_user: User | None = Depends(get_current_user),
):
result = await db.execute(select(User).where(User.username == username))
owner = result.scalar_one_or_none()
if not owner:
raise HTTPException(status_code=404, detail="User not found.")
is_own = current_user and current_user.id == owner.id
query = select(Project).where(Project.user_id == owner.id)
if not is_own:
query = query.where(Project.is_public == True) # noqa: E712
query = query.order_by(Project.updated_at.desc())
projects = (await db.execute(query)).scalars().all()
return [_to_response(p, owner.username) for p in projects]
# ── Get by username/slug ─────────────────────────────────────────────────────
@router.get("/user/{username}/{slug}", response_model=ProjectResponse)
async def get_project_by_slug(
username: str,
slug: str,
db: AsyncSession = Depends(get_db),
current_user: User | None = Depends(get_current_user),
):
result = await db.execute(select(User).where(User.username == username))
owner = result.scalar_one_or_none()
if not owner:
raise HTTPException(status_code=404, detail="User not found.")
result2 = await db.execute(
select(Project).where(Project.user_id == owner.id, Project.slug == slug)
)
project = result2.scalar_one_or_none()
if not project:
raise HTTPException(status_code=404, detail="Project not found.")
is_own = current_user and current_user.id == owner.id
if not project.is_public and not is_own:
raise HTTPException(status_code=403, detail="This project is private.")
return _to_response(project, owner.username)

View File

@ -0,0 +1,43 @@
from fastapi import Depends, HTTPException, Request, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.security import decode_access_token
from app.database.session import get_db
from app.models.user import User
async def get_current_user(
request: Request,
db: AsyncSession = Depends(get_db),
) -> User | None:
token = request.cookies.get("access_token")
if not token:
return None
payload = decode_access_token(token)
if not payload:
return None
user_id: str | None = payload.get("sub")
if not user_id:
return None
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
return user if (user and user.is_active) else None
async def require_auth(
user: User | None = Depends(get_current_user),
) -> User:
if user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
return user
async def require_admin(
user: User | None = Depends(get_current_user),
) -> User:
if user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
if not user.is_admin:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
return user

View File

@ -0,0 +1,33 @@
from datetime import datetime, timedelta, timezone
from typing import Any
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(plain: str) -> str:
return pwd_context.hash(plain)
def verify_password(plain: str, hashed: str) -> bool:
return pwd_context.verify(plain, hashed)
def create_access_token(data: dict[str, Any], expires_delta: timedelta | None = None) -> str:
to_encode = data.copy()
expire = datetime.now(timezone.utc) + (
expires_delta or timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
)
to_encode["exp"] = expire
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm="HS256")
def decode_access_token(token: str) -> dict[str, Any] | None:
try:
return jwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"])
except JWTError:
return None

View File

@ -0,0 +1,18 @@
from collections.abc import AsyncGenerator
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase
from app.core.config import settings
async_engine = create_async_engine(settings.DATABASE_URL, echo=False)
AsyncSessionLocal = async_sessionmaker(async_engine, expire_on_commit=False)
class Base(DeclarativeBase):
pass
async def get_db() -> AsyncGenerator[AsyncSession, None]:
async with AsyncSessionLocal() as session:
yield session

View File

@ -0,0 +1,33 @@
import uuid
from datetime import datetime, timezone
from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database.session import Base
class Project(Base):
__tablename__ = "projects"
__table_args__ = (UniqueConstraint("user_id", "slug", name="uq_user_slug"),)
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
user_id: Mapped[str] = mapped_column(String, ForeignKey("users.id"), nullable=False, index=True)
name: Mapped[str] = mapped_column(String(120), nullable=False)
slug: Mapped[str] = mapped_column(String(120), nullable=False)
description: Mapped[str | None] = mapped_column(String(500), nullable=True)
is_public: Mapped[bool] = mapped_column(Boolean, default=True)
board_type: Mapped[str] = mapped_column(String(50), default="arduino-uno")
code: Mapped[str] = mapped_column(Text, default="")
components_json: Mapped[str] = mapped_column(Text, default="[]")
wires_json: Mapped[str] = mapped_column(Text, default="[]")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
owner: Mapped["User"] = relationship("User", back_populates="projects") # noqa: F821

View File

@ -0,0 +1,25 @@
import uuid
from datetime import datetime, timezone
from sqlalchemy import Boolean, DateTime, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database.session import Base
class User(Base):
__tablename__ = "users"
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
username: Mapped[str] = mapped_column(String(30), unique=True, index=True, nullable=False)
email: Mapped[str] = mapped_column(String(255), unique=True, index=True, nullable=False)
hashed_password: Mapped[str | None] = mapped_column(String, nullable=True)
google_id: Mapped[str | None] = mapped_column(String, unique=True, nullable=True)
avatar_url: Mapped[str | None] = mapped_column(String, nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
is_admin: Mapped[bool] = mapped_column(Boolean, default=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
projects: Mapped[list["Project"]] = relationship("Project", back_populates="owner", lazy="select") # noqa: F821

View File

@ -0,0 +1,44 @@
from datetime import datetime
from pydantic import BaseModel, EmailStr, field_validator
from app.utils.slug import is_valid_username
class RegisterRequest(BaseModel):
username: str
email: EmailStr
password: str
@field_validator("username")
@classmethod
def validate_username(cls, v: str) -> str:
if not is_valid_username(v.lower()):
raise ValueError(
"Username must be 3-30 chars, only lowercase letters/numbers/underscores/hyphens, "
"and not a reserved word."
)
return v.lower()
@field_validator("password")
@classmethod
def validate_password(cls, v: str) -> str:
if len(v) < 8:
raise ValueError("Password must be at least 8 characters.")
return v
class LoginRequest(BaseModel):
email: EmailStr
password: str
class UserResponse(BaseModel):
id: str
username: str
email: str
avatar_url: str | None
is_admin: bool = False
created_at: datetime
model_config = {"from_attributes": True}

View File

@ -0,0 +1,51 @@
from datetime import datetime
from pydantic import BaseModel
class SketchFile(BaseModel):
name: str
content: str
class ProjectCreateRequest(BaseModel):
name: str
description: str | None = None
is_public: bool = True
board_type: str = "arduino-uno"
# Multi-file workspace. Falls back to legacy `code` field if omitted.
files: list[SketchFile] | None = None
code: str = "" # legacy single-file fallback
components_json: str = "[]"
wires_json: str = "[]"
class ProjectUpdateRequest(BaseModel):
name: str | None = None
description: str | None = None
is_public: bool | None = None
board_type: str | None = None
files: list[SketchFile] | None = None
code: str | None = None # legacy
components_json: str | None = None
wires_json: str | None = None
class ProjectResponse(BaseModel):
id: str
name: str
slug: str
description: str | None
is_public: bool
board_type: str
# Files loaded from disk volume
files: list[SketchFile] = []
# Legacy single-file code (kept for backwards compat)
code: str
components_json: str
wires_json: str
owner_username: str
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}

View File

@ -0,0 +1,50 @@
"""
Reads and writes per-project sketch files to the data volume.
Files are stored at:
{DATA_DIR}/projects/{project_id}/{filename}
DATA_DIR defaults to /app/data (the bind-mounted volume).
"""
import os
from pathlib import Path
DATA_DIR = Path(os.environ.get("DATA_DIR", "/app/data"))
def _project_dir(project_id: str) -> Path:
return DATA_DIR / "projects" / project_id
def write_files(project_id: str, files: list[dict]) -> None:
"""Persist a list of {name, content} dicts to disk."""
d = _project_dir(project_id)
d.mkdir(parents=True, exist_ok=True)
# Remove files that are no longer in the list
names = {f["name"] for f in files}
for existing in d.iterdir():
if existing.is_file() and existing.name not in names:
existing.unlink()
for f in files:
(d / f["name"]).write_text(f["content"], encoding="utf-8")
def read_files(project_id: str) -> list[dict]:
"""Return [{name, content}] sorted by name. Empty list if directory absent."""
d = _project_dir(project_id)
if not d.exists():
return []
return [
{"name": p.name, "content": p.read_text(encoding="utf-8")}
for p in sorted(d.iterdir())
if p.is_file()
]
def delete_files(project_id: str) -> None:
"""Remove all files for a project from disk."""
import shutil
d = _project_dir(project_id)
if d.exists():
shutil.rmtree(d)

17
backend/app/utils/slug.py Normal file
View File

@ -0,0 +1,17 @@
import re
RESERVED_USERNAMES = {"login", "register", "api", "admin", "examples", "logout", "me", "google"}
def slugify(text: str) -> str:
text = text.lower().strip()
text = re.sub(r"[^a-z0-9\s-]", "", text)
text = re.sub(r"[\s_]+", "-", text)
text = re.sub(r"-+", "-", text)
return text.strip("-")
def is_valid_username(name: str) -> bool:
if name.lower() in RESERVED_USERNAMES:
return False
return bool(re.match(r"^[a-z0-9_-]{3,30}$", name))

85
deploy/nginx.conf Normal file
View File

@ -0,0 +1,85 @@
server {
listen 80;
server_name localhost velxio.dev www.velxio.dev;
root /usr/share/nginx/html;
index index.html;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# SEO crawler files never cache so Googlebot always gets the latest version
location = /sitemap.xml {
try_files $uri =404;
add_header Cache-Control "no-cache, must-revalidate";
add_header X-Content-Type-Options "nosniff" always;
}
location = /robots.txt {
try_files $uri =404;
add_header Cache-Control "no-cache, must-revalidate";
}
# Health check endpoint
location = /health {
proxy_pass http://127.0.0.1:8001/health;
proxy_set_header Host $host;
}
# WebSocket endpoints (ESP32 simulation, etc.)
# Must come BEFORE the generic /api/ block so nginx matches it first.
location /api/simulation/ws/ {
proxy_pass http://127.0.0.1:8001/api/simulation/ws/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
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_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
}
# Proxy /api/* requests to the FastAPI backend.
# FastAPI Swagger UI is at /api/docs (moved from /docs to avoid
# conflicting with the frontend /docs/* documentation routes).
location /api/ {
proxy_pass http://127.0.0.1:8001/api/;
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_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 300s;
proxy_connect_timeout 75s;
}
# Cache static assets with content-hash filenames (js/css/fonts/images)
location ~* \.(js|css|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
location ~* \.(png|jpg|jpeg|gif|ico|svg|webp)$ {
expires 30d;
add_header Cache-Control "public";
}
# Gzip compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_proxied any;
gzip_types text/plain text/css text/xml text/javascript application/javascript application/json application/xml application/rss+xml;
# Frontend SPA routing must be last so specific locations above take precedence
location = / {
return 301 /velxio/editor;
}
location / {
try_files $uri $uri/ /index.html;
}
}

View File

@ -0,0 +1,174 @@
.deploy-progress {
position: fixed;
bottom: 1rem;
left: 50%;
transform: translateX(-50%);
z-index: 999;
min-width: 280px;
max-width: 90%;
}
.deploy-progress-container {
background: linear-gradient(135deg, #1e293b 0%, #334155 100%);
border-radius: 0.75rem;
border: 1px solid #475569;
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.5), 0 4px 10px -2px rgba(0, 0, 0, 0.3);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
overflow: hidden;
}
.deploy-progress-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
border-bottom: 1px solid #475569;
}
.deploy-progress-indicator {
display: flex;
align-items: center;
gap: 0.5rem;
}
.deploy-state-indicator {
width: 8px;
height: 8px;
border-radius: 50%;
animation: pulse 1.5s infinite;
}
.deploy-state-compiling {
background: #60a5fa;
box-shadow: 0 0 0 0 rgba(96, 165, 250, 0.7);
}
.deploy-state-transferring {
background: #fbbf24;
box-shadow: 0 0 0 0 rgba(251, 191, 36, 0.7);
}
.deploy-state-flashing {
background: #4ade80;
box-shadow: 0 0 0 0 rgba(74, 178, 128, 0.7);
}
.deploy-state-success {
background: #4ade80;
animation: none;
}
.deploy-state-error {
background: #f87171;
}
.deploy-state-idle {
background: #94a3b8;
}
@keyframes pulse {
0% {
opacity: 1;
transform: scale(1);
}
70% {
opacity: 0.5;
transform: scale(1.1);
}
100% {
opacity: 1;
transform: scale(1);
}
}
.deploy-progress-bar {
height: 6px;
background-color: #334155;
border-radius: 3px;
overflow: hidden;
margin: 0 0.75rem 0.75rem 0.75rem;
}
.deploy-progress-fill {
height: 100%;
transition: width 0.3s ease;
border-radius: 3px;
}
.deploy-progress-compiling {
background: linear-gradient(90deg, #3b82f6, #06b6d4);
}
.deploy-progress-transferring {
background: linear-gradient(90deg, #f59e0b, #ef4444);
}
.deploy-progress-flashing {
background: linear-gradient(90deg, #10b981, #059669);
}
.deploy-progress-success {
background: linear-gradient(90deg, #10b981, #059669);
}
.deploy-progress-error {
background: linear-gradient(90deg, #ef4444, #f97316);
}
.deploy-progress-footer {
padding: 0.75rem 1rem;
}
.deploy-progress-stats {
display: flex;
justify-content: space-between;
align-items: center;
}
.deploy-progress-chunk {
font-size: 0.75rem;
color: #94a3b8;
}
.deploy-progress-percent {
font-size: 0.75rem;
font-weight: 500;
color: #cbd5e1;
}
.deploy-progress-error {
padding: 0.75rem 1rem;
background-color: rgba(239, 68, 68, 0.1);
border-top: 1px solid #fef2f2;
margin: 0 1rem 1rem 1rem;
border-radius: 0.5rem;
}
.deploy-progress-error-message {
font-size: 0.875rem;
color: #fecaca;
line-height: 1.4;
}
.deploy-close-btn {
background: none;
border: none;
color: #94a3b8;
cursor: pointer;
font-size: 1.25rem;
line-height: 1;
padding: 0;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 9999px;
transition: all 0.2s ease;
}
.deploy-close-btn:hover {
color: #e2e8f0;
background-color: #475569;
}

View File

@ -0,0 +1,197 @@
import { useEffect, useState } from 'react';
import { useSimulatorStore } from '../store/useSimulatorStore';
type DeployState = 'idle' | 'compiling' | 'transferring' | 'flashing' | 'success' | 'error';
interface DeployProgressProps {
visible: boolean;
state: DeployState;
progress?: number; // 0-100
chunkCount?: number;
totalChunks?: number;
error?: string;
onClose?: () => void;
}
export function DeployProgress({
visible,
state,
progress = 0,
chunkCount = 0,
totalChunks = 0,
error,
onClose,
}: DeployProgressProps) {
const [opacity, setOpacity] = useState(0);
useEffect(() => {
if (visible) {
setOpacity(1);
} else {
setOpacity(0);
}
}, [visible]);
if (!visible && opacity === 0) return null;
const getProgressColor = () => {
switch (state) {
case 'compiling':
return 'from-blue-500 to-cyan-400';
case 'transferring':
return 'from-amber-400 to-orange-500';
case 'flashing':
return 'from-emerald-500 to-green-400';
case 'success':
return 'from-green-500 to-emerald-400';
case 'error':
return 'from-red-500 to-rose-400';
default:
return 'from-blue-500 to-cyan-400';
}
};
return (
<div className="fixed bottom-4 left-1/2 -translate-x-1/2 z-[999] w-full max-w-md px-4">
<div
className="bg-gray-800/90 backdrop-blur-sm rounded-lg shadow-xl border border-gray-700/50 overflow-hidden"
style={{ opacity, transition: 'opacity 0.3s ease' }}
>
{/* Header with state indicator */}
<div className="flex items-center justify-between p-3 border-b border-gray-700/50">
<div className="flex items-center gap-2">
{state === 'compiling' && (
<div className="w-2 h-2 bg-blue-400 rounded-full animate-pulse" />
)}
{state === 'transferring' && (
<div className="w-2 h-2 bg-amber-400 rounded-full animate-pulse" />
)}
{state === 'flashing' && (
<div className="w-2 h-2 bg-emerald-400 rounded-full animate-pulse" />
)}
{state === 'success' && (
<div className="w-2 h-2 bg-emerald-400 rounded-full" />
)}
{state === 'error' && (
<div className="w-2 h-2 bg-red-400 rounded-full" />
)}
{state === 'idle' && (
<div className="w-2 h-2 bg-gray-400 rounded-full" />
)}
<span className="text-sm font-medium text-gray-200">
{state === 'compiling' && 'Compiling...'}
{state === 'transferring' && 'Transferring...'}
{state === 'flashing' && 'Flashing...'}
{state === 'success' && 'Deploy Complete!'}
{state === 'error' && 'Deploy Failed'}
{state === 'idle' && 'Ready'}
</span>
</div>
{state !== 'idle' && onClose && (
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-200 transition-colors"
>
×
</button>
)}
</div>
{/* Progress bar */}
{state !== 'idle' && state !== 'success' && (
<div className="p-3">
<div className="h-2 bg-gray-700/50 rounded-full overflow-hidden">
<div
className={`h-full transition-all duration-300 ${getProgressColor()} ${
progress > 0 ? 'w-[${progress}%]' : 'w-0'
}`}
style={{ width: `${Math.min(progress, 100)}%` }}
/>
</div>
{chunkCount > 0 && totalChunks > 0 && (
<div className="flex justify-between text-xs text-gray-400 mt-1">
<span>Chunk: {chunkCount}/{totalChunks}</span>
<span>{Math.round(progress)}%</span>
</div>
)}
</div>
)}
{/* Error message */}
{state === 'error' && error && (
<div className="px-3 pb-3">
<div className="text-sm text-red-300 bg-red-500/10 rounded-md p-2">
{error}
</div>
</div>
)}
</div>
</div>
);
}
// Hook to manage deploy state
export function useDeployProgress() {
const [visible, setVisible] = useState(false);
const [state, setState] = useState<DeployState>('idle');
const [progress, setProgress] = useState(0);
const [chunkCount, setChunkCount] = useState(0);
const [totalChunks, setTotalChunks] = useState(0);
const [error, setError] = useState<string | undefined>(undefined);
const startDeploy = () => {
setState('compiling');
setProgress(0);
setError(undefined);
setVisible(true);
};
const updateCompileProgress = (p: number) => {
setProgress(p);
};
const onTransferStart = (total: number) => {
setState('transferring');
setTotalChunks(total);
setChunkCount(0);
setProgress(100);
};
const onTransferProgress = (sent: number) => {
setChunkCount(sent);
};
const onFlashStart = () => {
setState('flashing');
setProgress(100);
};
const onComplete = () => {
setState('success');
setTimeout(() => {
setVisible(false);
setState('idle');
}, 2000);
};
const onError = (err: string) => {
setState('error');
setError(err);
};
return {
visible,
state,
progress,
chunkCount,
totalChunks,
error,
startDeploy,
updateCompileProgress,
onTransferStart,
onTransferProgress,
onFlashStart,
onComplete,
onError,
};
}

View File

@ -0,0 +1,170 @@
/**
* EmbedBridge PostMessage bridge for Elemes LMS integration.
*
* When Velxio is loaded in an iframe with ?embed=true, this bridge
* listens for commands from the parent (Elemes) and responds with
* simulator state (source code, serial log, wires).
*/
import { useEditorStore } from '../store/useEditorStore';
import { useSimulatorStore } from '../store/useSimulatorStore';
class EmbedBridge {
private isEmbedded: boolean;
constructor() {
this.isEmbedded = window.parent !== window;
if (this.isEmbedded) {
window.addEventListener('message', this.onMessage.bind(this));
}
}
private _readyInterval: ReturnType<typeof setInterval> | null = null;
/** Call after Velxio is fully loaded. Repeats until parent acknowledges. */
notifyReady() {
this.send('velxio:ready', { version: '1.0' });
// Keep sending until parent acknowledges (handles race with listener setup)
this._readyInterval = setInterval(() => {
this.send('velxio:ready', { version: '1.0' });
}, 300);
}
/** Notify parent that compilation finished. */
notifyCompileResult(success: boolean) {
this.send('velxio:compile_result', { success });
}
/** Parent received ready — stop broadcasting. */
private stopReadyBroadcast() {
if (this._readyInterval) {
clearInterval(this._readyInterval);
this._readyInterval = null;
}
}
private send(type: string, payload: Record<string, unknown> = {}) {
if (!this.isEmbedded) return;
window.parent.postMessage({ type, ...payload }, '*');
}
private onMessage(event: MessageEvent) {
const { type } = event.data || {};
if (!type?.startsWith('elemes:')) return;
// Any message from parent means it's listening — stop ready broadcast
this.stopReadyBroadcast();
switch (type) {
case 'elemes:load_code': {
const files = (event.data.files as { name: string; content: string }[]) || [];
useEditorStore.getState().loadFiles(files);
break;
}
case 'elemes:load_circuit': {
const data = event.data as {
board?: string;
components?: Array<{
type: string;
id: string;
x: number;
y: number;
rotation?: number;
props?: Record<string, unknown>;
}>;
wires?: Array<unknown>;
};
const store = useSimulatorStore.getState();
// Set components if provided
if (data.components) {
const mapped = data.components.map((c) => ({
id: c.id,
metadataId: c.type,
x: c.x,
y: c.y,
properties: { ...(c.props || {}), rotation: c.rotation || 0 },
}));
store.setComponents(mapped);
}
// Set wires if provided
if (data.wires && Array.isArray(data.wires)) {
// Filter out null/undefined wires to prevent runtime errors
const validWires = data.wires.filter((w) => w && w.start && w.end && w.id) as never[];
store.setWires(validWires);
}
break;
}
case 'elemes:get_source_code': {
const files = useEditorStore.getState().files;
const payload = {
files: files.map((f) => ({ name: f.name, content: f.content })),
};
console.log('[EmbedBridge] Responding to get_source_code:', payload.files.length, 'files');
this.send('velxio:source_code', payload);
break;
}
case 'elemes:get_serial_log': {
const state = useSimulatorStore.getState();
// Read from active board's serialOutput
const activeBoard = state.boards.find((b) => b.id === state.activeBoardId);
const log = activeBoard?.serialOutput ?? state.serialOutput ?? '';
console.log('[EmbedBridge] Responding to get_serial_log:', JSON.stringify(log).substring(0, 200));
console.log('[EmbedBridge] activeBoardId:', state.activeBoardId, 'boards:', state.boards.length);
this.send('velxio:serial_log', { log });
break;
}
case 'elemes:get_wires': {
const wires = useSimulatorStore.getState().wires;
console.log('[EmbedBridge] Responding to get_wires:', wires.length, 'wires');
wires.forEach((w, i) => {
console.log(`[EmbedBridge] wire[${i}]: ${w.start.componentId}:${w.start.pinName}${w.end.componentId}:${w.end.pinName}`);
});
this.send('velxio:wires', { wires });
break;
}
case 'elemes:set_embed_mode': {
window.dispatchEvent(
new CustomEvent('velxio-embed-mode', { detail: event.data })
);
break;
}
case 'elemes:ping': {
// Parent missed the initial ready signal — re-send it
this.notifyReady();
break;
}
case 'elemes:compile_and_run': {
// Could trigger compile+run programmatically — future enhancement
break;
}
case 'elemes:stop': {
const store = useSimulatorStore.getState();
if (store.activeBoardId) {
store.stopBoard(store.activeBoardId);
} else {
store.stopSimulation();
}
break;
}
}
}
}
/** Singleton — created once when module loads. */
export const embedBridge = new EmbedBridge();
// Expose Zustand stores on window so parent iframe (same-origin) can access them directly.
// This is a fallback for when PostMessage bridge doesn't connect.
(window as any).__VELXIO_EDITOR_STORE__ = useEditorStore;
(window as any).__VELXIO_SIMULATOR_STORE__ = useSimulatorStore;