feat(board-options): per-board options modal + persistence

Adds a new BoardOptionsModal accessible from the EditorToolbar that exposes
per-board options (currently used for board-specific compile flags). Wires
the modal through:

- types/boardOptions.ts            new BoardOptions shape
- types/board.ts                   BoardInstance gains `boardOptions` + `spiffsFiles`
- store/useSimulatorStore.ts       boardOptions persisted in loadProjectState
- components/editor/EditorToolbar.tsx     button to open the modal
- components/simulator/BoardOptionsModal.{tsx,css}  the modal itself
- components/simulator/SimulatorCanvas.tsx  passes the options through
- utils/projectPayload.ts          board options serialised in saved projects
- pages/ProjectByIdPage.tsx        re-includes the by-id loader needed for
                                   project URLs that reference boards with
                                   their persisted options.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
David Montero Crespo 2026-05-18 02:38:51 -03:00
parent 6a1e506db6
commit 16f168ba7f
9 changed files with 1415 additions and 1 deletions

View File

@ -245,6 +245,12 @@ export const EditorToolbar = ({
})),
]);
},
// Per-board ESP32 build options + SPIFFS uploads. Undefined for AVR
// / RP2040 boards (ignored on those paths by the backend).
{
boardOptions: activeBoard?.boardOptions,
spiffsFiles: activeBoard?.spiffsFiles,
},
);
// After the build settles, append the structured analysis on top of
@ -654,6 +660,7 @@ export const EditorToolbar = ({
})),
]);
},
{ boardOptions: board.boardOptions, spiffsFiles: board.spiffsFiles },
);
const resultLogs = parseCompileResult(result, label);

View File

