Merge pull request #105 from davidmonterocrespo24/feature/shareable-urls
Feature/shareable urls
This commit is contained in:
commit
d03ce7df24
|
|
@ -114,3 +114,10 @@ backend/tests/test_wifi_webserver_e2e.py
|
|||
# Compiled binary test fixtures (large, should be built not committed)
|
||||
frontend/src/__tests__/fixtures/avr-blink/
|
||||
frontend/src/__tests__/fixtures/rp2040-blink/
|
||||
|
||||
# Arduino compilation byproducts in test fixtures
|
||||
**/*.ino.eep
|
||||
**/*.ino.with_bootloader.bin
|
||||
**/*.ino.with_bootloader.hex
|
||||
**/*.ino.map
|
||||
**/*.ino.uf2
|
||||
|
|
|
|||
|
|
@ -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 />} />
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -116,14 +116,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}>
|
||||
|
|
@ -145,6 +166,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' },
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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');
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
})),
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue