feat: add share functionality for projects and examples

- Implemented ExampleLoaderPage to load examples by ID from the URL.
- Added ExampleLoaderPage route to App component.
- Created ShareModal for sharing project links with visibility toggle.
- Updated UserProfilePage to include share button for user projects.
- Enhanced ExamplesGallery with a copy link button for examples.
- Introduced utility function loadExample to streamline example loading and library installation.
- Updated project visibility management in useProjectStore.
- Added styles for new components and buttons.
- Updated .gitignore to include Arduino compilation byproducts.
This commit is contained in:
David Montero Crespo 2026-03-30 19:00:33 -03:00
parent e99ded70b5
commit 8c68879ce7
13 changed files with 597 additions and 163 deletions

10
.gitignore vendored
View File

@ -97,4 +97,12 @@ test/esp32-emulator/out_*/
# Google Cloud service account credentials
velxio-ba3355a41944.json
marketing/*
marketing/*
docs/github-issues/*
# Arduino compilation byproducts in test fixtures
**/*.ino.eep
**/*.ino.with_bootloader.bin
**/*.ino.with_bootloader.hex
**/*.ino.map
**/*.ino.uf2

View File

@ -10,6 +10,7 @@ import { UserProfilePage } from './pages/UserProfilePage';
import { ProjectPage } from './pages/ProjectPage';
import { ProjectByIdPage } from './pages/ProjectByIdPage';
import { AdminPage } from './pages/AdminPage';
import { ExampleLoaderPage } from './pages/ExampleLoaderPage';
import { ArduinoSimulatorPage } from './pages/ArduinoSimulatorPage';
import { ArduinoEmulatorPage } from './pages/ArduinoEmulatorPage';
import { AtmegaSimulatorPage } from './pages/AtmegaSimulatorPage';
@ -37,6 +38,7 @@ function App() {
<Route path="/" element={<LandingPage />} />
<Route path="/editor" element={<EditorPage />} />
<Route path="/examples" element={<ExamplesPage />} />
<Route path="/examples/:exampleId" element={<ExampleLoaderPage />} />
<Route path="/docs" element={<DocsPage />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />

View File

@ -267,6 +267,23 @@
}
/* Empty state */
.example-copy-link {
margin-left: auto;
background: none;
border: 1px solid transparent;
border-radius: 4px;
padding: 3px 5px;
cursor: pointer;
color: #888;
display: flex;
align-items: center;
transition: color 0.15s, border-color 0.15s;
}
.example-copy-link:hover {
color: #4fc3f7;
border-color: #4fc3f7;
}
.examples-empty {
max-width: 1200px;
margin: 80px auto;

View File

@ -4,7 +4,7 @@
* Displays a gallery of example Arduino projects that users can load and run
*/
import React, { useState } from 'react';
import React, { useState, useCallback } from 'react';
import { exampleProjects, type ExampleProject } from '../../data/examples';
import './ExamplesGallery.css';
@ -42,6 +42,16 @@ export const ExamplesGallery: React.FC<ExamplesGalleryProps> = ({ onLoadExample
const [selectedBoard, setSelectedBoard] = useState<string>('all');
const [selectedCategory, setSelectedCategory] = useState<ExampleProject['category'] | 'all'>('all');
const [selectedDifficulty, setSelectedDifficulty] = useState<ExampleProject['difficulty'] | 'all'>('all');
const [copiedId, setCopiedId] = useState<string | null>(null);
const handleCopyLink = useCallback((e: React.MouseEvent, exampleId: string) => {
e.stopPropagation(); // Don't trigger card click
const url = `${window.location.origin}/examples/${exampleId}`;
navigator.clipboard.writeText(url).then(() => {
setCopiedId(exampleId);
setTimeout(() => setCopiedId(null), 2000);
});
}, []);
const filteredExamples = exampleProjects.filter((example) => {
const boardMatch = selectedBoard === 'all' || getBoardFilter(example) === selectedBoard;
@ -237,6 +247,22 @@ export const ExamplesGallery: React.FC<ExamplesGalleryProps> = ({ onLoadExample
{boardBadge.label}
</span>
)}
<button
className="example-copy-link"
onClick={(e) => handleCopyLink(e, example.id)}
title="Copy shareable link"
>
{copiedId === example.id ? (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#4ade80" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12" />
</svg>
) : (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
</svg>
)}
</button>
</div>
</div>
</div>

View File

@ -1,6 +1,8 @@
import { useState, useRef, useEffect } from 'react';
import { Link, useNavigate, useLocation } from 'react-router-dom';
import { useAuthStore } from '../../store/useAuthStore';
import { useProjectStore } from '../../store/useProjectStore';
import { ShareModal } from './ShareModal';
import { trackVisitGitHub, trackVisitDiscord } from '../../utils/analytics';
const GITHUB_URL = 'https://github.com/davidmonterocrespo24/velxio';
@ -13,8 +15,10 @@ export const AppHeader: React.FC<AppHeaderProps> = () => {
const logout = useAuthStore((s) => s.logout);
const navigate = useNavigate();
const location = useLocation();
const currentProject = useProjectStore((s) => s.currentProject);
const [dropdownOpen, setDropdownOpen] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);
const [showShareModal, setShowShareModal] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
@ -80,8 +84,30 @@ export const AppHeader: React.FC<AppHeaderProps> = () => {
</nav>
</div>
{/* Right: auth + mobile hamburger */}
{/* Right: share + auth + mobile hamburger */}
<div className="header-right">
{/* Share button — visible when a project is loaded */}
{currentProject && location.pathname === '/editor' && (
<button
onClick={() => setShowShareModal(true)}
style={{
background: 'transparent', border: '1px solid #555', borderRadius: 4,
padding: '4px 10px', cursor: 'pointer', display: 'flex',
alignItems: 'center', gap: 5, color: '#ccc', fontSize: 13,
}}
title="Share project"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="18" cy="5" r="3" />
<circle cx="6" cy="12" r="3" />
<circle cx="18" cy="19" r="3" />
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49" />
<line x1="15.41" y1="6.51" x2="8.59" y2="10.49" />
</svg>
Share
</button>
)}
{/* Auth UI */}
{user ? (
<div style={{ position: 'relative' }} ref={dropdownRef}>
@ -138,6 +164,8 @@ export const AppHeader: React.FC<AppHeaderProps> = () => {
</div>
</div>
{showShareModal && <ShareModal onClose={() => setShowShareModal(false)} />}
</header>
);
};

View File

@ -103,14 +103,35 @@ export const SaveProjectModal: React.FC<SaveProjectModalProps> = ({ onClose }) =
placeholder="Optional"
/>
<label style={styles.checkboxRow}>
<input
type="checkbox"
checked={isPublic}
onChange={(e) => setIsPublic(e.target.checked)}
/>
<span style={{ color: '#ccc', fontSize: 13 }}>Public</span>
</label>
<div
style={styles.visibilityToggle}
onClick={() => setIsPublic(!isPublic)}
role="button"
tabIndex={0}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
{isPublic ? (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#4ade80" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<line x1="2" y1="12" x2="22" y2="12" />
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
</svg>
) : (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#f59e0b" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
)}
<div>
<div style={{ color: isPublic ? '#4ade80' : '#f59e0b', fontSize: 13, fontWeight: 600 }}>
{isPublic ? 'Public' : 'Private'}
</div>
<div style={{ color: '#888', fontSize: 11 }}>
{isPublic ? 'Anyone with the link can view' : 'Only you can see this'}
</div>
</div>
</div>
</div>
<div style={styles.actions}>
<button type="submit" disabled={saving} style={styles.saveBtn}>
@ -132,6 +153,7 @@ const styles: Record<string, React.CSSProperties> = {
label: { color: '#9d9d9d', fontSize: 13 },
input: { background: '#3c3c3c', border: '1px solid #555', borderRadius: 4, padding: '8px 10px', color: '#ccc', fontSize: 14, outline: 'none' },
checkboxRow: { display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' },
visibilityToggle: { display: 'flex', alignItems: 'center', padding: '8px 10px', background: '#1e1e1e', border: '1px solid #444', borderRadius: 6, cursor: 'pointer', transition: 'border-color 0.15s' },
actions: { display: 'flex', gap: 8, marginTop: 4 },
saveBtn: { flex: 1, background: '#0e639c', border: 'none', borderRadius: 4, color: '#fff', padding: '9px', fontSize: 14, cursor: 'pointer', fontWeight: 500 },
cancelBtn: { background: 'transparent', border: '1px solid #555', borderRadius: 4, color: '#ccc', padding: '9px 16px', fontSize: 14, cursor: 'pointer' },

View File

@ -0,0 +1,161 @@
/**
* ShareModal shows a shareable project link and visibility toggle.
*/
import React, { useState } from 'react';
import { useProjectStore } from '../../store/useProjectStore';
import { updateProject } from '../../services/projectService';
interface ShareModalProps {
onClose: () => void;
}
export const ShareModal: React.FC<ShareModalProps> = ({ onClose }) => {
const currentProject = useProjectStore((s) => s.currentProject);
const setVisibility = useProjectStore((s) => s.setVisibility);
const [copied, setCopied] = useState(false);
const [toggling, setToggling] = useState(false);
if (!currentProject) return null;
const shareUrl = `${window.location.origin}/project/${currentProject.id}`;
const isPublic = currentProject.isPublic;
const handleCopy = () => {
navigator.clipboard.writeText(shareUrl).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
};
const handleToggleVisibility = async () => {
setToggling(true);
try {
await updateProject(currentProject.id, { is_public: !isPublic });
setVisibility(!isPublic);
} catch {
// Silently fail — user can retry
} finally {
setToggling(false);
}
};
return (
<div style={styles.overlay} onClick={onClose}>
<div style={styles.modal} onClick={(e) => e.stopPropagation()}>
<h2 style={styles.title}>Share project</h2>
{/* Visibility toggle */}
<div style={styles.visibilityRow}>
<div style={styles.visibilityInfo}>
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
{isPublic ? (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#4ade80" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<line x1="2" y1="12" x2="22" y2="12" />
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
</svg>
) : (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#f59e0b" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
)}
<span style={{ color: isPublic ? '#4ade80' : '#f59e0b', fontWeight: 600, fontSize: 13 }}>
{isPublic ? 'Public' : 'Private'}
</span>
</span>
<span style={{ color: '#888', fontSize: 12 }}>
{isPublic ? 'Anyone with the link can view this project' : 'Only you can see this project'}
</span>
</div>
<button
onClick={handleToggleVisibility}
disabled={toggling}
style={{
...styles.toggleBtn,
opacity: toggling ? 0.5 : 1,
}}
>
{toggling ? '...' : isPublic ? 'Make private' : 'Make public'}
</button>
</div>
{/* Share link */}
<div style={styles.linkRow}>
<input
type="text"
value={shareUrl}
readOnly
style={styles.linkInput}
onClick={(e) => (e.target as HTMLInputElement).select()}
/>
<button onClick={handleCopy} style={styles.copyBtn}>
{copied ? (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#4ade80" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12" />
</svg>
) : (
'Copy'
)}
</button>
</div>
{!isPublic && (
<div style={styles.warning}>
This project is private. Others will see a 403 error when opening this link.
</div>
)}
<div style={styles.actions}>
<button onClick={onClose} style={styles.closeBtn}>Close</button>
</div>
</div>
</div>
);
};
const styles: Record<string, React.CSSProperties> = {
overlay: {
position: 'fixed', inset: 0, background: 'rgba(0,0,0,.6)',
display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000,
},
modal: {
background: '#252526', border: '1px solid #3c3c3c', borderRadius: 8,
padding: '1.75rem', width: 440, display: 'flex', flexDirection: 'column', gap: 16,
},
title: { color: '#ccc', margin: 0, fontSize: 18, fontWeight: 600 },
visibilityRow: {
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
gap: 12, padding: '10px 12px', background: '#1e1e1e',
border: '1px solid #333', borderRadius: 6,
},
visibilityInfo: {
display: 'flex', flexDirection: 'column', gap: 4,
},
toggleBtn: {
background: 'transparent', border: '1px solid #555', borderRadius: 4,
color: '#ccc', padding: '6px 12px', fontSize: 12, cursor: 'pointer',
whiteSpace: 'nowrap', flexShrink: 0,
},
linkRow: { display: 'flex', gap: 6 },
linkInput: {
flex: 1, background: '#1e1e1e', border: '1px solid #444', borderRadius: 4,
padding: '8px 10px', color: '#4fc3f7', fontSize: 13, fontFamily: 'monospace',
outline: 'none',
},
copyBtn: {
background: '#0e639c', border: 'none', borderRadius: 4,
color: '#fff', padding: '8px 16px', fontSize: 13, cursor: 'pointer',
fontWeight: 500, display: 'flex', alignItems: 'center',
},
warning: {
background: '#3d2e00', border: '1px solid #f59e0b44', borderRadius: 4,
color: '#f59e0b', padding: '8px 12px', fontSize: 12,
},
actions: { display: 'flex', justifyContent: 'flex-end' },
closeBtn: {
background: 'transparent', border: '1px solid #555', borderRadius: 4,
color: '#ccc', padding: '8px 16px', fontSize: 13, cursor: 'pointer',
},
};

View File

@ -0,0 +1,91 @@
/**
* ExampleLoaderPage loads an example by ID from the URL and redirects to the editor.
*
* Route: /examples/:exampleId
* Example: /examples/blink-led
*/
import React, { useEffect, useState } 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';
export const ExampleLoaderPage: React.FC = () => {
const { exampleId } = useParams<{ exampleId: string }>();
const navigate = useNavigate();
const [error, setError] = useState(false);
const [installing, setInstalling] = useState<LibraryInstallProgress | null>(null);
useEffect(() => {
if (!exampleId) { setError(true); return; }
const example = exampleProjects.find((e) => e.id === exampleId);
if (!example) { setError(true); return; }
let cancelled = false;
(async () => {
await loadExample(example, setInstalling);
if (!cancelled) navigate('/editor', { replace: true });
})();
return () => { cancelled = true; };
}, [exampleId, navigate]);
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>
<Link
to="/examples"
style={{
color: '#4fc3f7', textDecoration: 'none',
border: '1px solid #4fc3f7', borderRadius: 4,
padding: '8px 20px', fontSize: 14,
}}
>
Browse all examples
</Link>
</div>
</div>
);
}
return (
<div style={{
display: 'flex', flexDirection: 'column', minHeight: '100vh',
background: '#1e1e1e', alignItems: 'center', justifyContent: 'center',
}}>
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: 16, color: '#ccc', marginBottom: 12 }}>
Loading example...
</div>
{installing && (
<div style={{ maxWidth: 300, margin: '0 auto' }}>
<div style={{ fontSize: 13, color: '#999', marginBottom: 8 }}>
Installing libraries ({installing.done + 1}/{installing.total})
</div>
<div style={{ fontSize: 14, color: '#00e5ff', fontWeight: 600, marginBottom: 12 }}>
{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>
);
};

View File

@ -10,165 +10,17 @@ import { ExamplesGallery } from '../components/examples/ExamplesGallery';
import { AppHeader } from '../components/layout/AppHeader';
import { useSEO } from '../utils/useSEO';
import { getSeoMeta } from '../seoRoutes';
import { useEditorStore } from '../store/useEditorStore';
import { useSimulatorStore } from '../store/useSimulatorStore';
import { useVfsStore } from '../store/useVfsStore';
import { isBoardComponent } from '../utils/boardPinMapping';
import { getInstalledLibraries, installLibrary } from '../services/libraryService';
import { loadExample, type LibraryInstallProgress } from '../utils/loadExample';
import type { ExampleProject } from '../data/examples';
import { trackOpenExample } from '../utils/analytics';
import type { BoardKind } from '../types/board';
export const ExamplesPage: React.FC = () => {
useSEO(getSeoMeta('/examples')!);
const navigate = useNavigate();
const { setCode } = useEditorStore();
const { setComponents, setWires, setBoardType, activeBoardId, boards, addBoard, removeBoard, setActiveBoardId } = useSimulatorStore();
const [installing, setInstalling] = useState<{ total: number; done: number; current: string } | null>(null);
/** Install any missing libraries required by the example (non-blocking UI). */
const ensureLibraries = async (libs: string[]): Promise<void> => {
if (libs.length === 0) return;
try {
const installed = await getInstalledLibraries();
const installedNames = new Set(
installed.map((l) => (l.library?.name ?? l.name ?? '').toLowerCase())
);
const missing = libs.filter((l) => !installedNames.has(l.toLowerCase()));
if (missing.length === 0) return;
setInstalling({ total: missing.length, done: 0, current: missing[0] });
for (let i = 0; i < missing.length; i++) {
setInstalling({ total: missing.length, done: i, current: missing[i] });
await installLibrary(missing[i]);
}
setInstalling(null);
} catch {
// If install fails (e.g. offline), continue anyway — compile will show the error
setInstalling(null);
}
};
const [installing, setInstalling] = useState<LibraryInstallProgress | null>(null);
const handleLoadExample = async (example: ExampleProject) => {
trackOpenExample(example.title);
// Auto-install required libraries before loading
if (example.libraries && example.libraries.length > 0) {
await ensureLibraries(example.libraries);
}
if (example.boards && example.boards.length > 0) {
// ── Multi-board loading ───────────────────────────────────────────────
// 1. Remove all current boards
const currentIds = boards.map((b) => b.id);
currentIds.forEach((id) => removeBoard(id));
// 2. Add each board from the example; addBoard returns deterministic IDs
example.boards.forEach((eb) => {
addBoard(eb.boardKind as BoardKind, eb.x, eb.y);
});
// 3. Load code + VFS per board
const { boards: newBoards } = useSimulatorStore.getState();
example.boards.forEach((eb) => {
const boardId = eb.boardKind; // predictable: first board of each kind = boardKind string
const board = newBoards.find((b) => b.id === boardId);
if (!board) return;
if (eb.code) {
const filename = boardId === 'arduino-uno' || boardId === 'arduino-nano' || boardId === 'arduino-mega'
? 'sketch.ino'
: 'main.cpp';
// loadFiles reads activeGroupId internally — switch to this board's group first
useEditorStore.getState().setActiveGroup(board.activeFileGroupId);
useEditorStore.getState().loadFiles([{ name: filename, content: eb.code }]);
}
if (eb.vfsFiles && boardId === 'raspberry-pi-3') {
// Update VFS files by name (default tree has script.py and hello.sh)
const vfsState = useVfsStore.getState();
const tree = vfsState.getTree(boardId);
for (const [nodeId, node] of Object.entries(tree)) {
if (node.type === 'file' && eb.vfsFiles[node.name] !== undefined) {
vfsState.setContent(boardId, nodeId, eb.vfsFiles[node.name]);
}
}
}
});
// 4. Set active board to the first non-Pi board (so editor shows Arduino code)
const firstArduino = example.boards.find((eb) =>
eb.boardKind !== 'raspberry-pi-3' && eb.boardKind !== 'esp32' &&
eb.boardKind !== 'esp32-s3' && eb.boardKind !== 'esp32-c3'
);
if (firstArduino) {
setActiveBoardId(firstArduino.boardKind);
}
// 5. Load components (filter out board components — they're placed via boards[])
const componentsWithoutBoard = example.components.filter(
(comp) =>
!comp.type.includes('arduino') &&
!comp.type.includes('pico') &&
!comp.type.includes('raspberry') &&
!comp.type.includes('esp32')
);
setComponents(
componentsWithoutBoard.map((comp) => ({
id: comp.id,
metadataId: comp.type.replace('wokwi-', ''),
x: comp.x,
y: comp.y,
properties: comp.properties,
}))
);
// 6. Load wires — componentIds already match board instance IDs
setWires(
example.wires.map((wire) => ({
id: wire.id,
start: { componentId: wire.start.componentId, pinName: wire.start.pinName, x: 0, y: 0 },
end: { componentId: wire.end.componentId, pinName: wire.end.pinName, x: 0, y: 0 },
color: wire.color,
waypoints: [],
}))
);
} else {
// ── Single-board loading (original behaviour) ─────────────────────────
const targetBoard = example.boardType || 'arduino-uno';
setBoardType(targetBoard);
setCode(example.code);
const componentsWithoutBoard = example.components.filter(
(comp) =>
!comp.type.includes('arduino') &&
!comp.type.includes('pico') &&
!comp.type.includes('esp32')
);
setComponents(
componentsWithoutBoard.map((comp) => ({
id: comp.id,
metadataId: comp.type.replace('wokwi-', ''),
x: comp.x,
y: comp.y,
properties: comp.properties,
}))
);
const boardInstanceId = activeBoardId ?? 'arduino-uno';
const remapBoardId = (id: string) => isBoardComponent(id) ? boardInstanceId : id;
setWires(
example.wires.map((wire) => ({
id: wire.id,
start: { componentId: remapBoardId(wire.start.componentId), pinName: wire.start.pinName, x: 0, y: 0 },
end: { componentId: remapBoardId(wire.end.componentId), pinName: wire.end.pinName, x: 0, y: 0 },
color: wire.color,
waypoints: [],
}))
);
}
await loadExample(example, setInstalling);
navigate('/editor');
};

View File

@ -128,6 +128,23 @@
font-family: var(--mono);
}
.profile-share-btn {
background: none;
border: 1px solid transparent;
border-radius: 4px;
padding: 2px 4px;
cursor: pointer;
color: #888;
display: flex;
align-items: center;
margin-left: 4px;
transition: color 0.15s, border-color 0.15s;
}
.profile-share-btn:hover {
color: #4fc3f7;
border-color: #4fc3f7;
}
.profile-muted {
color: var(--text-muted);
font-size: 15px;

View File

@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useEffect, useState, useCallback } from 'react';
import { Link, useParams } from 'react-router-dom';
import { getUserProjects, type ProjectResponse } from '../services/projectService';
import { useAuthStore } from '../store/useAuthStore';
@ -30,6 +30,17 @@ export const UserProfilePage: React.FC = () => {
}, [username]);
const isOwn = user?.username === username;
const [copiedId, setCopiedId] = useState<string | null>(null);
const handleCopyLink = useCallback((e: React.MouseEvent, projectId: string) => {
e.preventDefault(); // Don't navigate via the <Link>
e.stopPropagation();
const url = `${window.location.origin}/project/${projectId}`;
navigator.clipboard.writeText(url).then(() => {
setCopiedId(projectId);
setTimeout(() => setCopiedId(null), 2000);
});
}, []);
return (
<div className="profile-page">
@ -58,6 +69,24 @@ export const UserProfilePage: React.FC = () => {
<span className="profile-badge">{p.board_type}</span>
{!p.is_public && <span className="profile-badge profile-badge-private">Private</span>}
<span className="profile-date">{new Date(p.updated_at).toLocaleDateString()}</span>
{p.is_public && (
<button
className="profile-share-btn"
onClick={(e) => handleCopyLink(e, p.id)}
title="Copy shareable link"
>
{copiedId === p.id ? (
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="#4ade80" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12" />
</svg>
) : (
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
</svg>
)}
</button>
)}
</div>
</Link>
))}

View File

@ -11,10 +11,15 @@ interface ProjectState {
currentProject: CurrentProject | null;
setCurrentProject: (project: CurrentProject) => void;
clearCurrentProject: () => void;
setVisibility: (isPublic: boolean) => void;
}
export const useProjectStore = create<ProjectState>((set) => ({
currentProject: null,
setCurrentProject: (project) => set({ currentProject: project }),
clearCurrentProject: () => set({ currentProject: null }),
setVisibility: (isPublic) =>
set((s) =>
s.currentProject ? { currentProject: { ...s.currentProject, isPublic } } : s,
),
}));

View File

@ -0,0 +1,176 @@
/**
* Shared utility to load an example project into the editor and simulator stores.
* Used by both ExamplesPage (gallery click) and ExampleLoaderPage (direct URL).
*/
import type { ExampleProject } from '../data/examples';
import type { BoardKind } from '../types/board';
import { useEditorStore } from '../store/useEditorStore';
import { useSimulatorStore } from '../store/useSimulatorStore';
import { useVfsStore } from '../store/useVfsStore';
import { isBoardComponent } from './boardPinMapping';
import { getInstalledLibraries, installLibrary } from '../services/libraryService';
import { trackOpenExample } from './analytics';
export interface LibraryInstallProgress {
total: number;
done: number;
current: string;
}
/**
* Install any missing Arduino libraries required by an example.
* Calls onProgress for UI updates; silently continues on failure.
*/
export async function ensureLibraries(
libs: string[],
onProgress?: (progress: LibraryInstallProgress | null) => void,
): Promise<void> {
if (libs.length === 0) return;
try {
const installed = await getInstalledLibraries();
const installedNames = new Set(
installed.map((l) => (l.library?.name ?? l.name ?? '').toLowerCase()),
);
const missing = libs.filter((l) => !installedNames.has(l.toLowerCase()));
if (missing.length === 0) return;
onProgress?.({ total: missing.length, done: 0, current: missing[0] });
for (let i = 0; i < missing.length; i++) {
onProgress?.({ total: missing.length, done: i, current: missing[i] });
await installLibrary(missing[i]);
}
onProgress?.(null);
} catch {
onProgress?.(null);
}
}
/**
* Load an example project into the editor + simulator stores.
* Does NOT navigate the caller is responsible for navigation.
*/
export async function loadExample(
example: ExampleProject,
onLibraryProgress?: (progress: LibraryInstallProgress | null) => void,
): Promise<void> {
trackOpenExample(example.title);
// Auto-install required libraries
if (example.libraries && example.libraries.length > 0) {
await ensureLibraries(example.libraries, onLibraryProgress);
}
const {
setComponents, setWires, setBoardType,
activeBoardId, boards, addBoard, removeBoard, setActiveBoardId,
} = useSimulatorStore.getState();
if (example.boards && example.boards.length > 0) {
// ── Multi-board loading ───────────────────────────────────────────────
const currentIds = boards.map((b) => b.id);
currentIds.forEach((id) => removeBoard(id));
example.boards.forEach((eb) => {
addBoard(eb.boardKind as BoardKind, eb.x, eb.y);
});
const { boards: newBoards } = useSimulatorStore.getState();
example.boards.forEach((eb) => {
const boardId = eb.boardKind;
const board = newBoards.find((b) => b.id === boardId);
if (!board) return;
if (eb.code) {
const filename =
boardId === 'arduino-uno' || boardId === 'arduino-nano' || boardId === 'arduino-mega'
? 'sketch.ino'
: 'main.cpp';
useEditorStore.getState().setActiveGroup(board.activeFileGroupId);
useEditorStore.getState().loadFiles([{ name: filename, content: eb.code }]);
}
if (eb.vfsFiles && boardId === 'raspberry-pi-3') {
const vfsState = useVfsStore.getState();
const tree = vfsState.getTree(boardId);
for (const [nodeId, node] of Object.entries(tree)) {
if (node.type === 'file' && eb.vfsFiles[node.name] !== undefined) {
vfsState.setContent(boardId, nodeId, eb.vfsFiles[node.name]);
}
}
}
});
const firstArduino = example.boards.find(
(eb) =>
eb.boardKind !== 'raspberry-pi-3' &&
eb.boardKind !== 'esp32' &&
eb.boardKind !== 'esp32-s3' &&
eb.boardKind !== 'esp32-c3',
);
if (firstArduino) {
setActiveBoardId(firstArduino.boardKind);
}
const componentsWithoutBoard = example.components.filter(
(comp) =>
!comp.type.includes('arduino') &&
!comp.type.includes('pico') &&
!comp.type.includes('raspberry') &&
!comp.type.includes('esp32'),
);
setComponents(
componentsWithoutBoard.map((comp) => ({
id: comp.id,
metadataId: comp.type.replace('wokwi-', ''),
x: comp.x,
y: comp.y,
properties: comp.properties,
})),
);
setWires(
example.wires.map((wire) => ({
id: wire.id,
start: { componentId: wire.start.componentId, pinName: wire.start.pinName, x: 0, y: 0 },
end: { componentId: wire.end.componentId, pinName: wire.end.pinName, x: 0, y: 0 },
color: wire.color,
waypoints: [],
})),
);
} else {
// ── Single-board loading ─────────────────────────────────────────────
const targetBoard = example.boardType || 'arduino-uno';
setBoardType(targetBoard);
useEditorStore.getState().setCode(example.code);
const componentsWithoutBoard = example.components.filter(
(comp) =>
!comp.type.includes('arduino') &&
!comp.type.includes('pico') &&
!comp.type.includes('esp32'),
);
setComponents(
componentsWithoutBoard.map((comp) => ({
id: comp.id,
metadataId: comp.type.replace('wokwi-', ''),
x: comp.x,
y: comp.y,
properties: comp.properties,
})),
);
const boardInstanceId = activeBoardId ?? 'arduino-uno';
const remapBoardId = (id: string) => (isBoardComponent(id) ? boardInstanceId : id);
setWires(
example.wires.map((wire) => ({
id: wire.id,
start: { componentId: remapBoardId(wire.start.componentId), pinName: wire.start.pinName, x: 0, y: 0 },
end: { componentId: remapBoardId(wire.end.componentId), pinName: wire.end.pinName, x: 0, y: 0 },
color: wire.color,
waypoints: [],
})),
);
}
}