@ -0,0 +1,341 @@
.bom-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.75);
display: flex;
align-items: center;
justify-content: center;
z-index: 1100;
backdrop-filter: blur(4px);
animation: bom-fadeIn 0.15s ease;
}
@keyframes bom-fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.bom-modal {
background: #1a1a1a;
border: 1px solid #333;
border-radius: 8px;
width: 620px;
max-width: 95vw;
max-height: 85vh;
display: flex;
flex-direction: column;
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.6);
animation: bom-slideIn 0.2s ease;
overflow: hidden;
}
@keyframes bom-slideIn {
from {
transform: translateY(-16px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.bom-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 18px;
background: #111;
border-bottom: 1px solid #2a2a2a;
}
.bom-title {
display: flex;
flex-direction: column;
gap: 2px;
}
.bom-title-main {
font-size: 13px;
font-weight: 700;
letter-spacing: 0.12em;
color: #ccc;
text-transform: uppercase;
}
.bom-title-sub {
font-size: 11px;
color: #888;
}
.bom-close {
background: none;
border: none;
color: #888;
font-size: 18px;
cursor: pointer;
padding: 4px 8px;
border-radius: 4px;
line-height: 1;
}
.bom-close:hover {
background: #2a2a2a;
color: #fff;
}
.bom-tabs {
display: flex;
background: #111;
border-bottom: 1px solid #2a2a2a;
}
.bom-tab {
padding: 10px 16px;
background: transparent;
border: none;
border-bottom: 2px solid transparent;
color: #888;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.04em;
cursor: pointer;
}
.bom-tab:hover {
color: #ccc;
}
.bom-tab.active {
color: #4fc3f7;
border-bottom-color: #4fc3f7;
}
.bom-body {
padding: 16px 18px;
overflow-y: auto;
flex: 1;
}
.bom-section {
margin-bottom: 18px;
}
.bom-section-title {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.16em;
color: #888;
text-transform: uppercase;
margin: 0 0 8px 0;
padding-bottom: 4px;
border-bottom: 1px solid #2a2a2a;
}
.bom-row {
display: grid;
grid-template-columns: 160px 1fr;
align-items: center;
gap: 12px;
padding: 6px 0;
}
.bom-label {
font-size: 12px;
color: #bbb;
}
.bom-control {
display: flex;
align-items: center;
gap: 8px;
}
.bom-select {
background: #2a2a2a;
color: #ddd;
border: 1px solid #444;
border-radius: 4px;
padding: 4px 8px;
font-size: 12px;
min-width: 200px;
}
.bom-select:focus {
outline: 1px solid #4fc3f7;
}
.bom-check {
accent-color: #4fc3f7;
}
.bom-badge {
display: inline-block;
padding: 2px 6px;
border-radius: 3px;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.bom-badge-warn {
background: #4a2a00;
color: #ffb74d;
}
.bom-badge-error {
background: #5a1a1a;
color: #ff8080;
}
.bom-warning {
margin: 8px 0 12px 0;
padding: 8px 10px;
background: #3a2a00;
border: 1px solid #6a4a00;
border-radius: 4px;
color: #ffd07a;
font-size: 11px;
line-height: 1.4;
}
.bom-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 12px 18px;
background: #111;
border-top: 1px solid #2a2a2a;
}
.bom-btn {
padding: 6px 14px;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
border: 1px solid transparent;
}
.bom-btn-secondary {
background: transparent;
border-color: #555;
color: #aaa;
}
.bom-btn-secondary:hover {
background: #2a2a2a;
color: #fff;
}
.bom-btn-primary {
background: #0e639c;
border-color: #0e639c;
color: #fff;
}
.bom-btn-primary:hover {
background: #1177bd;
}
/* ── SPIFFS panel ───────────────────────────────────────────────────────── */
.bom-spiffs-empty {
padding: 24px;
text-align: center;
color: #888;
font-size: 12px;
border: 1px dashed #333;
border-radius: 4px;
margin-bottom: 12px;
}
.bom-spiffs-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
margin-bottom: 12px;
}
.bom-spiffs-table th {
text-align: left;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.08em;
color: #888;
text-transform: uppercase;
padding: 6px 8px;
border-bottom: 1px solid #2a2a2a;
}
.bom-spiffs-table td {
padding: 6px 8px;
border-bottom: 1px solid #222;
color: #ddd;
}
.bom-spiffs-name {
font-family: 'Consolas', 'Monaco', monospace;
font-size: 12px;
}
.bom-spiffs-size {
font-family: 'Consolas', 'Monaco', monospace;
font-size: 11px;
color: #aaa;
text-align: right;
}
.bom-spiffs-actions {
text-align: right;
}
.bom-spiffs-delete {
background: none;
border: 1px solid #555;
color: #aaa;
padding: 2px 8px;
border-radius: 3px;
font-size: 11px;
cursor: pointer;
}
.bom-spiffs-delete:hover {
background: #5a1a1a;
border-color: #a33;
color: #fff;
}
.bom-spiffs-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.bom-spiffs-add {
background: #0e639c;
border: none;
color: #fff;
padding: 6px 12px;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
}
.bom-spiffs-add:hover {
background: #1177bd;
}
.bom-spiffs-tally {
font-size: 11px;
color: #888;
font-family: 'Consolas', 'Monaco', monospace;
}
.bom-spiffs-tally.over {
color: #ff8080;
}

View File

@ -0,0 +1,541 @@
import { useCallback, useMemo, useRef, useState } from 'react';
import type { BoardKind } from '../../types/board';
import { BOARD_KIND_LABELS } from '../../types/board';
import type {
ESP32BoardOptions,
ESP32CoreSelect,
SpiffsFile,
} from '../../types/boardOptions';
import {
CORE_SELECT_OPTIONS,
CPU_FREQ_OPTIONS,
DEBUG_LEVEL_OPTIONS,
FLASH_FREQ_OPTIONS,
FLASH_MODE_OPTIONS,
FLASH_SIZE_OPTIONS,
PARTITION_SCHEME_FS_SIZE,
PARTITION_SCHEME_LABELS,
boardSupportsOpiPsram,
boardSupportsPsram,
getDefaultOptionsForKind,
} from '../../types/boardOptions';
import './BoardOptionsModal.css';
interface BoardOptionsModalProps {
isOpen: boolean;
boardId: string;
boardKind: BoardKind;
currentOptions: ESP32BoardOptions | undefined;
spiffsFiles: SpiffsFile[];
onClose: () => void;
onApply: (next: ESP32BoardOptions) => void;
onSpiffsChange: (next: SpiffsFile[]) => void;
}
type TabKey = 'options' | 'files';
const PARTITION_SCHEMES: (keyof typeof PARTITION_SCHEME_LABELS)[] = [
'default',
'defaults_ffat',
'min_spiffs',
'min_ffat',
'no_ota',
'no_fs',
'huge_app',
'large_spiffs',
'rainmaker',
];
const MAX_FILE_BYTES = 1 * 1024 * 1024; // 1 MB per file warning
const SOFT_TOTAL_CAP = 4 * 1024 * 1024; // 4 MB total guardrail
function fmtBytes(n: number): string {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
return `${(n / (1024 * 1024)).toFixed(2)} MB`;
}
async function readFileAsBase64(file: File): Promise<string> {
const buf = await file.arrayBuffer();
const bytes = new Uint8Array(buf);
let bin = '';
// Chunk to avoid the call-stack ceiling on large files.
const CHUNK = 0x8000;
for (let i = 0; i < bytes.length; i += CHUNK) {
bin += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
}
return btoa(bin);
}
export const BoardOptionsModal = ({
isOpen,
boardId,
boardKind,
currentOptions,
spiffsFiles,
onClose,
onApply,
onSpiffsChange,
}: BoardOptionsModalProps) => {
const seed = useMemo(
() => currentOptions ?? getDefaultOptionsForKind(boardKind),
[currentOptions, boardKind],
);
const [tab, setTab] = useState<TabKey>('options');
const [draft, setDraft] = useState<ESP32BoardOptions>(seed);
const fileInputRef = useRef<HTMLInputElement | null>(null);
if (!isOpen) return null;
const showPsram = boardSupportsPsram(boardKind);
const showOpi = boardSupportsOpiPsram(boardKind);
const fsCapacity = PARTITION_SCHEME_FS_SIZE[draft.partitionScheme] ?? 0;
const totalUploaded = spiffsFiles.reduce((sum, f) => sum + f.size, 0);
const overSchemeCap = fsCapacity > 0 && totalUploaded > fsCapacity;
const noFsButHasFiles = fsCapacity === 0 && spiffsFiles.length > 0;
const nonDio = draft.flashMode !== 'dio';
const sameCore = draft.eventsRunOnCore === draft.arduinoRunsOnCore;
const update = useCallback(
(patch: Partial<ESP32BoardOptions>) => setDraft((d) => ({ ...d, ...patch })),
[],
);
const handleApply = () => {
// Strip PSRAM for boards that don't support it (e.g. C3) so a stale
// value can't smuggle through to the backend.
const sanitised: ESP32BoardOptions = { ...draft };
if (!showPsram) sanitised.psram = 'disabled';
if (!showOpi && sanitised.psram === 'opi') sanitised.psram = 'enabled';
onApply(sanitised);
onClose();
};
const handleFiles = async (files: FileList | null) => {
if (!files || files.length === 0) return;
const next: SpiffsFile[] = [...spiffsFiles];
for (const f of Array.from(files)) {
if (next.some((existing) => existing.name === f.name)) {
const overwrite = window.confirm(
`A file named "${f.name}" already exists. Overwrite?`,
);
if (!overwrite) continue;
}
const contentB64 = await readFileAsBase64(f);
const entry: SpiffsFile = {
name: f.name,
contentB64,
size: f.size,
};
const i = next.findIndex((existing) => existing.name === f.name);
if (i >= 0) next[i] = entry;
else next.push(entry);
}
onSpiffsChange(next);
};
const handleDelete = (name: string) => {
onSpiffsChange(spiffsFiles.filter((f) => f.name !== name));
};
return (
<div className="bom-overlay" onClick={onClose}>
<div className="bom-modal" onClick={(e) => e.stopPropagation()}>
<div className="bom-header">
<div className="bom-title">
<div className="bom-title-main">Board Options</div>
<div className="bom-title-sub">
{BOARD_KIND_LABELS[boardKind]} - id {boardId}
</div>
</div>
<button className="bom-close" onClick={onClose} aria-label="Close">
&times;
</button>
</div>
<div className="bom-tabs">
<button
className={`bom-tab ${tab === 'options' ? 'active' : ''}`}
onClick={() => setTab('options')}
>
Board Options
</button>
<button
className={`bom-tab ${tab === 'files' ? 'active' : ''}`}
onClick={() => setTab('files')}
>
Filesystem Files ({spiffsFiles.length})
</button>
</div>
<div className="bom-body">
{tab === 'options' ? (
<>
<section className="bom-section">
<h4 className="bom-section-title">Memory</h4>
<div className="bom-row">
<label className="bom-label">Partition Scheme</label>
<div className="bom-control">
<select
className="bom-select"
value={draft.partitionScheme}
onChange={(e) =>
update({
partitionScheme: e.target.value as ESP32BoardOptions['partitionScheme'],
})
}
>
{PARTITION_SCHEMES.map((s) => (
<option key={s} value={s}>
{PARTITION_SCHEME_LABELS[s]}
</option>
))}
</select>
</div>
</div>
<div className="bom-row">
<label className="bom-label">Flash Size</label>
<div className="bom-control">
<select
className="bom-select"
value={draft.flashSize}
onChange={(e) =>
update({ flashSize: e.target.value as ESP32BoardOptions['flashSize'] })
}
>
{FLASH_SIZE_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
{draft.flashSize !== '4MB' && (
<span className="bom-badge bom-badge-warn">QEMU image grows</span>
)}
</div>
</div>
</section>
<section className="bom-section">
<h4 className="bom-section-title">Speed</h4>
<div className="bom-row">
<label className="bom-label">CPU Frequency</label>
<div className="bom-control">
<select
className="bom-select"
value={draft.cpuFreqMHz}
onChange={(e) =>
update({
cpuFreqMHz: Number(e.target.value) as ESP32BoardOptions['cpuFreqMHz'],
})
}
>
{CPU_FREQ_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</div>
</div>
<div className="bom-row">
<label className="bom-label">Flash Frequency</label>
<div className="bom-control">
<select
className="bom-select"
value={draft.flashFreqMHz}
onChange={(e) =>
update({
flashFreqMHz: e.target.value as ESP32BoardOptions['flashFreqMHz'],
})
}
>
{FLASH_FREQ_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</div>
</div>
</section>
<section className="bom-section">
<h4 className="bom-section-title">Flash</h4>
<div className="bom-row">
<label className="bom-label">Flash Mode</label>
<div className="bom-control">
<select
className="bom-select"
value={draft.flashMode}
onChange={(e) =>
update({ flashMode: e.target.value as ESP32BoardOptions['flashMode'] })
}
>
{FLASH_MODE_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
{nonDio && (
<span className="bom-badge bom-badge-error">
QEMU may fail to boot
</span>
)}
</div>
</div>
</section>
{showPsram && (
<section className="bom-section">
<h4 className="bom-section-title">PSRAM</h4>
<div className="bom-row">
<label className="bom-label">External PSRAM</label>
<div className="bom-control">
<select
className="bom-select"
value={draft.psram}
onChange={(e) =>
update({ psram: e.target.value as ESP32BoardOptions['psram'] })
}
>
<option value="disabled">Disabled</option>
<option value="enabled">Enabled</option>
{showOpi && <option value="opi">OPI PSRAM</option>}
</select>
</div>
</div>
</section>
)}
<section className="bom-section">
<h4 className="bom-section-title">Debug</h4>
<div className="bom-row">
<label className="bom-label">Core Debug Level</label>
<div className="bom-control">
<select
className="bom-select"
value={draft.coreDebugLevel}
onChange={(e) =>
update({
coreDebugLevel: e.target
.value as ESP32BoardOptions['coreDebugLevel'],
})
}
>
{DEBUG_LEVEL_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</div>
</div>
</section>
<section className="bom-section">
<h4 className="bom-section-title">Concurrency</h4>
<div className="bom-row">
<label className="bom-label">Events Run On</label>
<div className="bom-control">
<select
className="bom-select"
value={draft.eventsRunOnCore}
onChange={(e) =>
update({ eventsRunOnCore: Number(e.target.value) as ESP32CoreSelect })
}
>
{CORE_SELECT_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</div>
</div>
<div className="bom-row">
<label className="bom-label">Arduino Runs On</label>
<div className="bom-control">
<select
className="bom-select"
value={draft.arduinoRunsOnCore}
onChange={(e) =>
update({ arduinoRunsOnCore: Number(e.target.value) as ESP32CoreSelect })
}
>
{CORE_SELECT_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
{sameCore && (
<span className="bom-badge bom-badge-warn">
Both on same core
</span>
)}
</div>
</div>
</section>
<section className="bom-section">
<h4 className="bom-section-title">Tools</h4>
<div className="bom-row">
<label className="bom-label">Erase Flash on Upload</label>
<div className="bom-control">
<input
className="bom-check"
type="checkbox"
checked={draft.eraseFlashOnUpload}
onChange={(e) => update({ eraseFlashOnUpload: e.target.checked })}
/>
<span style={{ fontSize: 11, color: '#888' }}>
Zero NVS / SPIFFS before next run
</span>
</div>
</div>
</section>
</>
) : (
<SpiffsPanel
files={spiffsFiles}
fsCapacity={fsCapacity}
totalUploaded={totalUploaded}
overSchemeCap={overSchemeCap}
noFsButHasFiles={noFsButHasFiles}
fileInputRef={fileInputRef}
onAdd={() => fileInputRef.current?.click()}
onChange={handleFiles}
onDelete={handleDelete}
/>
)}
</div>
<div className="bom-footer">
<button className="bom-btn bom-btn-secondary" onClick={onClose}>
Cancel
</button>
<button className="bom-btn bom-btn-primary" onClick={handleApply}>
Apply
</button>
</div>
</div>
</div>
);
};
interface SpiffsPanelProps {
files: SpiffsFile[];
fsCapacity: number;
totalUploaded: number;
overSchemeCap: boolean;
noFsButHasFiles: boolean;
fileInputRef: React.RefObject<HTMLInputElement | null>;
onAdd: () => void;
onChange: (files: FileList | null) => void;
onDelete: (name: string) => void;
}
const SpiffsPanel = ({
files,
fsCapacity,
totalUploaded,
overSchemeCap,
noFsButHasFiles,
fileInputRef,
onAdd,
onChange,
onDelete,
}: SpiffsPanelProps) => {
const overSoft = totalUploaded > SOFT_TOTAL_CAP;
return (
<>
{noFsButHasFiles && (
<div className="bom-warning">
Selected partition scheme has no filesystem. Uploaded files will be
ignored at flash time. Switch to a scheme like
<code> default </code> or <code>min_spiffs</code> to keep them.
</div>
)}
{overSchemeCap && (
<div className="bom-warning">
Total upload size ({fmtBytes(totalUploaded)}) exceeds the partition's
SPIFFS capacity ({fmtBytes(fsCapacity)}). The image will not fit -
remove files or pick a larger scheme.
</div>
)}
{overSoft && !overSchemeCap && (
<div className="bom-warning">
Total upload size exceeds the recommended 4 MB cap. Save / load
operations may slow down.
</div>
)}
<div className="bom-spiffs-toolbar">
<button className="bom-spiffs-add" onClick={onAdd}>
Add file
</button>
<span className={`bom-spiffs-tally ${overSchemeCap ? 'over' : ''}`}>
{fmtBytes(totalUploaded)}{' '}
{fsCapacity > 0 ? `/ ${fmtBytes(fsCapacity)}` : '(no FS partition)'}
</span>
</div>
<input
ref={fileInputRef}
type="file"
multiple
style={{ display: 'none' }}
onChange={(e) => {
onChange(e.target.files);
if (e.target) e.target.value = '';
}}
/>
{files.length === 0 ? (
<div className="bom-spiffs-empty">
No files. Click "Add file" to upload assets that will be flashed into
the SPIFFS partition.
</div>
) : (
<table className="bom-spiffs-table">
<thead>
<tr>
<th>Name</th>
<th style={{ textAlign: 'right' }}>Size</th>
<th style={{ textAlign: 'right' }}>Actions</th>
</tr>
</thead>
<tbody>
{files.map((f) => (
<tr key={f.name}>
<td className="bom-spiffs-name">
/{f.name}
{f.size > MAX_FILE_BYTES && (
<span
className="bom-badge bom-badge-warn"
style={{ marginLeft: 8 }}
>
large
</span>
)}
</td>
<td className="bom-spiffs-size">{fmtBytes(f.size)}</td>
<td className="bom-spiffs-actions">
<button
className="bom-spiffs-delete"
onClick={() => onDelete(f.name)}
>
Delete
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</>
);
};

View File

@ -43,6 +43,8 @@ import { useIsCoarsePointer } from '../../utils/useTouchDevice';
import type { ComponentMetadata } from '../../types/component-metadata';
import type { BoardKind } from '../../types/board';
import { BOARD_KIND_LABELS } from '../../types/board';
import { isEsp32Family } from '../../types/boardOptions';
import { BoardOptionsModal } from './BoardOptionsModal';
import { useOscilloscopeStore } from '../../store/useOscilloscopeStore';
import {
trackSelectBoard,
@ -115,6 +117,7 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => {
initSimulator,
updateComponentState,
removeBoard,
updateBoard,
updateComponent,
serialMonitorOpen,
toggleSerialMonitor,
@ -222,6 +225,8 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => {
} | null>(null);
// Board removal confirmation dialog
const [boardToRemove, setBoardToRemove] = useState<string | null>(null);
// Board Options modal — id of the board whose options are being edited.
const [boardOptionsModalFor, setBoardOptionsModalFor] = useState<string | null>(null);
// Click vs drag detection
const [clickStartTime, setClickStartTime] = useState<number>(0);
@ -2767,6 +2772,7 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => {
w.start.componentId === boardContextMenu.boardId ||
w.end.componentId === boardContextMenu.boardId,
).length;
const supportsOptions = board ? isEsp32Family(board.boardKind) : false;
return (
<>
<div
@ -2787,7 +2793,7 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => {
borderRadius: 6,
padding: '4px 0',
zIndex: 9999,
minWidth: 180,
minWidth: 200,
boxShadow: '0 4px 16px rgba(0,0,0,0.4)',
fontSize: 13,
}}
@ -2803,6 +2809,56 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => {
>
{label}
</div>
<button
disabled={!supportsOptions}
title={supportsOptions ? undefined : 'ESP32 only'}
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
width: '100%',
padding: '7px 14px',
background: 'none',
border: 'none',
color: supportsOptions ? '#ddd' : '#555',
cursor: supportsOptions ? 'pointer' : 'not-allowed',
fontSize: 13,
textAlign: 'left',
}}
onMouseEnter={(e) => {
if (supportsOptions) e.currentTarget.style.background = '#2a2d2e';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'none';
}}
onClick={() => {
if (!supportsOptions) return;
setBoardOptionsModalFor(boardContextMenu.boardId);
setBoardContextMenu(null);
}}
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
Board Options...
</button>
<div
style={{
height: 1,
background: '#3c3c3c',
margin: '4px 0',
}}
/>
<button
style={{
display: 'flex',
@ -2853,6 +2909,25 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => {
);
})()}
{/* Board Options modal (ESP32 only) */}
{boardOptionsModalFor &&
(() => {
const b = boards.find((x) => x.id === boardOptionsModalFor);
if (!b) return null;
return (
<BoardOptionsModal
isOpen={true}
boardId={b.id}
boardKind={b.boardKind}
currentOptions={b.boardOptions}
spiffsFiles={b.spiffsFiles ?? []}
onClose={() => setBoardOptionsModalFor(null)}
onApply={(opts) => updateBoard(b.id, { boardOptions: opts })}
onSpiffsChange={(files) => updateBoard(b.id, { spiffsFiles: files })}
/>
);
})()}
{/* Board removal confirmation dialog */}
{boardToRemove &&
(() => {

View File

@ -0,0 +1,239 @@
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<ProjectMeta | null>(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);
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 (
<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 />;
};
// ── Helpers ─────────────────────────────────────────────────────────────────
interface RawProject {
board_type: string;
files: { name: string; content: string }[];
file_groups?: { groupId: string; files: { name: string; content: string }[] }[];
boards_json?: string;
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<BoardInstance> & { id: string; boardKind: string }) => ({
id: b.id,
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,
}));
}
} 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',
},
];
}
// File groups
const fileGroups: Record<string, { name: string; content: string }[]> = {};
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 = [];
}
return {
boards,
fileGroups,
components: components as never[],
wires: wires as never[],
activeBoardId: boards[0]?.id ?? null,
};
}

View File

@ -1157,6 +1157,19 @@ export const useSimulatorStore = create<SimulatorState>((set, get) => {
),
}));
}
if (b.boardOptions || b.spiffsFiles) {
set((s) => ({
boards: s.boards.map((bb) =>
bb.id === b.id
? {
...bb,
boardOptions: b.boardOptions,
spiffsFiles: b.spiffsFiles,
}
: bb,
),
}));
}
});
// Replace editor file groups atomically. Skip groups that already exist

View File

@ -74,6 +74,12 @@ export interface BoardInstance {
hasWifi?: boolean; // set by compiler — true when sketch uses WiFi
wifiStatus?: WifiStatus;
bleStatus?: BleStatus;
// ESP32-only — populated when the user opens Board Options... on the
// canvas context menu. Undefined for AVR / RP2040 / Pi3 and for
// pre-feature saved projects (compiler falls back to defaults).
// Types live in `./boardOptions` to avoid a circular import.
boardOptions?: import('./boardOptions').ESP32BoardOptions;
spiffsFiles?: import('./boardOptions').SpiffsFile[];
}
export const BOARD_KIND_LABELS: Record<BoardKind, string> = {

View File

@ -0,0 +1,187 @@
// Per-board ESP32 build options, mirroring the Arduino IDE Tools menu.
// Values get serialised inside the board entry of `boards_json` (no DB
// migration). The backend translates them into sdkconfig knobs and a
// generated partitions.csv at compile time.
//
// Non-ESP32 boards (AVR / RP2040 / Pi3) leave these undefined — the
// modal isn't exposed there.
import type { BoardKind } from './board';
export type ESP32PartitionScheme =
| 'default' // 1.2MB APP / 1.5MB SPIFFS (OTA)
| 'defaults_ffat' // 1.2MB APP / 1.5MB FATFS (OTA)
| 'min_spiffs' // 1.9MB APP / 190KB SPIFFS (OTA) — current Velxio default
| 'no_ota' // 2MB APP / 2MB SPIFFS
| 'no_fs' // 2MB APP x2, no filesystem
| 'huge_app' // 3MB APP / 1MB SPIFFS (no OTA)
| 'min_ffat' // 1.9MB APP / 190KB FATFS (OTA)
| 'large_spiffs' // 1.9MB APP / 1.4MB SPIFFS (no OTA)
| 'rainmaker'; // 1.9MB APP x2 with rainmaker fctry (OTA)
export type ESP32CpuFreq = 240 | 160 | 80 | 40 | 20 | 10;
export type ESP32FlashMode = 'qio' | 'dio' | 'qout' | 'dout';
export type ESP32FlashSize = '4MB' | '8MB' | '16MB';
export type ESP32FlashFreq = '80' | '40';
export type ESP32PsramMode = 'disabled' | 'enabled' | 'opi';
export type ESP32CoreDebugLevel = 'none' | 'error' | 'warn' | 'info' | 'debug' | 'verbose';
export type ESP32CoreSelect = 0 | 1;
export interface ESP32BoardOptions {
partitionScheme: ESP32PartitionScheme;
cpuFreqMHz: ESP32CpuFreq;
flashMode: ESP32FlashMode;
flashSize: ESP32FlashSize;
flashFreqMHz: ESP32FlashFreq;
psram: ESP32PsramMode;
coreDebugLevel: ESP32CoreDebugLevel;
eraseFlashOnUpload: boolean;
eventsRunOnCore: ESP32CoreSelect;
arduinoRunsOnCore: ESP32CoreSelect;
}
// One uploaded file destined for the SPIFFS partition. `contentB64` is a
// raw base64 of the file bytes (no data: prefix). Used for both UI display
// and the compile request body.
export interface SpiffsFile {
name: string;
contentB64: string;
size: number;
}
// Defaults match the historical `sdkconfig.defaults` baked into Velxio
// (DIO @ 40 MHz / 4 MB, min_spiffs, no PSRAM). Picking these as the
// fallback means projects saved before this feature compile bit-for-bit
// the same way after upgrade.
export const DEFAULT_ESP32_OPTIONS: ESP32BoardOptions = {
partitionScheme: 'min_spiffs',
cpuFreqMHz: 240,
flashMode: 'dio',
flashSize: '4MB',
flashFreqMHz: '40',
psram: 'disabled',
coreDebugLevel: 'none',
eraseFlashOnUpload: false,
eventsRunOnCore: 1,
arduinoRunsOnCore: 1,
};
const ESP32_S3_KINDS: ReadonlySet<BoardKind> = new Set([
'esp32-s3',
'xiao-esp32-s3',
'arduino-nano-esp32',
]);
const ESP32_C3_KINDS: ReadonlySet<BoardKind> = new Set([
'esp32-c3',
'xiao-esp32-c3',
'aitewinrobot-esp32c3-supermini',
]);
const ESP32_XTENSA_KINDS: ReadonlySet<BoardKind> = new Set([
'esp32',
'esp32-devkit-c-v4',
'esp32-cam',
'wemos-lolin32-lite',
]);
export function isEsp32Family(kind: BoardKind): boolean {
return (
ESP32_XTENSA_KINDS.has(kind) ||
ESP32_S3_KINDS.has(kind) ||
ESP32_C3_KINDS.has(kind)
);
}
// PSRAM availability by family:
// - Classic ESP32: optional, "enabled" only
// - ESP32-S3: optional, "enabled" or "opi" (octal)
// - ESP32-C3: no PSRAM support at all — section hidden in UI
export function boardSupportsPsram(kind: BoardKind): boolean {
return ESP32_XTENSA_KINDS.has(kind) || ESP32_S3_KINDS.has(kind);
}
export function boardSupportsOpiPsram(kind: BoardKind): boolean {
return ESP32_S3_KINDS.has(kind);
}
export function getDefaultOptionsForKind(kind: BoardKind): ESP32BoardOptions {
if (!isEsp32Family(kind)) return { ...DEFAULT_ESP32_OPTIONS };
const defaults = { ...DEFAULT_ESP32_OPTIONS };
// C3 has no PSRAM controller — clear the field so a stale 'enabled'
// value from an upgraded project doesn't reach the backend.
if (ESP32_C3_KINDS.has(kind)) defaults.psram = 'disabled';
return defaults;
}
// Human-readable label for a partition scheme. Used in the modal dropdown
// so users see "min_spiffs - 1.9MB APP / 190KB SPIFFS (OTA)" instead of
// the bare key.
export const PARTITION_SCHEME_LABELS: Record<ESP32PartitionScheme, string> = {
default: 'Default 4MB with spiffs (1.2MB APP / 1.5MB SPIFFS)',
defaults_ffat: 'Default 4MB with ffat (1.2MB APP / 1.5MB FATFS)',
min_spiffs: 'Minimal SPIFFS (1.9MB APP with OTA / 190KB SPIFFS)',
min_ffat: 'Minimal FATFS (1.9MB APP with OTA / 190KB FATFS)',
no_ota: 'No OTA (2MB APP / 2MB SPIFFS)',
no_fs: 'No FS 4MB (2MB APP x2)',
huge_app: 'Huge APP (3MB No OTA / 1MB SPIFFS)',
large_spiffs: 'Large SPIFFS (1.9MB APP / 1.4MB SPIFFS)',
rainmaker: 'RainMaker 4MB',
};
// SPIFFS (or FATFS) capacity in bytes for each scheme. Used in the upload
// panel to show "Total: X / Y KB" and to block oversize uploads.
// Returns 0 for schemes with no filesystem partition.
export const PARTITION_SCHEME_FS_SIZE: Record<ESP32PartitionScheme, number> = {
default: 0x16f000, // 1.5 MB
defaults_ffat: 0x16f000,
min_spiffs: 0x2f000, // 190 KB
min_ffat: 0x2f000,
no_ota: 0x200000, // 2 MB
no_fs: 0,
huge_app: 0x100000, // 1 MB
large_spiffs: 0x166000, // ~1.4 MB
rainmaker: 0x6000, // ~24 KB nvs/fctry only
};
// Display-only metadata for the CPU-frequency selector.
export const CPU_FREQ_OPTIONS: { value: ESP32CpuFreq; label: string }[] = [
{ value: 240, label: '240MHz (WiFi/BT)' },
{ value: 160, label: '160MHz' },
{ value: 80, label: '80MHz' },
{ value: 40, label: '40MHz' },
{ value: 20, label: '20MHz' },
{ value: 10, label: '10MHz' },
];
export const FLASH_MODE_OPTIONS: { value: ESP32FlashMode; label: string }[] = [
{ value: 'qio', label: 'QIO' },
{ value: 'dio', label: 'DIO' },
{ value: 'qout', label: 'QOUT' },
{ value: 'dout', label: 'DOUT' },
];
export const FLASH_SIZE_OPTIONS: { value: ESP32FlashSize; label: string }[] = [
{ value: '4MB', label: '4MB (32Mb)' },
{ value: '8MB', label: '8MB (64Mb) - experimental' },
{ value: '16MB', label: '16MB (128Mb) - experimental' },
];
export const FLASH_FREQ_OPTIONS: { value: ESP32FlashFreq; label: string }[] = [
{ value: '80', label: '80MHz' },
{ value: '40', label: '40MHz' },
];
export const DEBUG_LEVEL_OPTIONS: { value: ESP32CoreDebugLevel; label: string }[] = [
{ value: 'none', label: 'None' },
{ value: 'error', label: 'Error' },
{ value: 'warn', label: 'Warn' },
{ value: 'info', label: 'Info' },
{ value: 'debug', label: 'Debug' },
{ value: 'verbose', label: 'Verbose' },
];
export const CORE_SELECT_OPTIONS: { value: ESP32CoreSelect; label: string }[] = [
{ value: 0, label: 'Core 0' },
{ value: 1, label: 'Core 1' },
];

View File

@ -23,6 +23,11 @@ function serialisableBoard(b: BoardInstance) {
languageMode: b.languageMode,
serialBaudRate: b.serialBaudRate,
compiledProgram: b.compiledProgram,
// ESP32 board options + uploaded SPIFFS files. Optional, only present
// after the user has opened Board Options... at least once. Both ride
// inside boards_json so there's no DB migration.
boardOptions: b.boardOptions,
spiffsFiles: b.spiffsFiles,
};
}