feat(editor): starter-template picker on pristine /editor and New workspace
New NewProjectDialog: a bare /editor visit (and the New workspace button) offers a blank workspace or a ready-to-run Blink starter per board family instead of silently dropping the user into the hardcoded Uno + LED starter. Boards with a gallery Blink example load it fully wired; the rest get a fresh board with the on-board-LED sketch. Pro-gated boards carry the PRO pill and go through the boardGateDecision seam. Shows once per page load; en/es strings (the other locales fall back to English for now).
This commit is contained in:
parent
b1c9c41849
commit
3bcb8d518e
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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<string, string> = {
|
||||
'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<string, string> = {
|
||||
'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<ExampleProject | undefined> {
|
||||
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<void> {
|
||||
// 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 = () => (
|
||||
<span className="new-project-pro" title="Pro feature — paid plan or Velxio Desktop">
|
||||
PRO
|
||||
</span>
|
||||
);
|
||||
|
||||
export const NewProjectDialog: React.FC<NewProjectDialogProps> = ({ 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 <body>: escape the canvas subtree so no ancestor stacking
|
||||
// context can pin the dialog below floating panels (e.g. the AI chat).
|
||||
return createPortal(
|
||||
<div className="new-project-overlay" onClick={onClose}>
|
||||
<div
|
||||
className="new-project-dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="new-project-head">
|
||||
<h3 className="new-project-title">{t('editor.newProject.title')}</h3>
|
||||
<p className="new-project-sub">{t('editor.newProject.subtitle')}</p>
|
||||
</div>
|
||||
|
||||
<div className="new-project-body">
|
||||
<div className="new-project-grid">
|
||||
<button
|
||||
className="new-project-card new-project-card-blank"
|
||||
onClick={() => handleSelect('blank')}
|
||||
>
|
||||
<span className="new-project-card-name">
|
||||
{t('editor.newProject.blankTitle')}
|
||||
</span>
|
||||
<span className="new-project-card-desc">
|
||||
{t('editor.newProject.blankDesc')}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{sections.map((section) =>
|
||||
section.entries.length === 0 ? null : (
|
||||
<React.Fragment key={section.title}>
|
||||
<div className="new-project-section-title">{section.title}</div>
|
||||
<div className="new-project-grid">
|
||||
{section.entries.map(({ kind, blurb }) => (
|
||||
<button
|
||||
key={kind}
|
||||
className="new-project-card"
|
||||
onClick={() => handleSelect(kind)}
|
||||
>
|
||||
{isProBoardKind(kind) && <ProPill />}
|
||||
<span className="new-project-card-name">
|
||||
{BOARD_KIND_LABELS[kind as BoardKind] ?? kind}
|
||||
</span>
|
||||
<span className="new-project-card-desc">{blurb}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</React.Fragment>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="new-project-foot">
|
||||
<button className="new-project-cancel" onClick={onClose}>
|
||||
{t('editor.newProject.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
};
|
||||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 = () => {
|
|||
/>
|
||||
)}
|
||||
<NewsModal />
|
||||
<NewProjectDialog
|
||||
isOpen={showNewProjectDialog}
|
||||
onClose={() => setShowNewProjectDialog(false)}
|
||||
/>
|
||||
{/* Slot reserved for the private pro overlay (e.g. agent chat panel).
|
||||
Self-hosted builds without an overlay see nothing here. */}
|
||||
<div data-velxio-slot="agent-chat" />
|
||||
|
|
|
|||
Loading…
Reference in New Issue