/** * NewProjectDialog — starter-template picker. * * Shown (a) on a pristine `/editor` visit (over an emptied canvas), and * (b) from the "New workspace" button / File menu entry. Offers a blank * workspace plus a ready-to-run Blink starter per board family — Arduino, * ESP32 (one card per chip generation, XIAO variant preferred), M5Stack * (overlay-registered all-in-ones, listed ahead of STM32), STM32, Raspberry * Pi — each card carrying the same circuit thumbnail the examples gallery * uses (/examples-thumbs/.webp, CircuitPreview fallback). * * Selecting a board loads its gallery Blink example when one exists (full * wiring: 220Ω resistor + LED); boards without one get a fresh board whose * default sketch blinks the on-board LED. Pro-gated boards (STM32 + QEMU * Raspberry Pi) carry the same PRO pill as the component picker and go * through the same `boardGateDecision` seam before anything is created. */ import React, { useEffect, useMemo, useSyncExternalStore } from 'react'; import { createPortal } from 'react-dom'; import { useTranslation } from 'react-i18next'; import type { BoardKind } from '../../types/board'; import { BOARD_KIND_LABELS } from '../../types/board'; import { boardGateDecision, isProBoardKind, proBoardFeatureName, triggerProUpgradePrompt, } from '../../lib/proBoardGate'; import { listProBoards, subscribeProBoards, getProBoardsVersion, type ProBoardDef, } from '../../lib/proBoardRegistry'; import { useSimulatorStore, DEFAULT_BOARD_POSITION } from '../../store/useSimulatorStore'; import { useProjectStore } from '../../store/useProjectStore'; import { useEditorStore } from '../../store/useEditorStore'; import { getLocaleFromPath, localizedPath } from '../../i18n/path'; import { loadExample } from '../../utils/loadExample'; import type { ExampleProject } from '../../data/examples'; import { ExampleThumbnail } from '../examples/ExampleThumbnail'; import { trackSelectBoard } from '../../utils/analytics'; import './NewProjectDialog.css'; interface NewProjectDialogProps { isOpen: boolean; onClose: () => void; } /** Card blurbs (same voice as the component picker's board descriptions). */ const BOARD_BLURBS: Record = { 'arduino-uno': '8-bit AVR, 32KB flash, 14 digital I/O', 'arduino-mega': '8-bit AVR, 256KB flash, 54 digital I/O', 'arduino-nano': '8-bit AVR, 32KB flash, breadboard-size Uno', 'arduino-nano-esp32': 'ESP32-S3 in the Nano footprint, WiFi+BT (QEMU)', esp32: 'Xtensa LX6 dual-core, WiFi+BT, 38 GPIO (QEMU)', 'esp32-cam': 'ESP32 + OV2640 camera, streams to LCD (QEMU)', 'xiao-esp32-s3': 'Seeed XIAO tiny form, 8MB flash+PSRAM (QEMU)', 'xiao-esp32-c3': 'Seeed XIAO ESP32-C3 mini board (QEMU)', 'stm32-bluepill': 'STM32F103C8 Cortex-M3, 64KB flash, 37 GPIO (QEMU)', 'stm32-blackpill': 'STM32F411CE Cortex-M4, 512KB flash, 50 GPIO (QEMU)', 'stm32-bluepill-f103cb': 'STM32F103CB Cortex-M3, 128KB flash, 37 GPIO (QEMU)', 'stm32-blackpill-f401': 'STM32F401CE Cortex-M4, 512KB flash, 50 GPIO (QEMU)', 'stm32-f4-discovery': 'STM32F407VG Cortex-M4, 1MB flash, 4 onboard LEDs (QEMU)', 'stm32-olimex-h405': 'Olimex STM32-H405, F405RG Cortex-M4, 1MB flash (QEMU)', 'stm32-netduino-plus2': 'Netduino Plus 2, STM32F405 Cortex-M4 (QEMU)', 'stm32-netduino2': 'Netduino 2, STM32F205 Cortex-M3 (QEMU, serial)', 'raspberry-pi-pico': 'RP2040 dual-core Cortex-M0+', 'raspberry-pi-3': 'ARM64 Cortex-A53 quad-core, Linux/Python (QEMU)', 'raspberry-pi-4': 'ARM64 Cortex-A72 quad-core, Linux/Python (QEMU)', 'raspberry-pi-5': 'ARM64 Cortex-A76 quad-core + RP1 I/O, Linux/Python (QEMU)', }; const STM32_BOARDS: BoardKind[] = [ 'stm32-bluepill', 'stm32-blackpill', 'stm32-bluepill-f103cb', 'stm32-blackpill-f401', 'stm32-f4-discovery', 'stm32-olimex-h405', 'stm32-netduino-plus2', 'stm32-netduino2', ]; /** * Starter-card thumbnail: reuse the gallery's convention-based thumbs * (/examples-thumbs/.webp, CircuitPreview SVG fallback). Kinds with a * Blink example use that example's thumb; the rest use a captured * `starter-` shot living in the same folder. */ function thumbStubFor(kind: string, label: string): ExampleProject { return { id: PREFERRED_BLINK_EXAMPLE[kind] ?? `starter-${kind}`, title: label, description: '', category: 'basics', difficulty: 'beginner', boardType: kind, code: '', components: [], wires: [], }; } /** * Empty the whole workspace: boards, components, wires, current project and * every editor file group. Removing the LAST board leaves the editor's * files/activeFileId mirror pointing at the deleted group (removeBoard only * re-points when another board remains), so Monaco kept showing the dead * sketch — pointing the editor at a nonexistent group blanks it. */ export function clearWorkspaceForStarter(): void { // currentProject FIRST — same auto-save hazard loadExample documents: // mutating the stores while a saved project is still "current" lets the // debounced PUT overwrite that project with the emptied content. useProjectStore.getState().clearCurrentProject(); const sim = useSimulatorStore.getState(); sim.boards.forEach((b) => sim.stopBoard(b.id)); sim.boards.map((b) => b.id).forEach((id) => sim.removeBoard(id)); sim.setComponents([]); sim.setWires([]); const editor = useEditorStore.getState(); Object.keys(editor.fileGroups).forEach((g) => editor.deleteFileGroup(g)); editor.setActiveGroup(''); } /** * Gallery Blink example per board kind, when one exists. Preferred ids first * (some kinds have several blink-ish examples — e.g. esp32 also has the * ESP-IDF variant); a generic single-board "blink" search covers overlay * examples registered at runtime. */ const PREFERRED_BLINK_EXAMPLE: Record = { 'arduino-uno': 'blink-led', 'arduino-mega': 'mega-blink', 'arduino-nano': 'nano-blink', // Camera board: the webcam demo IS its "blink" — the board exists to // show the sensor, a bare LED sketch would be a misleading first run. 'esp32-cam': 'esp32cam-webcam-demo', esp32: 'esp32-blink-led', 'esp32-s3': 'esp32s3-blink-led', 'esp32-c3': 'c3-blink', 'esp32-c6': 'c6-blink', 'raspberry-pi-pico': 'pico-blink', 'raspberry-pi-3': 'pi3-blink-led', 'xiao-rp2040': 'xiao-rp2040-blink', 'xiao-esp32c6': 'xiao-esp32c6-blink', 'stm32-bluepill': 'stm32-bluepill-blink', 'stm32-blackpill': 'stm32-blackpill-blink', 'stm32-bluepill-f103cb': 'stm32-bluepill-f103cb-blink', 'stm32-blackpill-f401': 'stm32-blackpill-f401-blink', 'stm32-f4-discovery': 'stm32-f4-discovery-blink', 'stm32-olimex-h405': 'stm32-olimex-h405-blink', 'stm32-netduino-plus2': 'stm32-netduino-plus2-blink', // M5Stack all-in-ones (overlay kinds): no LED to blink — the on-LCD hello // is the first run. Both ids carry a captured gallery thumb. 'cardputer-adv': 'cardputer-adv-hello', 'm5stack-core': 'm5stack-core-m5-helloworld', }; /** Dynamic import keeps the (large) gallery data out of the editor bundle * until a starter is actually picked. */ async function findBlinkExample(kind: string): Promise { const { exampleProjects } = await import('../../data/examples'); const preferredId = PREFERRED_BLINK_EXAMPLE[kind]; if (preferredId) { const preferred = exampleProjects.find((e) => e.id === preferredId); if (preferred) return preferred; } return exampleProjects.find( (e) => !e.boards && e.boardType === kind && /blink/i.test(`${e.id} ${e.title}`), ); } /** * Replace the workspace with the chosen starter. 'blank' leaves an empty * canvas (boards are optional — the board-less analog examples rely on the * same state). Board kinds load their gallery Blink example when one exists; * otherwise a fresh board whose default sketch already blinks the on-board * LED (createFileGroup picks the per-board/language blink content). */ async function applyStarter(kind: string | 'blank'): Promise { clearWorkspaceForStarter(); if (kind !== 'blank') { let example: ExampleProject | undefined; try { example = await findBlinkExample(kind); } catch { example = undefined; } if (example) { await loadExample(example); } else { const fresh = useSimulatorStore.getState(); const newId = fresh.addBoard( kind as BoardKind, DEFAULT_BOARD_POSITION.x, DEFAULT_BOARD_POSITION.y, ); fresh.setActiveBoardId(newId); } } // The workspace no longer belongs to whatever project URL we were on — // leaving it would silently reload the OLD project over this fresh // workspace on refresh. replaceState, not pushState (a back-entry at the // stale project URL would remount the project route and reload it). const locale = getLocaleFromPath(window.location.pathname); const editorPath = localizedPath('/editor', locale); if (window.location.pathname !== editorPath) { window.history.replaceState(null, '', editorPath); } } export interface StarterSection { title: string; entries: Array<{ kind: string; blurb: string }>; } /** * The card sections, in display order. Overlay-registered kinds only exist * when the private overlay mounted — an OSS build simply doesn't show those * cards, and a section left with no entries is not rendered at all. */ export function buildStarterSections(defs: ProBoardDef[]): StarterSection[] { const oss = (k: BoardKind) => ({ kind: k as string, blurb: BOARD_BLURBS[k] ?? '' }); const pro = (k: string) => { const d = defs.find((x) => x.kind === k); return d ? [{ kind: d.kind, blurb: d.description }] : []; }; return [ { title: 'Arduino', entries: [ oss('arduino-uno'), oss('arduino-mega'), oss('arduino-nano'), oss('arduino-nano-esp32'), ], }, // One card per ESP32 chip generation, XIAO variant preferred where // Seeed makes one: classic → DevKit V1, S3/C3 → XIAO, C6 → XIAO (overlay). { title: 'ESP32', entries: [ oss('esp32'), oss('esp32-cam'), oss('xiao-esp32-s3'), oss('xiao-esp32-c3'), ...pro('xiao-esp32c6'), ], }, // M5Stack all-in-ones (overlay): ahead of STM32 by operator request — // they run on Free and are a friendlier first pick than the Pro-gated // STM32 family. Cardputer first, then the Core. { title: 'M5Stack', entries: [...pro('cardputer-adv'), ...pro('m5stack-core')] }, { title: 'STM32', entries: STM32_BOARDS.map(oss) }, { title: 'Raspberry Pi', entries: [ oss('raspberry-pi-pico'), ...pro('xiao-rp2040'), oss('raspberry-pi-3'), oss('raspberry-pi-4'), oss('raspberry-pi-5'), ], }, ]; } const ProPill: React.FC = () => ( PRO ); export const NewProjectDialog: React.FC = ({ isOpen, onClose }) => { const { t } = useTranslation(); // Late-overlay registrations (the @pro import is dynamic) must re-render an // already-mounted dialog — same contract as the component picker. const proBoardsVersion = useSyncExternalStore( subscribeProBoards, getProBoardsVersion, getProBoardsVersion, ); // eslint-disable-next-line react-hooks/exhaustive-deps const sections = useMemo(() => buildStarterSections(listProBoards()), [proBoardsVersion]); useEffect(() => { if (!isOpen) return; const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [isOpen, onClose]); if (!isOpen) return null; const handleSelect = (kind: string | 'blank') => { if (kind !== 'blank' && boardGateDecision(kind as BoardKind) === 'block') { // Same gate as adding the board from the picker: close, prompt, create // nothing. The overlay's upgrade modal takes over from here. onClose(); triggerProUpgradePrompt(proBoardFeatureName(kind)); return; } if (kind !== 'blank') trackSelectBoard(kind); onClose(); applyStarter(kind).catch((err) => { // eslint-disable-next-line no-console console.warn('[editor] starter template failed to load:', err); }); }; // Portal to : escape the canvas subtree so no ancestor stacking // context can pin the dialog below floating panels (e.g. the AI chat). return createPortal(
e.stopPropagation()} >

{t('editor.newProject.title')}

{t('editor.newProject.subtitle')}

{sections.map((section) => section.entries.length === 0 ? null : (
{section.title}
{section.entries.map(({ kind, blurb }) => { const label = BOARD_KIND_LABELS[kind as BoardKind] ?? kind; return ( ); })}
), )}
, document.body, ); };