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:
parent
540c3081db
commit
6e1781926e
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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}
|
||||
|
|
@ -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}
|
||||
|
|
@ -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)
|
||||
|
|
@ -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))
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
Loading…
Reference in New Issue