From 65cbc403d22e81b69396d6fd61dd8c64c39c3bf7 Mon Sep 17 00:00:00 2001 From: David Montero Crespo Date: Fri, 15 May 2026 00:14:36 -0300 Subject: [PATCH] feat(examples): /example/ route with pinned URL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror of the /project/ pattern but for built-in examples. Loading an example used to navigate to a generic /editor and lose all trace of which example was loaded — same URL whether you clicked Blink or Doom, nothing shareable, no back-button history. New page: pages/ExampleEditorPage.tsx - Route: /example/:exampleId (singular, distinct from the plural /examples/ landing). - useEffect calls loadExample(...) once when exampleId changes, guarded by a ref so React strict-mode's double-effect doesn't re-load (which would clobber any edits the user made). - Renders after the load completes — same as how ProjectByIdPage stays mounted at /project/ after load. - SEO: title + description per example, canonical URL points at /example/. - 404 state for unknown ids (typo'd link, deleted example). - Inline install progress while libraries fetch — the overlay UI moved here from ExamplesPage/ExampleDetailPage so progress is visible right at the URL you'll bookmark. App.tsx — registered the new route alongside the existing landing. Both coexist on purpose: /examples/ = SEO landing page (preview, badges, "Open in Simulator" CTA). Indexed by Google (130 URLs already in sitemap.xml). /example/ = live editor with the example pre-loaded; URL stays pinned so the link is shareable + bookmarkable like a saved project URL. ExamplesPage — gallery now navigates to /example/ instead of calling loadExample directly. Also drops the install-overlay block (progress UI is on ExampleEditorPage now). ExampleDetailPage — "Open in Simulator" navigates to /example/ instead of loading directly. Drops its own install overlay too. Side effect: this also kills the data-loss bug from 95f2aa9 in a second way. Even if a future change forgets to call clearCurrentProject() somewhere, navigating into ExampleEditorPage forces a fresh page transition — the previous project's state + the auto-save subscription don't survive into the example session. Build verified (vite OSS+pro, 285 SEO pages prerendered). Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/src/App.tsx | 6 + frontend/src/pages/ExampleDetailPage.tsx | 58 ++------ frontend/src/pages/ExampleEditorPage.tsx | 160 +++++++++++++++++++++++ frontend/src/pages/ExamplesPage.tsx | 71 +--------- 4 files changed, 181 insertions(+), 114 deletions(-) create mode 100644 frontend/src/pages/ExampleEditorPage.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e1d1512a..16f64e4a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -10,6 +10,7 @@ import { DocsPage } from './pages/DocsPage'; // mountPro() and appear under /login, /admin, /:username etc. only when // the overlay is loaded. import { ExampleDetailPage } from './pages/ExampleDetailPage'; +import { ExampleEditorPage } from './pages/ExampleEditorPage'; import { ArduinoSimulatorPage } from './pages/ArduinoSimulatorPage'; import { ArduinoEmulatorPage } from './pages/ArduinoEmulatorPage'; import { AtmegaSimulatorPage } from './pages/AtmegaSimulatorPage'; @@ -46,7 +47,12 @@ const ROUTES: { path: string; element: ReactElement; index?: boolean }[] = [ { path: '/', element: , index: true }, { path: 'editor', element: }, { path: 'examples', element: }, + // /examples/ = SEO landing (preview, badges, "Open in Simulator" CTA). + // /example/ = live editor with the example pre-loaded; the URL + // stays pinned so links are shareable + bookmarkable. + // Singular vs plural is intentional — Google indexes the plural landings. { path: 'examples/:exampleId', element: }, + { path: 'example/:exampleId', element: }, { path: 'docs', element: }, { path: 'docs/:section', element: }, // SEO landing pages — keyword-targeted diff --git a/frontend/src/pages/ExampleDetailPage.tsx b/frontend/src/pages/ExampleDetailPage.tsx index ddddeafa..494a7b5b 100644 --- a/frontend/src/pages/ExampleDetailPage.tsx +++ b/frontend/src/pages/ExampleDetailPage.tsx @@ -9,10 +9,9 @@ * statically-served HTML for search engines. */ -import React, { useState } from 'react'; +import React from 'react'; import { useParams, useNavigate, Link } from 'react-router-dom'; import { exampleProjects } from '../data/examples'; -import { loadExample, type LibraryInstallProgress } from '../utils/loadExample'; import { AppHeader } from '../components/layout/AppHeader'; import { ExampleThumbnail } from '../components/examples/ExampleThumbnail'; import { useSEO } from '../utils/useSEO'; @@ -46,7 +45,6 @@ const DIFFICULTY_COLOR: Record = { export const ExampleDetailPage: React.FC = () => { const { exampleId } = useParams<{ exampleId: string }>(); const navigate = useNavigate(); - const [installing, setInstalling] = useState(null); const example = exampleId ? exampleProjects.find((e) => e.id === exampleId) : null; @@ -69,10 +67,12 @@ export const ExampleDetailPage: React.FC = () => { url: `${DOMAIN}/examples/${exampleId ?? ''}`, }); - const handleOpen = async () => { + const handleOpen = () => { if (!example) return; - await loadExample(example, setInstalling); - navigate('/editor'); + // Navigate to the live editor URL — ExampleEditorPage owns the load. + // Pinning the URL means the user can refresh / share the link and + // keep the example loaded. + navigate(`/example/${example.id}`); }; // ── 404 state ─────────────────────────────────────────────────────────────── @@ -371,50 +371,8 @@ export const ExampleDetailPage: React.FC = () => { /> - {/* Library install overlay */} - {installing && ( -
-
-
- Installing libraries ({installing.done + 1}/{installing.total}) -
-
- {installing.current} -
-
-
-
-
-
- )} + {/* Library install overlay used to live here — moved to + ExampleEditorPage now that loading runs at /example/. */}
); }; diff --git a/frontend/src/pages/ExampleEditorPage.tsx b/frontend/src/pages/ExampleEditorPage.tsx new file mode 100644 index 00000000..cfc58a08 --- /dev/null +++ b/frontend/src/pages/ExampleEditorPage.tsx @@ -0,0 +1,160 @@ +/** + * ExampleEditorPage — route `/example/:exampleId`. + * + * Paralelo a ProjectByIdPage (`/project/`) but for the built-in + * example projects. Loads the example into the editor + simulator + * stores AND keeps the URL pinned to `/example/` while the user + * runs / edits. That makes example links: + * + * - Shareable: copy the URL, send it, recipient lands on the same + * example pre-loaded. + * - Bookmarkable: a tab title and back-button history that point + * at the example, not at a generic `/editor`. + * - SEO-friendly: each example gets its own URL the same way + * /examples/ already gave it a landing page. The two co- + * exist on purpose — `/examples/` (plural) is the marketing + * landing with preview + description, `/example/` (singular) + * is the live editor with the example pre-loaded. + * + * If the user starts editing and clicks "Save", the pro overlay's + * save modal asks for a name and creates a NEW project (no project + * id is set on useProjectStore, so it can't overwrite anything). + */ + +import { useEffect, useRef, useState } from 'react'; +import { useParams } from 'react-router-dom'; +import { exampleProjects } from '../data/examples'; +import { loadExample, type LibraryInstallProgress } from '../utils/loadExample'; +import { EditorPage } from './EditorPage'; +import { AppHeader } from '../components/layout/AppHeader'; +import { useSEO } from '../utils/useSEO'; + +const DOMAIN = 'https://velxio.dev'; + +export const ExampleEditorPage: React.FC = () => { + const { exampleId } = useParams<{ exampleId: string }>(); + const [ready, setReady] = useState(false); + const [error, setError] = useState(false); + const [installing, setInstalling] = useState(null); + // Guard so React strict-mode (which fires effects twice in dev) doesn't + // run loadExample twice — and so the user can keep editing without the + // example reloading on every store-triggered re-render. + const loadedIdRef = useRef(null); + + const example = exampleId + ? exampleProjects.find((e) => e.id === exampleId) + : null; + + useSEO({ + title: example + ? `${example.title} — Velxio Arduino Simulator` + : 'Example — Velxio', + description: + example?.description ?? 'Arduino example running on Velxio.', + url: example + ? `${DOMAIN}/example/${example.id}` + : `${DOMAIN}/examples`, + }); + + useEffect(() => { + if (!exampleId) { + setError(true); + return; + } + if (!example) { + setError(true); + return; + } + if (loadedIdRef.current === exampleId) return; + loadedIdRef.current = exampleId; + + let cancelled = false; + setReady(false); + setError(false); + (async () => { + try { + await loadExample(example, setInstalling); + } catch { + // loadExample's internal failures (library install network errors) + // are swallowed inside ensureLibraries — anything that DOES bubble + // up here means the stores are partially populated. Surfacing a + // clean error is more useful than rendering an empty editor. + if (!cancelled) setError(true); + return; + } + if (!cancelled) setReady(true); + })(); + + return () => { + cancelled = true; + }; + }, [exampleId, example]); + + if (error) { + return ( +
+ +
+
404
+
+ Example "{exampleId}" not found. +
+ + Browse all examples + +
+
+ ); + } + + if (!ready) { + return ( +
+
+
Loading example…
+ {installing && ( +
+ Installing {installing.current} ({installing.done + 1}/{installing.total}) +
+ )} +
+
+ ); + } + + return ; +}; diff --git a/frontend/src/pages/ExamplesPage.tsx b/frontend/src/pages/ExamplesPage.tsx index 8199a868..034e8e9c 100644 --- a/frontend/src/pages/ExamplesPage.tsx +++ b/frontend/src/pages/ExamplesPage.tsx @@ -1,31 +1,29 @@ /** * Examples Page Component * - * Displays the examples gallery + * Displays the examples gallery. Clicking a tile navigates to + * `/example/` — ExampleEditorPage owns the actual load (library + * install, store mutations) and keeps the URL pinned for the lifetime + * of the session so examples are shareable like saved projects are. */ -import React, { useState } from 'react'; +import React from 'react'; import { useNavigate } from 'react-router-dom'; -import { useTranslation } from 'react-i18next'; import { ExamplesGallery } from '../components/examples/ExamplesGallery'; import { AppHeader } from '../components/layout/AppHeader'; import { useLocalizedHref } from '../i18n/useLocalizedNavigate'; import { useSEO } from '../utils/useSEO'; import { getSeoMeta } from '../seoRoutes'; -import { loadExample, type LibraryInstallProgress } from '../utils/loadExample'; import type { ExampleProject } from '../data/examples'; export const ExamplesPage: React.FC = () => { - const { t } = useTranslation(); const localize = useLocalizedHref(); useSEO(getSeoMeta('/examples')!); const navigate = useNavigate(); - const [installing, setInstalling] = useState(null); - const handleLoadExample = async (example: ExampleProject) => { - await loadExample(example, setInstalling); - navigate(localize('/editor')); + const handleLoadExample = (example: ExampleProject) => { + navigate(localize(`/example/${example.id}`)); }; return ( @@ -39,61 +37,6 @@ export const ExamplesPage: React.FC = () => { > - - {/* Library install overlay */} - {installing && ( -
-
-
- {t('examples.installing', { - done: installing.done + 1, - total: installing.total, - })} -
-
- {installing.current} -
-
-
-
-
-
- )}
); };