/** * Examples Gallery Component * * Displays a gallery of example Arduino projects that users can load and run */ import React, { useState, useCallback, useSyncExternalStore } from 'react'; import { useTranslation } from 'react-i18next'; import { exampleProjects, subscribeProExamples, getProExamplesVersion, type ExampleProject, } from '../../data/examples'; import { subscribeProBoards, getProBoardsVersion } from '../../lib/proBoardRegistry'; import { BOARD_KIND_LABELS } from '../../types/board'; import { ExampleThumbnail } from './ExampleThumbnail'; import './ExamplesGallery.css'; interface ExamplesGalleryProps { onLoadExample: (example: ExampleProject) => void; } // ── Board config ─────────────────────────────────────────────────────────── interface BoardTab { id: string; label: string; color: string; bg: string; } const BOARD_TABS: BoardTab[] = [ { id: 'all', label: 'All', color: '#ffffff', bg: '#444444' }, { id: 'retro', label: 'Retro', color: '#1a1a1a', bg: '#e0a82e' }, { id: 'arduino-uno', label: 'Arduino Uno', color: '#ffffff', bg: '#007acc' }, { id: 'arduino-nano', label: 'Arduino Nano', color: '#ffffff', bg: '#0055aa' }, { id: 'arduino-mega', label: 'Arduino Mega', color: '#ffffff', bg: '#003388' }, { id: 'raspberry-pi-pico', label: 'Pico', color: '#ffffff', bg: '#c11c31' }, { id: 'pi-pico-w', label: 'Pico W (Wi-Fi)', color: '#ffffff', bg: '#8c0e1e' }, { id: 'esp32', label: 'ESP32 (Xtensa)', color: '#ffffff', bg: '#e77d11' }, { id: 'esp32-cam', label: 'ESP32-CAM', color: '#ffffff', bg: '#d35400' }, { id: 'esp32-s3', label: 'ESP32-S3 (Xtensa)', color: '#ffffff', bg: '#b3540e' }, { id: 'esp32-c3', label: 'ESP32-C3 (RISC-V)', color: '#ffffff', bg: '#27ae60' }, { id: 'stm32-bluepill', label: 'STM32 Blue Pill', color: '#ffffff', bg: '#0a7ea4' }, { id: 'stm32-blackpill', label: 'STM32 Black Pill', color: '#ffffff', bg: '#2d3436' }, { id: 'attiny85', label: 'ATtiny85', color: '#ffffff', bg: '#5d4037' }, { id: 'multi', label: 'Multi-Board', color: '#ffffff', bg: '#7b2d8b' }, { id: 'analog', label: 'Analog', color: '#ffffff', bg: '#0ea5a5' }, { id: 'digital', label: 'Digital', color: '#ffffff', bg: '#6366f1' }, ]; /** The "Retro" tab is tag-based (not board-kind based) so it can collect the * Z80 / Intel / vintage-CPU examples regardless of their board filter. They * also still appear under their board tab (Digital), so this is additive. */ function isRetro(example: ExampleProject): boolean { return (example.tags ?? []).includes('retro'); } function getBoardFilter(example: ExampleProject): string { // An explicit boardFilter is the author's intent and wins — even for // examples authored with the multi-board `boards[]` format (e.g. the STM32 // single-board examples). Genuinely multi-board examples with no boardFilter // still fall through to 'multi'. if ((example as any).boardFilter) return (example as any).boardFilter; if (example.boards) return 'multi'; return example.boardType ?? 'arduino-uno'; } export const ExamplesGallery: React.FC = ({ onLoadExample }) => { const { t } = useTranslation(); const [selectedBoard, setSelectedBoard] = useState('all'); const [selectedCategory, setSelectedCategory] = useState( 'all', ); const [selectedDifficulty, setSelectedDifficulty] = useState< ExampleProject['difficulty'] | 'all' >('all'); const [copiedId, setCopiedId] = useState(null); const [search, setSearch] = useState(''); // Overlay boards and their examples register asynchronously — re-derive the // filter options when either lands, so a pro board's tab appears without a // reload. useSyncExternalStore(subscribeProExamples, getProExamplesVersion, getProExamplesVersion); useSyncExternalStore(subscribeProBoards, getProBoardsVersion, getProBoardsVersion); // The static tabs cover the OSS boards; every other board kind that appears // in the (possibly overlay-extended) gallery gets a tab of its own, labelled // from the board registry. Without this, examples for runtime-registered // boards (XIAO, M5Stack, Raspberry Pi, ...) were reachable only via "All". const boardTabs: BoardTab[] = (() => { const known = new Set(BOARD_TABS.map((tab) => tab.id)); const extra: BoardTab[] = []; for (const ex of exampleProjects) { const bf = getBoardFilter(ex); if (known.has(bf)) continue; known.add(bf); extra.push({ id: bf, label: (BOARD_KIND_LABELS as Record)[bf] ?? bf, color: '#ffffff', bg: '#4a5568', }); } extra.sort((a, b) => a.label.localeCompare(b.label)); return [...BOARD_TABS, ...extra]; })(); 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); }); }, []); // Pre-tokenise the search string once per keystroke. Each token must match // somewhere in the example's haystack, so users can type "esp32 oled dht" // and find every project that hits all three. const searchTokens = search .trim() .toLowerCase() .split(/\s+/) .filter(Boolean); const exampleHaystack = (example: ExampleProject): string => [ example.title, example.description, example.category, example.difficulty, getBoardFilter(example), ...(example.tags ?? []), ...example.components.map((c) => c.type), ] .join(' ') .toLowerCase(); const filteredExamples = exampleProjects.filter((example) => { const boardMatch = selectedBoard === 'all' || (selectedBoard === 'retro' ? isRetro(example) : getBoardFilter(example) === selectedBoard); const catMatch = selectedCategory === 'all' || example.category === selectedCategory; const diffMatch = selectedDifficulty === 'all' || example.difficulty === selectedDifficulty; if (!boardMatch || !catMatch || !diffMatch) return false; if (searchTokens.length === 0) return true; const hay = exampleHaystack(example); return searchTokens.every((tok) => hay.includes(tok)); }) .sort((a, b) => { // Order by board following the boardTabs order (so Arduino Uno comes // first), then alphabetically by title within each board. const ra = boardTabs.findIndex((t) => t.id === getBoardFilter(a)); const rb = boardTabs.findIndex((t) => t.id === getBoardFilter(b)); if (ra !== rb) return (ra < 0 ? 999 : ra) - (rb < 0 ? 999 : rb); return (a.title ?? '').localeCompare(b.title ?? ''); }); // Count per board for tab badges const boardCounts: Record = { all: exampleProjects.length, retro: exampleProjects.filter(isRetro).length, }; exampleProjects.forEach((ex) => { const b = getBoardFilter(ex); boardCounts[b] = (boardCounts[b] ?? 0) + 1; }); const getCategoryIcon = (category: ExampleProject['category']): React.ReactNode => { const svgProps = { width: 16, height: 16, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round' as const, strokeLinejoin: 'round' as const, style: { display: 'inline-block', verticalAlign: 'middle', flexShrink: 0 }, }; const icons: Record = { basics: ( ), sensors: ( ), displays: ( ), communication: ( ), games: ( ), robotics: ( ), circuits: ( ), }; return icons[category]; }; const getDifficultyColor = (difficulty: ExampleProject['difficulty']): string => ({ beginner: '#4ade80', intermediate: '#fbbf24', advanced: '#f87171', })[difficulty]; const getBoardBadge = ( example: ExampleProject, ): { label: string; color: string; bg: string } | null => { const bf = getBoardFilter(example); const tab = boardTabs.find((t) => t.id === bf); if (!tab || tab.id === 'all') return null; return { label: tab.label, color: tab.color, bg: tab.bg }; }; const CATEGORIES = [ 'all', 'basics', 'sensors', 'displays', 'communication', 'games', 'robotics', 'circuits', ] as const; const DIFFICULTIES = ['all', 'beginner', 'intermediate', 'advanced'] as const; // Active filters rendered as removable chips (the compact replacement for // the old three rows of always-visible filter buttons). Each chip clears // its own filter; "Clear all" resets everything. const boardTabForChip = boardTabs.find((tb) => tb.id === selectedBoard); const activeChips: { key: string; label: string; onClear: () => void }[] = []; if (selectedBoard !== 'all' && boardTabForChip) { activeChips.push({ key: 'board', label: boardTabForChip.label, onClear: () => setSelectedBoard('all'), }); } if (selectedCategory !== 'all') { activeChips.push({ key: 'cat', label: t(`examples.filters.category.${selectedCategory}`), onClear: () => setSelectedCategory('all'), }); } if (selectedDifficulty !== 'all') { activeChips.push({ key: 'diff', label: t(`examples.filters.difficulty.${selectedDifficulty}`), onClear: () => setSelectedDifficulty('all'), }); } if (search.trim()) { activeChips.push({ key: 'search', label: `“${search.trim()}”`, onClear: () => setSearch(''), }); } const resetAll = () => { setSearch(''); setSelectedBoard('all'); setSelectedCategory('all'); setSelectedDifficulty('all'); }; return (

{t('examples.heading')}

{t('examples.subtitle')}

{/* Compact toolbar: search + three dropdowns + live count. Replaces the old search row + 16 board tabs + two filter rows. */}
setSearch(e.target.value)} aria-label={t('examples.searchLabel')} /> {search && ( )}
{t('examples.matchCount', { count: filteredExamples.length })}
{/* Active-filter chips (removable badges). */} {activeChips.length > 0 && (
{activeChips.map((chip) => ( {chip.label} ))} {activeChips.length > 1 && ( )}
)} {/* Examples Grid */}
{filteredExamples.map((example) => { const boardBadge = getBoardBadge(example); return (
onLoadExample(example)}>

{example.title}

{example.description}

{example.difficulty} {getCategoryIcon(example.category)} {example.category} {boardBadge && ( {boardBadge.label} )}
); })}
{filteredExamples.length === 0 && (

{searchTokens.length > 0 ? t('examples.emptyForQuery', { query: search.trim() }) : t('examples.empty')}

{(searchTokens.length > 0 || selectedBoard !== 'all' || selectedCategory !== 'all' || selectedDifficulty !== 'all') && ( )}
)}
); };