feat(examples): /example/<id> route with pinned URL

Mirror of the /project/<uuid> 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/<id> 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 <EditorPage /> after the load completes — same as how
    ProjectByIdPage stays mounted at /project/<uuid> after load.
  - SEO: title + description per example, canonical URL points at
    /example/<id>.
  - 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/<id>  = SEO landing page (preview, badges, "Open in
                    Simulator" CTA). Indexed by Google (130 URLs
                    already in sitemap.xml).
  /example/<id>   = 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/<id> instead of
calling loadExample directly. Also drops the install-overlay block
(progress UI is on ExampleEditorPage now).

ExampleDetailPage — "Open in Simulator" navigates to /example/<id>
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) <noreply@anthropic.com>
This commit is contained in:
David Montero Crespo 2026-05-15 00:14:36 -03:00
parent 95f2aa9a9f
commit 65cbc403d2
4 changed files with 181 additions and 114 deletions

View File

@ -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: <LandingPage />, index: true },
{ path: 'editor', element: <EditorPage /> },
{ path: 'examples', element: <ExamplesPage /> },
// /examples/<id> = SEO landing (preview, badges, "Open in Simulator" CTA).
// /example/<id> = 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: <ExampleDetailPage /> },
{ path: 'example/:exampleId', element: <ExampleEditorPage /> },
{ path: 'docs', element: <DocsPage /> },
{ path: 'docs/:section', element: <DocsPage /> },
// SEO landing pages — keyword-targeted

View File

@ -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<string, string> = {
export const ExampleDetailPage: React.FC = () => {
const { exampleId } = useParams<{ exampleId: string }>();
const navigate = useNavigate();
const [installing, setInstalling] = useState<LibraryInstallProgress | null>(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 = () => {
/>
</main>
{/* Library install overlay */}
{installing && (
<div
style={{
position: 'fixed',
inset: 0,
zIndex: 9999,
background: 'rgba(0,0,0,0.7)',
backdropFilter: 'blur(4px)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<div
style={{
background: '#1e1e1e',
border: '1px solid #333',
borderRadius: 12,
padding: '28px 36px',
textAlign: 'center',
maxWidth: 360,
}}
>
<div style={{ fontSize: 14, color: '#ccc', marginBottom: 12 }}>
Installing libraries ({installing.done + 1}/{installing.total})
</div>
<div style={{ fontSize: 16, color: '#00e5ff', fontWeight: 600, marginBottom: 16 }}>
{installing.current}
</div>
<div style={{ height: 4, borderRadius: 2, background: '#333', overflow: 'hidden' }}>
<div
style={{
height: '100%',
borderRadius: 2,
background: '#00b8d4',
width: `${((installing.done + 1) / installing.total) * 100}%`,
transition: 'width 0.3s ease',
}}
/>
</div>
</div>
</div>
)}
{/* Library install overlay used to live here moved to
ExampleEditorPage now that loading runs at /example/<id>. */}
</div>
);
};

View File

@ -0,0 +1,160 @@
/**
* ExampleEditorPage route `/example/:exampleId`.
*
* Paralelo a ProjectByIdPage (`/project/<uuid>`) but for the built-in
* example projects. Loads the example into the editor + simulator
* stores AND keeps the URL pinned to `/example/<id>` 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/<id> already gave it a landing page. The two co-
* exist on purpose `/examples/<id>` (plural) is the marketing
* landing with preview + description, `/example/<id>` (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<LibraryInstallProgress | null>(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<string | null>(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 (
<div
style={{
display: 'flex',
flexDirection: 'column',
minHeight: '100vh',
background: '#1e1e1e',
}}
>
<AppHeader />
<div
style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 16,
}}
>
<div style={{ fontSize: 48, color: '#555' }}>404</div>
<div style={{ fontSize: 16, color: '#999' }}>
Example &quot;{exampleId}&quot; not found.
</div>
<a
href="/examples"
style={{
color: '#4fc3f7',
textDecoration: 'none',
border: '1px solid #4fc3f7',
borderRadius: 4,
padding: '8px 20px',
fontSize: 14,
}}
>
Browse all examples
</a>
</div>
</div>
);
}
if (!ready) {
return (
<div
style={{
minHeight: '100vh',
background: '#1e1e1e',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<div style={{ textAlign: 'center', color: '#ccc' }}>
<div style={{ fontSize: 15 }}>Loading example</div>
{installing && (
<div style={{ marginTop: 10, fontSize: 13, color: '#9d9d9d' }}>
Installing {installing.current} ({installing.done + 1}/{installing.total})
</div>
)}
</div>
</div>
);
}
return <EditorPage />;
};

View File

@ -1,31 +1,29 @@
/**
* Examples Page Component
*
* Displays the examples gallery
* Displays the examples gallery. Clicking a tile navigates to
* `/example/<id>` 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<LibraryInstallProgress | null>(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 = () => {
>
<AppHeader />
<ExamplesGallery onLoadExample={handleLoadExample} />
{/* Library install overlay */}
{installing && (
<div
style={{
position: 'fixed',
inset: 0,
zIndex: 9999,
background: 'rgba(0,0,0,0.7)',
backdropFilter: 'blur(4px)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<div
style={{
background: '#1e1e1e',
border: '1px solid #333',
borderRadius: 12,
padding: '28px 36px',
textAlign: 'center',
maxWidth: 360,
}}
>
<div style={{ fontSize: 14, color: '#ccc', marginBottom: 12 }}>
{t('examples.installing', {
done: installing.done + 1,
total: installing.total,
})}
</div>
<div style={{ fontSize: 16, color: '#00e5ff', fontWeight: 600, marginBottom: 16 }}>
{installing.current}
</div>
<div
style={{
height: 4,
borderRadius: 2,
background: '#333',
overflow: 'hidden',
}}
>
<div
style={{
height: '100%',
borderRadius: 2,
background: '#00b8d4',
width: `${((installing.done + 1) / installing.total) * 100}%`,
transition: 'width 0.3s ease',
}}
/>
</div>
</div>
</div>
)}
</div>
);
};