diff --git a/backend/app/api/routes/projects.py b/backend/app/api/routes/projects.py
index 4582805..4714528 100644
--- a/backend/app/api/routes/projects.py
+++ b/backend/app/api/routes/projects.py
@@ -8,12 +8,24 @@ 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
+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,
@@ -22,6 +34,7 @@ def _to_response(project: Project, owner_username: str) -> ProjectResponse:
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,
@@ -44,6 +57,8 @@ async def _unique_slug(db: AsyncSession, user_id: str, base_slug: str) -> str:
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),
@@ -56,6 +71,30 @@ async def my_projects(
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,
@@ -79,9 +118,17 @@ async def create_project(
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,
@@ -98,7 +145,6 @@ async def update_project(
if body.name is not None:
project.name = body.name
- # Re-slug only if the name actually changed
new_base = slugify(body.name)
if new_base != project.slug:
project.slug = await _unique_slug(db, user.id, new_base)
@@ -118,9 +164,21 @@ async def update_project(
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,
@@ -135,8 +193,11 @@ async def delete_project(
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,
@@ -158,8 +219,10 @@ async def user_projects(
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(
+async def get_project_by_slug(
username: str,
slug: str,
db: AsyncSession = Depends(get_db),
@@ -179,6 +242,6 @@ async def get_project(
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="Forbidden.")
+ raise HTTPException(status_code=403, detail="This project is private.")
return _to_response(project, owner.username)
diff --git a/backend/app/schemas/project.py b/backend/app/schemas/project.py
index 1ba3510..acae867 100644
--- a/backend/app/schemas/project.py
+++ b/backend/app/schemas/project.py
@@ -3,12 +3,19 @@ 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"
- code: str = ""
+ # 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 = "[]"
@@ -18,7 +25,8 @@ class ProjectUpdateRequest(BaseModel):
description: str | None = None
is_public: bool | None = None
board_type: str | None = None
- code: str | None = None
+ files: list[SketchFile] | None = None
+ code: str | None = None # legacy
components_json: str | None = None
wires_json: str | None = None
@@ -30,6 +38,9 @@ class ProjectResponse(BaseModel):
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
diff --git a/backend/app/services/project_files.py b/backend/app/services/project_files.py
new file mode 100644
index 0000000..0e03f23
--- /dev/null
+++ b/backend/app/services/project_files.py
@@ -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)
diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml
index f43f71f..76497ee 100644
--- a/docker-compose.prod.yml
+++ b/docker-compose.prod.yml
@@ -9,6 +9,7 @@ services:
- "3080:80"
environment:
- DATABASE_URL=sqlite+aiosqlite:////app/data/velxio.db
+ - DATA_DIR=/app/data
volumes:
- ./data:/app/data
healthcheck:
diff --git a/docker-compose.yml b/docker-compose.yml
index 643069e..be38022 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -8,6 +8,7 @@ services:
restart: unless-stopped
environment:
- DATABASE_URL=sqlite+aiosqlite:////app/data/velxio.db
+ - DATA_DIR=/app/data
volumes:
- ./data:/app/data
healthcheck:
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index b0a58fb..084a6c7 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -7,6 +7,7 @@ import { LoginPage } from './pages/LoginPage';
import { RegisterPage } from './pages/RegisterPage';
import { UserProfilePage } from './pages/UserProfilePage';
import { ProjectPage } from './pages/ProjectPage';
+import { ProjectByIdPage } from './pages/ProjectByIdPage';
import { useAuthStore } from './store/useAuthStore';
import './App.css';
@@ -25,7 +26,9 @@ function App() {
} />
} />
} />
- {/* Specific literal routes must come before wildcard /:username */}
+ {/* Canonical project URL by ID */}
+ } />
+ {/* Legacy slug route — redirects to /project/:id */}
} />
} />
diff --git a/frontend/src/components/layout/SaveProjectModal.tsx b/frontend/src/components/layout/SaveProjectModal.tsx
index a22715a..443b6ee 100644
--- a/frontend/src/components/layout/SaveProjectModal.tsx
+++ b/frontend/src/components/layout/SaveProjectModal.tsx
@@ -44,6 +44,7 @@ export const SaveProjectModal: React.FC = ({ onClose }) =
description: description.trim() || undefined,
is_public: isPublic,
board_type: boardType,
+ files: files.map((f) => ({ name: f.name, content: f.content })),
code,
components_json: JSON.stringify(components),
wires_json: JSON.stringify(wires),
@@ -62,7 +63,7 @@ export const SaveProjectModal: React.FC = ({ onClose }) =
ownerUsername: saved.owner_username,
isPublic: saved.is_public,
});
- navigate(`/${saved.owner_username}/${saved.slug}`, { replace: true });
+ navigate(`/project/${saved.id}`, { replace: true });
onClose();
} catch (err: any) {
setError(err?.response?.data?.detail || 'Save failed.');
diff --git a/frontend/src/pages/ProjectByIdPage.tsx b/frontend/src/pages/ProjectByIdPage.tsx
new file mode 100644
index 0000000..c5a7f15
--- /dev/null
+++ b/frontend/src/pages/ProjectByIdPage.tsx
@@ -0,0 +1,80 @@
+import { useEffect, useState } from 'react';
+import { useParams, useNavigate } from 'react-router-dom';
+import { getProjectById } from '../services/projectService';
+import { useEditorStore } from '../store/useEditorStore';
+import { useSimulatorStore } from '../store/useSimulatorStore';
+import { useProjectStore } from '../store/useProjectStore';
+import { EditorPage } from './EditorPage';
+
+export const ProjectByIdPage: React.FC = () => {
+ const { id } = useParams<{ id: string }>();
+ const navigate = useNavigate();
+ const loadFiles = useEditorStore((s) => s.loadFiles);
+ const { setComponents, setWires, setBoardType } = useSimulatorStore();
+ const setCurrentProject = useProjectStore((s) => s.setCurrentProject);
+ const currentProject = useProjectStore((s) => s.currentProject);
+ const [ready, setReady] = useState(false);
+ const [error, setError] = useState('');
+
+ useEffect(() => {
+ if (!id) return;
+ // If this project is already loaded in the store (e.g. navigated here
+ // right after saving) skip the fetch to avoid overwriting unsaved state.
+ if (currentProject?.id === id && ready) return;
+
+ getProjectById(id)
+ .then((project) => {
+ const files =
+ project.files.length > 0
+ ? project.files
+ : [{ name: 'sketch.ino', content: project.code }];
+ loadFiles(files);
+ setBoardType(project.board_type as any);
+ try {
+ setComponents(JSON.parse(project.components_json));
+ setWires(JSON.parse(project.wires_json));
+ } catch {
+ // keep defaults if JSON is malformed
+ }
+ setCurrentProject({
+ id: project.id,
+ slug: project.slug,
+ ownerUsername: project.owner_username,
+ isPublic: project.is_public,
+ });
+ setReady(true);
+ })
+ .catch((err) => {
+ const s = err?.response?.status;
+ if (s === 404) setError('Project not found.');
+ else if (s === 403) setError('This project is private.');
+ else setError('Failed to load project.');
+ });
+ }, [id]);
+
+ if (error) {
+ return (
+
+
+
{error}
+
+
+
+ );
+ }
+
+ if (!ready) {
+ return (
+
+ );
+ }
+
+ return ;
+};
diff --git a/frontend/src/pages/ProjectPage.tsx b/frontend/src/pages/ProjectPage.tsx
index 59d48ea..526fc8e 100644
--- a/frontend/src/pages/ProjectPage.tsx
+++ b/frontend/src/pages/ProjectPage.tsx
@@ -6,10 +6,15 @@ import { useSimulatorStore } from '../store/useSimulatorStore';
import { useProjectStore } from '../store/useProjectStore';
import { EditorPage } from './EditorPage';
+/**
+ * Legacy route: /:username/:projectName
+ * Loads the project by slug then redirects to /project/:id so the canonical
+ * URL is always the ID-based one.
+ */
export const ProjectPage: React.FC = () => {
const { username, projectName } = useParams<{ username: string; projectName: string }>();
const navigate = useNavigate();
- const setCode = useEditorStore((s) => s.setCode);
+ const loadFiles = useEditorStore((s) => s.loadFiles);
const { setComponents, setWires, setBoardType } = useSimulatorStore();
const setCurrentProject = useProjectStore((s) => s.setCurrentProject);
const [ready, setReady] = useState(false);
@@ -19,13 +24,17 @@ export const ProjectPage: React.FC = () => {
if (!username || !projectName) return;
getProject(username, projectName)
.then((project) => {
- setCode(project.code);
+ const files =
+ project.files.length > 0
+ ? project.files
+ : [{ name: 'sketch.ino', content: project.code }];
+ loadFiles(files);
setBoardType(project.board_type as any);
try {
setComponents(JSON.parse(project.components_json));
setWires(JSON.parse(project.wires_json));
} catch {
- // keep defaults if JSON is malformed
+ // keep defaults
}
setCurrentProject({
id: project.id,
@@ -33,12 +42,14 @@ export const ProjectPage: React.FC = () => {
ownerUsername: project.owner_username,
isPublic: project.is_public,
});
+ // Redirect to canonical ID URL
+ navigate(`/project/${project.id}`, { replace: true });
setReady(true);
})
.catch((err) => {
- const status = err?.response?.status;
- if (status === 404) setError('Project not found.');
- else if (status === 403) setError('This project is private.');
+ const s = err?.response?.status;
+ if (s === 404) setError('Project not found.');
+ else if (s === 403) setError('This project is private.');
else setError('Failed to load project.');
});
}, [username, projectName]);
@@ -49,7 +60,7 @@ export const ProjectPage: React.FC = () => {
{error}
diff --git a/frontend/src/services/projectService.ts b/frontend/src/services/projectService.ts
index 6043676..58a0f4b 100644
--- a/frontend/src/services/projectService.ts
+++ b/frontend/src/services/projectService.ts
@@ -4,6 +4,11 @@ const API_BASE = import.meta.env.VITE_API_BASE || 'http://localhost:8001/api';
const api = axios.create({ baseURL: API_BASE, withCredentials: true });
+export interface SketchFile {
+ name: string;
+ content: string;
+}
+
export interface ProjectResponse {
id: string;
name: string;
@@ -11,7 +16,8 @@ export interface ProjectResponse {
description: string | null;
is_public: boolean;
board_type: string;
- code: string;
+ files: SketchFile[];
+ code: string; // legacy fallback
components_json: string;
wires_json: string;
owner_username: string;
@@ -24,7 +30,8 @@ export interface ProjectSaveData {
description?: string;
is_public: boolean;
board_type: string;
- code: string;
+ files: SketchFile[];
+ code?: string; // legacy fallback
components_json: string;
wires_json: string;
}
@@ -39,6 +46,11 @@ export async function getUserProjects(username: string): Promise {
+ const { data } = await api.get(`/projects/${id}`);
+ return data;
+}
+
export async function getProject(username: string, slug: string): Promise {
const { data } = await api.get(`/user/${username}/${slug}`);
return data;