diff --git a/frontend/src/components/editor/NewProjectDialog.css b/frontend/src/components/editor/NewProjectDialog.css new file mode 100644 index 00000000..10db7a41 --- /dev/null +++ b/frontend/src/components/editor/NewProjectDialog.css @@ -0,0 +1,152 @@ +/* NewProjectDialog — starter-template picker (dark, matches the picker/modals) */ + +.new-project-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.6); + display: flex; + align-items: center; + justify-content: center; + /* Above every floating panel, including the pro AI chat (8000/8001). */ + z-index: 9000; + animation: newProjectFadeIn 0.15s ease; +} + +@keyframes newProjectFadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +.new-project-dialog { + background: #1e1e1e; + border: 1px solid #444; + border-radius: 10px; + width: min(780px, 94vw); + max-height: 86vh; + display: flex; + flex-direction: column; + overflow: hidden; + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5); +} + +.new-project-head { + padding: 20px 24px 10px; + flex-shrink: 0; +} + +.new-project-title { + margin: 0; + color: #e8e8e8; + font-size: 17px; + font-weight: 600; +} + +.new-project-sub { + margin: 6px 0 0; + color: #8f949c; + font-size: 12.5px; + line-height: 1.45; +} + +.new-project-body { + padding: 4px 24px 16px; + overflow-y: auto; +} + +.new-project-section-title { + margin: 16px 0 8px; + color: #8a8f98; + font-size: 11px; + font-weight: 700; + letter-spacing: 1px; + text-transform: uppercase; +} + +.new-project-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 8px; +} + +.new-project-card { + position: relative; + display: flex; + flex-direction: column; + gap: 4px; + text-align: left; + background: #2b2b2b; + border: 1px solid #3d3d3d; + border-radius: 8px; + padding: 10px 12px; + color: #ddd; + cursor: pointer; + font: inherit; + transition: background 0.1s ease, border-color 0.1s ease; +} + +.new-project-card:hover { + background: #343434; + border-color: #5a5a5a; +} + +.new-project-card-blank { + border-style: dashed; + border-color: #4a4f58; + margin-top: 12px; +} + +.new-project-card-name { + font-weight: 600; + font-size: 13px; + /* Keep clear of the PRO pill in the corner. */ + padding-right: 44px; +} + +.new-project-card-desc { + font-size: 11px; + color: #8f949c; + line-height: 1.35; +} + +/* Gold PRO pill — same look as the component picker's badge. */ +.new-project-pro { + position: absolute; + top: 8px; + right: 8px; + z-index: 1; + padding: 3px 10px; + border-radius: 999px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.6px; + color: #1a1205; + background: linear-gradient(180deg, #ffd566, #f5a623); + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.35); +} + +.new-project-foot { + padding: 12px 24px 16px; + border-top: 1px solid #333; + display: flex; + justify-content: flex-end; + flex-shrink: 0; +} + +.new-project-cancel { + padding: 6px 16px; + background: transparent; + border: 1px solid #555; + border-radius: 6px; + color: #999; + cursor: pointer; + font-size: 12.5px; +} + +.new-project-cancel:hover { + color: #ccc; + border-color: #777; +} diff --git a/frontend/src/components/editor/NewProjectDialog.tsx b/frontend/src/components/editor/NewProjectDialog.tsx new file mode 100644 index 00000000..173d202c --- /dev/null +++ b/frontend/src/components/editor/NewProjectDialog.tsx @@ -0,0 +1,317 @@ +/** + * NewProjectDialog — starter-template picker. + * + * Shown (a) on a pristine `/editor` visit, instead of silently dropping the + * user into the hardcoded Arduino Uno + LED starter, 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, Seeed XIAO, + * STM32, Raspberry Pi). + * + * 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, +} from '../../lib/proBoardRegistry'; +import { useSimulatorStore, DEFAULT_BOARD_POSITION } from '../../store/useSimulatorStore'; +import { useProjectStore } from '../../store/useProjectStore'; +import { getLocaleFromPath, localizedPath } from '../../i18n/path'; +import { loadExample } from '../../utils/loadExample'; +import type { ExampleProject } from '../../data/examples'; +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', + esp32: 'Xtensa LX6 dual-core, WiFi+BT, 38 GPIO (QEMU)', + 'esp32-devkit-c-v4': 'ESP32 DevKit C V4, official Espressif (QEMU)', + 'esp32-cam': 'ESP32 + 2MP camera, microSD (QEMU)', + 'wemos-lolin32-lite': 'Compact ESP32, LiPo battery support (QEMU)', + 'esp32-s3': 'Xtensa LX7 dual-core, WiFi+BT, AI accel (QEMU)', + 'arduino-nano-esp32': 'Nano form-factor, ESP32-S3, RGB LED (QEMU)', + 'esp32-c3': 'RISC-V single-core, WiFi+BLE, 22 GPIO (QEMU)', + 'aitewinrobot-esp32c3-supermini': 'ESP32-C3 SuperMini (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 ESP32_BOARDS: BoardKind[] = [ + 'esp32', + 'esp32-devkit-c-v4', + 'esp32-cam', + 'wemos-lolin32-lite', + 'esp32-s3', + 'arduino-nano-esp32', + 'esp32-c3', + 'aitewinrobot-esp32c3-supermini', +]; + +const XIAO_BOARDS: BoardKind[] = ['xiao-esp32-s3', 'xiao-esp32-c3']; + +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', +]; + +const PI_BOARDS: BoardKind[] = [ + 'raspberry-pi-pico', + 'raspberry-pi-3', + 'raspberry-pi-4', + 'raspberry-pi-5', +]; + +/** + * 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', + 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', +}; + +/** 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 { + // Clear 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 starter's 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([]); + + 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); + } +} + +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, + ); + + const sections = useMemo(() => { + const defs = listProBoards(); + const overlay = (prefix: string) => + defs + .filter((d) => d.kind.startsWith(prefix)) + .map((d) => ({ kind: d.kind, blurb: d.description })); + const oss = (kinds: BoardKind[]) => + kinds.map((k) => ({ kind: k as string, blurb: BOARD_BLURBS[k] ?? '' })); + return [ + { title: 'Arduino', entries: oss(['arduino-uno']) }, + { title: 'ESP32', entries: [...oss(ESP32_BOARDS), ...overlay('esp32')] }, + { title: 'Seeed Studio XIAO', entries: [...oss(XIAO_BOARDS), ...overlay('xiao')] }, + { title: 'STM32', entries: oss(STM32_BOARDS) }, + { title: 'Raspberry Pi', entries: oss(PI_BOARDS) }, + ]; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [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 }) => ( + + ))} +
+
+ ), + )} +
+ +
+ +
+
+
, + document.body, + ); +}; diff --git a/frontend/src/i18n/locales/en/common2.json b/frontend/src/i18n/locales/en/common2.json index fab60a19..d192404f 100644 --- a/frontend/src/i18n/locales/en/common2.json +++ b/frontend/src/i18n/locales/en/common2.json @@ -45,7 +45,7 @@ }, "fileExplorer": { "workspace": "WORKSPACE", - "newWorkspace": "New workspace (clears boards, components, wires and files)", + "newWorkspace": "New workspace (choose a starter template)", "confirmNew": { "title": "Start a new workspace?", "message": "This clears every board, component, wire and file. This cannot be undone.", @@ -285,6 +285,13 @@ "close": "Close", "empty": "No compilation output yet. Click Compile to start." }, + "newProject": { + "title": "Start a new project", + "subtitle": "Pick a blank workspace or a board with a ready-to-run Blink example. This replaces everything in the current workspace.", + "blankTitle": "Blank project", + "blankDesc": "Empty workspace — add your own boards and parts", + "cancel": "Cancel" + }, "boardPicker": { "title": "Add Board" }, diff --git a/frontend/src/i18n/locales/es/common2.json b/frontend/src/i18n/locales/es/common2.json index 5acb66f2..559ad389 100644 --- a/frontend/src/i18n/locales/es/common2.json +++ b/frontend/src/i18n/locales/es/common2.json @@ -45,7 +45,7 @@ }, "fileExplorer": { "workspace": "ESPACIO DE TRABAJO", - "newWorkspace": "Nuevo espacio de trabajo (borra placas, componentes, cables y archivos)", + "newWorkspace": "Nuevo espacio de trabajo (elige una plantilla inicial)", "confirmNew": { "title": "¿Iniciar un nuevo espacio de trabajo?", "message": "Esto borra todas las placas, componentes, cables y archivos. No se puede deshacer.", @@ -285,6 +285,13 @@ "close": "Cerrar", "empty": "Aún no hay salida de compilación. Haz clic en Compilar para empezar." }, + "newProject": { + "title": "Comienza un proyecto nuevo", + "subtitle": "Elige un espacio en blanco o una placa con un ejemplo Blink listo para ejecutar. Esto reemplaza todo el contenido del espacio de trabajo actual.", + "blankTitle": "Proyecto en blanco", + "blankDesc": "Espacio vacío — añade tus propias placas y componentes", + "cancel": "Cancelar" + }, "boardPicker": { "title": "Añadir placa" }, diff --git a/frontend/src/pages/EditorPage.tsx b/frontend/src/pages/EditorPage.tsx index 5c0e03fe..cf7dc15e 100644 --- a/frontend/src/pages/EditorPage.tsx +++ b/frontend/src/pages/EditorPage.tsx @@ -21,12 +21,12 @@ import { AppHeader } from '../components/layout/AppHeader'; import { triggerSaveAction } from '../lib/proSaveAction'; import { GitHubStarBanner } from '../components/layout/GitHubStarBanner'; import { NewsModal } from '../components/layout/NewsModal'; -import { useSimulatorStore, DEFAULT_BOARD_POSITION } from '../store/useSimulatorStore'; +import { useSimulatorStore } from '../store/useSimulatorStore'; import { useEditorStore } from '../store/useEditorStore'; import { useCompileLogsStore } from '../store/useCompileLogsStore'; import { useOscilloscopeStore } from '../store/useOscilloscopeStore'; import { useProjectStore } from '../store/useProjectStore'; -import { showConfirmDialog } from '../store/useMessageDialogStore'; +import { NewProjectDialog } from '../components/editor/NewProjectDialog'; import { useAutoSaveProject } from '../hooks/useAutoSaveProject'; import { registerEditorCommand } from '../lib/editorCommands'; import { EditorMenuBar } from '../components/editor/EditorMenuBar'; @@ -43,6 +43,11 @@ const EXPLORER_MIN = 110; const EXPLORER_MAX = 500; const EXPLORER_DEFAULT = 165; +// Once per full page load: the pristine-visit starter dialog must not pop +// again when the user closes it and later navigates away from and back to +// /editor within the same SPA session. +let starterDialogShownThisLoad = false; + const resizeHandleStyle: React.CSSProperties = { height: 5, flexShrink: 0, @@ -101,6 +106,33 @@ export const EditorPage: React.FC = () => { restoreStashedWorkspace(); }, []); + const [showNewProjectDialog, setShowNewProjectDialog] = useState(false); + + // Pristine-visit starter dialog: a bare /editor landing still holds the + // hardcoded Uno + LED starter (see useSimulatorStore INITIAL_BOARD / + // builtin components). Offer the template picker instead of silently + // dropping the user into the Arduino blink. Guarded so it never fires over + // a loaded project/example URL, a restored login draft (both leave the + // stores non-pristine), or twice per page load. Declared AFTER the + // restoreStashedWorkspace effect — mount order is what makes the + // pristine check see the restored draft. + useEffect(() => { + if (starterDialogShownThisLoad) return; + const locale = getLocaleFromPath(window.location.pathname); + if (window.location.pathname !== localizedPath('/editor', locale)) return; + if (window.location.search) return; + if (useProjectStore.getState().currentProject) return; + const sim = useSimulatorStore.getState(); + const pristine = + sim.boards.length === 1 && + sim.boards[0].id === 'arduino-uno' && + sim.components.length === 2 && + sim.components.every((c) => c.id === 'led_builtin' || c.id === 'r_builtin'); + if (!pristine) return; + starterDialogShownThisLoad = true; + setShowNewProjectDialog(true); + }, []); + // ── GitHub star prompt (show twice at most: 2nd visit OR after 3 min) ────── // Three localStorage flags drive this: // velxio_star_prompted → dismissed the first ask @@ -188,40 +220,12 @@ export const EditorPage: React.FC = () => { triggerSaveAction(); }, []); - const handleNewClick = useCallback(async () => { - const confirmed = await showConfirmDialog( - t('editor.fileExplorer.confirmNew.message'), - { - kind: 'error', - title: t('editor.fileExplorer.confirmNew.title'), - confirmLabel: t('editor.fileExplorer.confirmNew.confirm'), - cancelLabel: t('editor.fileExplorer.confirmNew.cancel'), - danger: true, - }, - ); - if (!confirmed) return; - const sim = useSimulatorStore.getState(); - sim.boards.forEach((b) => sim.stopBoard(b.id)); - const ids = sim.boards.map((b) => b.id); - ids.forEach((id) => sim.removeBoard(id)); - sim.setComponents([]); - sim.setWires([]); - useProjectStore.getState().clearCurrentProject(); - const newId = useSimulatorStore - .getState() - .addBoard('arduino-uno', DEFAULT_BOARD_POSITION.x, DEFAULT_BOARD_POSITION.y); - useSimulatorStore.getState().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 (and via the back button). replaceState, not - // pushState: a back-entry pointing at the stale project URL would - // remount the project route and cause exactly that reload. - const locale = getLocaleFromPath(window.location.pathname); - const editorPath = localizedPath('/editor', locale); - if (window.location.pathname !== editorPath) { - window.history.replaceState(null, '', editorPath); - } - }, [t]); + // "New workspace" opens the starter-template dialog. Destruction moved + // from here into the dialog's selection handler — Cancel/ESC now leaves + // the current workspace untouched, so the old confirm dialog is gone. + const handleNewClick = useCallback(() => { + setShowNewProjectDialog(true); + }, []); // Track mobile breakpoint useEffect(() => { @@ -750,6 +754,10 @@ export const EditorPage: React.FC = () => { /> )} + setShowNewProjectDialog(false)} + /> {/* Slot reserved for the private pro overlay (e.g. agent chat panel). Self-hosted builds without an overlay see nothing here. */}