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:
parent
95f2aa9a9f
commit
65cbc403d2
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 "{exampleId}" 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 />;
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue