feat: /project/:id URL, per-project file volumes, and public/private access control
Backend:
- project_files.py: read/write sketch files to /app/data/projects/{id}/
- GET /api/projects/{id}: load project by ID (public = anyone, private = owner only)
- create/update write files to disk volume; delete removes them
- ProjectResponse includes files[] list loaded from disk
Frontend:
- /project/:id canonical route -> ProjectByIdPage
- ProjectPage (legacy /:username/:slug) redirects to /project/:id after load
- SaveProjectModal sends files[] and navigates to /project/{id} after save
- DATA_DIR env var in both compose files
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
03f2d7f22e
commit
7260c8d092
|
|
@ -8,12 +8,24 @@ from app.core.dependencies import get_current_user, require_auth
|
||||||
from app.database.session import get_db
|
from app.database.session import get_db
|
||||||
from app.models.project import Project
|
from app.models.project import Project
|
||||||
from app.models.user import User
|
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
|
from app.utils.slug import slugify
|
||||||
|
|
||||||
router = APIRouter()
|
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:
|
def _to_response(project: Project, owner_username: str) -> ProjectResponse:
|
||||||
return ProjectResponse(
|
return ProjectResponse(
|
||||||
id=project.id,
|
id=project.id,
|
||||||
|
|
@ -22,6 +34,7 @@ def _to_response(project: Project, owner_username: str) -> ProjectResponse:
|
||||||
description=project.description,
|
description=project.description,
|
||||||
is_public=project.is_public,
|
is_public=project.is_public,
|
||||||
board_type=project.board_type,
|
board_type=project.board_type,
|
||||||
|
files=_files_for_project(project),
|
||||||
code=project.code,
|
code=project.code,
|
||||||
components_json=project.components_json,
|
components_json=project.components_json,
|
||||||
wires_json=project.wires_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
|
counter += 1
|
||||||
|
|
||||||
|
|
||||||
|
# ── My projects (literal route — must be before /{project_id}) ───────────────
|
||||||
|
|
||||||
@router.get("/projects/me", response_model=list[ProjectResponse])
|
@router.get("/projects/me", response_model=list[ProjectResponse])
|
||||||
async def my_projects(
|
async def my_projects(
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
|
|
@ -56,6 +71,30 @@ async def my_projects(
|
||||||
return [_to_response(p, user.username) for p in 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)
|
@router.post("/projects/", response_model=ProjectResponse, status_code=status.HTTP_201_CREATED)
|
||||||
async def create_project(
|
async def create_project(
|
||||||
body: ProjectCreateRequest,
|
body: ProjectCreateRequest,
|
||||||
|
|
@ -79,9 +118,17 @@ async def create_project(
|
||||||
db.add(project)
|
db.add(project)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(project)
|
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)
|
return _to_response(project, user.username)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Update ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@router.put("/projects/{project_id}", response_model=ProjectResponse)
|
@router.put("/projects/{project_id}", response_model=ProjectResponse)
|
||||||
async def update_project(
|
async def update_project(
|
||||||
project_id: str,
|
project_id: str,
|
||||||
|
|
@ -98,7 +145,6 @@ async def update_project(
|
||||||
|
|
||||||
if body.name is not None:
|
if body.name is not None:
|
||||||
project.name = body.name
|
project.name = body.name
|
||||||
# Re-slug only if the name actually changed
|
|
||||||
new_base = slugify(body.name)
|
new_base = slugify(body.name)
|
||||||
if new_base != project.slug:
|
if new_base != project.slug:
|
||||||
project.slug = await _unique_slug(db, user.id, new_base)
|
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)
|
project.updated_at = datetime.now(timezone.utc)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(project)
|
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)
|
return _to_response(project, user.username)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Delete ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@router.delete("/projects/{project_id}", status_code=status.HTTP_204_NO_CONTENT)
|
@router.delete("/projects/{project_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
async def delete_project(
|
async def delete_project(
|
||||||
project_id: str,
|
project_id: str,
|
||||||
|
|
@ -135,8 +193,11 @@ async def delete_project(
|
||||||
raise HTTPException(status_code=403, detail="Forbidden.")
|
raise HTTPException(status_code=403, detail="Forbidden.")
|
||||||
await db.delete(project)
|
await db.delete(project)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
delete_files(project_id)
|
||||||
|
|
||||||
|
|
||||||
|
# ── User public projects ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
@router.get("/user/{username}", response_model=list[ProjectResponse])
|
@router.get("/user/{username}", response_model=list[ProjectResponse])
|
||||||
async def user_projects(
|
async def user_projects(
|
||||||
username: str,
|
username: str,
|
||||||
|
|
@ -158,8 +219,10 @@ async def user_projects(
|
||||||
return [_to_response(p, owner.username) for p in projects]
|
return [_to_response(p, owner.username) for p in projects]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Get by username/slug ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
@router.get("/user/{username}/{slug}", response_model=ProjectResponse)
|
@router.get("/user/{username}/{slug}", response_model=ProjectResponse)
|
||||||
async def get_project(
|
async def get_project_by_slug(
|
||||||
username: str,
|
username: str,
|
||||||
slug: str,
|
slug: str,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
|
|
@ -179,6 +242,6 @@ async def get_project(
|
||||||
|
|
||||||
is_own = current_user and current_user.id == owner.id
|
is_own = current_user and current_user.id == owner.id
|
||||||
if not project.is_public and not is_own:
|
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)
|
return _to_response(project, owner.username)
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,19 @@ from datetime import datetime
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class SketchFile(BaseModel):
|
||||||
|
name: str
|
||||||
|
content: str
|
||||||
|
|
||||||
|
|
||||||
class ProjectCreateRequest(BaseModel):
|
class ProjectCreateRequest(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
is_public: bool = True
|
is_public: bool = True
|
||||||
board_type: str = "arduino-uno"
|
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 = "[]"
|
components_json: str = "[]"
|
||||||
wires_json: str = "[]"
|
wires_json: str = "[]"
|
||||||
|
|
||||||
|
|
@ -18,7 +25,8 @@ class ProjectUpdateRequest(BaseModel):
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
is_public: bool | None = None
|
is_public: bool | None = None
|
||||||
board_type: str | 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
|
components_json: str | None = None
|
||||||
wires_json: str | None = None
|
wires_json: str | None = None
|
||||||
|
|
||||||
|
|
@ -30,6 +38,9 @@ class ProjectResponse(BaseModel):
|
||||||
description: str | None
|
description: str | None
|
||||||
is_public: bool
|
is_public: bool
|
||||||
board_type: str
|
board_type: str
|
||||||
|
# Files loaded from disk volume
|
||||||
|
files: list[SketchFile] = []
|
||||||
|
# Legacy single-file code (kept for backwards compat)
|
||||||
code: str
|
code: str
|
||||||
components_json: str
|
components_json: str
|
||||||
wires_json: str
|
wires_json: str
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -9,6 +9,7 @@ services:
|
||||||
- "3080:80"
|
- "3080:80"
|
||||||
environment:
|
environment:
|
||||||
- DATABASE_URL=sqlite+aiosqlite:////app/data/velxio.db
|
- DATABASE_URL=sqlite+aiosqlite:////app/data/velxio.db
|
||||||
|
- DATA_DIR=/app/data
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ services:
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
- DATABASE_URL=sqlite+aiosqlite:////app/data/velxio.db
|
- DATABASE_URL=sqlite+aiosqlite:////app/data/velxio.db
|
||||||
|
- DATA_DIR=/app/data
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { LoginPage } from './pages/LoginPage';
|
||||||
import { RegisterPage } from './pages/RegisterPage';
|
import { RegisterPage } from './pages/RegisterPage';
|
||||||
import { UserProfilePage } from './pages/UserProfilePage';
|
import { UserProfilePage } from './pages/UserProfilePage';
|
||||||
import { ProjectPage } from './pages/ProjectPage';
|
import { ProjectPage } from './pages/ProjectPage';
|
||||||
|
import { ProjectByIdPage } from './pages/ProjectByIdPage';
|
||||||
import { useAuthStore } from './store/useAuthStore';
|
import { useAuthStore } from './store/useAuthStore';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
|
|
||||||
|
|
@ -25,7 +26,9 @@ function App() {
|
||||||
<Route path="/examples" element={<ExamplesPage />} />
|
<Route path="/examples" element={<ExamplesPage />} />
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
<Route path="/register" element={<RegisterPage />} />
|
<Route path="/register" element={<RegisterPage />} />
|
||||||
{/* Specific literal routes must come before wildcard /:username */}
|
{/* Canonical project URL by ID */}
|
||||||
|
<Route path="/project/:id" element={<ProjectByIdPage />} />
|
||||||
|
{/* Legacy slug route — redirects to /project/:id */}
|
||||||
<Route path="/:username/:projectName" element={<ProjectPage />} />
|
<Route path="/:username/:projectName" element={<ProjectPage />} />
|
||||||
<Route path="/:username" element={<UserProfilePage />} />
|
<Route path="/:username" element={<UserProfilePage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ export const SaveProjectModal: React.FC<SaveProjectModalProps> = ({ onClose }) =
|
||||||
description: description.trim() || undefined,
|
description: description.trim() || undefined,
|
||||||
is_public: isPublic,
|
is_public: isPublic,
|
||||||
board_type: boardType,
|
board_type: boardType,
|
||||||
|
files: files.map((f) => ({ name: f.name, content: f.content })),
|
||||||
code,
|
code,
|
||||||
components_json: JSON.stringify(components),
|
components_json: JSON.stringify(components),
|
||||||
wires_json: JSON.stringify(wires),
|
wires_json: JSON.stringify(wires),
|
||||||
|
|
@ -62,7 +63,7 @@ export const SaveProjectModal: React.FC<SaveProjectModalProps> = ({ onClose }) =
|
||||||
ownerUsername: saved.owner_username,
|
ownerUsername: saved.owner_username,
|
||||||
isPublic: saved.is_public,
|
isPublic: saved.is_public,
|
||||||
});
|
});
|
||||||
navigate(`/${saved.owner_username}/${saved.slug}`, { replace: true });
|
navigate(`/project/${saved.id}`, { replace: true });
|
||||||
onClose();
|
onClose();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err?.response?.data?.detail || 'Save failed.');
|
setError(err?.response?.data?.detail || 'Save failed.');
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
|
<div style={{ minHeight: '100vh', background: '#1e1e1e', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<div style={{ color: '#f44747', fontSize: 16, textAlign: 'center' }}>
|
||||||
|
<p>{error}</p>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/')}
|
||||||
|
style={{ marginTop: 12, background: '#0e639c', border: 'none', color: '#fff', padding: '8px 16px', borderRadius: 4, cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
Go home
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ready) {
|
||||||
|
return (
|
||||||
|
<div style={{ minHeight: '100vh', background: '#1e1e1e', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<p style={{ color: '#9d9d9d' }}>Loading project…</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <EditorPage />;
|
||||||
|
};
|
||||||
|
|
@ -6,10 +6,15 @@ import { useSimulatorStore } from '../store/useSimulatorStore';
|
||||||
import { useProjectStore } from '../store/useProjectStore';
|
import { useProjectStore } from '../store/useProjectStore';
|
||||||
import { EditorPage } from './EditorPage';
|
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 = () => {
|
export const ProjectPage: React.FC = () => {
|
||||||
const { username, projectName } = useParams<{ username: string; projectName: string }>();
|
const { username, projectName } = useParams<{ username: string; projectName: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const setCode = useEditorStore((s) => s.setCode);
|
const loadFiles = useEditorStore((s) => s.loadFiles);
|
||||||
const { setComponents, setWires, setBoardType } = useSimulatorStore();
|
const { setComponents, setWires, setBoardType } = useSimulatorStore();
|
||||||
const setCurrentProject = useProjectStore((s) => s.setCurrentProject);
|
const setCurrentProject = useProjectStore((s) => s.setCurrentProject);
|
||||||
const [ready, setReady] = useState(false);
|
const [ready, setReady] = useState(false);
|
||||||
|
|
@ -19,13 +24,17 @@ export const ProjectPage: React.FC = () => {
|
||||||
if (!username || !projectName) return;
|
if (!username || !projectName) return;
|
||||||
getProject(username, projectName)
|
getProject(username, projectName)
|
||||||
.then((project) => {
|
.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);
|
setBoardType(project.board_type as any);
|
||||||
try {
|
try {
|
||||||
setComponents(JSON.parse(project.components_json));
|
setComponents(JSON.parse(project.components_json));
|
||||||
setWires(JSON.parse(project.wires_json));
|
setWires(JSON.parse(project.wires_json));
|
||||||
} catch {
|
} catch {
|
||||||
// keep defaults if JSON is malformed
|
// keep defaults
|
||||||
}
|
}
|
||||||
setCurrentProject({
|
setCurrentProject({
|
||||||
id: project.id,
|
id: project.id,
|
||||||
|
|
@ -33,12 +42,14 @@ export const ProjectPage: React.FC = () => {
|
||||||
ownerUsername: project.owner_username,
|
ownerUsername: project.owner_username,
|
||||||
isPublic: project.is_public,
|
isPublic: project.is_public,
|
||||||
});
|
});
|
||||||
|
// Redirect to canonical ID URL
|
||||||
|
navigate(`/project/${project.id}`, { replace: true });
|
||||||
setReady(true);
|
setReady(true);
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
const status = err?.response?.status;
|
const s = err?.response?.status;
|
||||||
if (status === 404) setError('Project not found.');
|
if (s === 404) setError('Project not found.');
|
||||||
else if (status === 403) setError('This project is private.');
|
else if (s === 403) setError('This project is private.');
|
||||||
else setError('Failed to load project.');
|
else setError('Failed to load project.');
|
||||||
});
|
});
|
||||||
}, [username, projectName]);
|
}, [username, projectName]);
|
||||||
|
|
@ -49,7 +60,7 @@ export const ProjectPage: React.FC = () => {
|
||||||
<div style={{ color: '#f44747', fontSize: 16, textAlign: 'center' }}>
|
<div style={{ color: '#f44747', fontSize: 16, textAlign: 'center' }}>
|
||||||
<p>{error}</p>
|
<p>{error}</p>
|
||||||
<button onClick={() => navigate('/')} style={{ marginTop: 12, background: '#0e639c', border: 'none', color: '#fff', padding: '8px 16px', borderRadius: 4, cursor: 'pointer' }}>
|
<button onClick={() => navigate('/')} style={{ marginTop: 12, background: '#0e639c', border: 'none', color: '#fff', padding: '8px 16px', borderRadius: 4, cursor: 'pointer' }}>
|
||||||
Go to home
|
Go home
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
const api = axios.create({ baseURL: API_BASE, withCredentials: true });
|
||||||
|
|
||||||
|
export interface SketchFile {
|
||||||
|
name: string;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ProjectResponse {
|
export interface ProjectResponse {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -11,7 +16,8 @@ export interface ProjectResponse {
|
||||||
description: string | null;
|
description: string | null;
|
||||||
is_public: boolean;
|
is_public: boolean;
|
||||||
board_type: string;
|
board_type: string;
|
||||||
code: string;
|
files: SketchFile[];
|
||||||
|
code: string; // legacy fallback
|
||||||
components_json: string;
|
components_json: string;
|
||||||
wires_json: string;
|
wires_json: string;
|
||||||
owner_username: string;
|
owner_username: string;
|
||||||
|
|
@ -24,7 +30,8 @@ export interface ProjectSaveData {
|
||||||
description?: string;
|
description?: string;
|
||||||
is_public: boolean;
|
is_public: boolean;
|
||||||
board_type: string;
|
board_type: string;
|
||||||
code: string;
|
files: SketchFile[];
|
||||||
|
code?: string; // legacy fallback
|
||||||
components_json: string;
|
components_json: string;
|
||||||
wires_json: string;
|
wires_json: string;
|
||||||
}
|
}
|
||||||
|
|
@ -39,6 +46,11 @@ export async function getUserProjects(username: string): Promise<ProjectResponse
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getProjectById(id: string): Promise<ProjectResponse> {
|
||||||
|
const { data } = await api.get<ProjectResponse>(`/projects/${id}`);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
export async function getProject(username: string, slug: string): Promise<ProjectResponse> {
|
export async function getProject(username: string, slug: string): Promise<ProjectResponse> {
|
||||||
const { data } = await api.get<ProjectResponse>(`/user/${username}/${slug}`);
|
const { data } = await api.get<ProjectResponse>(`/user/${username}/${slug}`);
|
||||||
return data;
|
return data;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue