import { useEffect, useState } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import { getProjectById } from '../services/projectService'; import { useSimulatorStore } from '../store/useSimulatorStore'; import { useProjectStore } from '../store/useProjectStore'; import { useSEO } from '../utils/useSEO'; import { EditorPage } from './EditorPage'; import type { BoardInstance, BoardKind } from '../types/board'; const DOMAIN = 'https://velxio.dev'; interface ProjectMeta { name: string; description: string; ownerUsername: string; isPublic: boolean; } export const ProjectByIdPage: React.FC = () => { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); const loadProjectState = useSimulatorStore((s) => s.loadProjectState); const setCurrentProject = useProjectStore((s) => s.setCurrentProject); const clearCurrentProject = useProjectStore((s) => s.clearCurrentProject); const currentProject = useProjectStore((s) => s.currentProject); const [ready, setReady] = useState(false); const [error, setError] = useState(''); const [projectMeta, setProjectMeta] = useState(null); // SEO: update once we have real project data; use generic noindex fallback until then. useSEO( projectMeta && projectMeta.isPublic ? { title: `${projectMeta.name} by ${projectMeta.ownerUsername} | Velxio`, description: projectMeta.description ? `${projectMeta.description} — Simulate and remix this Arduino project on Velxio.` : `Arduino project by ${projectMeta.ownerUsername}. View and simulate it free on Velxio.`, url: `${DOMAIN}/project/${id}`, } : { title: 'Project — Velxio Arduino Emulator', description: 'View and simulate this Arduino project on Velxio — free, open-source multi-board emulator.', url: `${DOMAIN}/editor`, noindex: true, }, ); 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 payload = buildLoadPayload(project); // Per-board manifests ride in boards_json (buildLoadPayload migrates // pre-per-board projects), so loadProjectState restores each board's // compile scope directly. loadProjectState(payload); setCurrentProject({ id: project.id, slug: project.slug, ownerUsername: project.owner_username, isPublic: project.is_public, }); setProjectMeta({ name: project.name ?? 'Untitled Project', description: project.description ?? '', ownerUsername: project.owner_username ?? '', isPublic: project.is_public ?? false, }); 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.'); clearCurrentProject(); }); }, [id]); if (error) { return (

{error}

); } if (!ready) { return (

Loading project…

); } return ; }; // ── Helpers ───────────────────────────────────────────────────────────────── interface RawProject { board_type: string; files: { name: string; content: string }[]; file_groups?: { groupId: string; files: { name: string; content: string }[] }[]; boards_json?: string; libraries_json?: string; // P2.4 — declared library manifest (compile scope) code: string; components_json: string; wires_json: string; } /** Build a loadProjectState payload from a server ProjectResponse. Falls back * to a single default board when the project predates multi-board persistence. */ export function buildLoadPayload(project: RawProject) { // Boards let boards: BoardInstance[] = []; try { const parsed = JSON.parse(project.boards_json || '[]'); if (Array.isArray(parsed) && parsed.length > 0) { boards = parsed.map((b: Partial & { id: string; boardKind: string }) => ({ id: b.id, name: b.name, boardKind: b.boardKind as BoardKind, x: b.x ?? 50, y: b.y ?? 50, running: false, compiledProgram: b.compiledProgram ?? null, serialOutput: '', serialBaudRate: b.serialBaudRate ?? 0, serialMonitorOpen: false, activeFileGroupId: b.activeFileGroupId ?? `group-${b.id}`, languageMode: b.languageMode ?? 'arduino', // ESP32 per-board options + uploaded SPIFFS files. Undefined for // pre-feature projects; the compiler falls back to its defaults. boardOptions: b.boardOptions, spiffsFiles: b.spiffsFiles, // P2.4 — this board's declared manifest (per-board compile scope). libraries: b.libraries, })); } } catch { // ignore } if (boards.length === 0) { // Pre-backfill project: synthesise a single board from board_type. const kind = (project.board_type || 'arduino-uno') as BoardKind; boards = [ { id: kind, boardKind: kind, x: 50, y: 50, running: false, compiledProgram: null, serialOutput: '', serialBaudRate: 0, serialMonitorOpen: false, activeFileGroupId: `group-${kind}`, languageMode: 'arduino', }, ]; } // P2.4 migration — projects saved before per-board manifests stored a single // project-level manifest (libraries_json). If no board carries its own, seed // every board with the project union so it keeps compiling scoped. if (!boards.some((b) => b.libraries && b.libraries.length)) { try { const union = JSON.parse(project.libraries_json || '[]'); if (Array.isArray(union) && union.length) { for (const b of boards) b.libraries = union as string[]; } } catch { // ignore } } // File groups const fileGroups: Record = {}; if (project.file_groups && project.file_groups.length > 0) { for (const g of project.file_groups) { fileGroups[g.groupId] = g.files.map((f) => ({ name: f.name, content: f.content })); } } // Ensure every board has a file group: fall back to legacy `files` for the // active board, or to a synthesised sketch.ino from `code`. for (const b of boards) { if (!fileGroups[b.activeFileGroupId] || fileGroups[b.activeFileGroupId].length === 0) { const fallback = project.files && project.files.length > 0 ? project.files : project.code ? [{ name: 'sketch.ino', content: project.code }] : [{ name: 'sketch.ino', content: '' }]; fileGroups[b.activeFileGroupId] = fallback.map((f) => ({ name: f.name, content: f.content, })); } } // Components and wires let components: unknown[] = []; let wires: unknown[] = []; try { components = JSON.parse(project.components_json || '[]'); } catch { components = []; } try { wires = JSON.parse(project.wires_json || '[]'); } catch { wires = []; } // Per-board library manifests ride inside each board (boards_json) and were // migrated above for pre-per-board projects, so loadProjectState restores the // compile scope along with the boards — no separate step needed. return { boards, fileGroups, components: components as never[], wires: wires as never[], activeBoardId: boards[0]?.id ?? null, }; }