velxio/frontend/src/components/ComponentPickerModal.tsx

869 lines
32 KiB
TypeScript

/**
* Component Picker Modal
*
* Modal interface for searching and selecting components from the wokwi-elements library.
* Features:
* - Search bar with real-time filtering
* - Category tabs for filtering
* - Grid layout with component thumbnails
* - Click to select and add component
*/
import React, { useState, useEffect, useMemo, useRef, useSyncExternalStore } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { ComponentRegistry } from '../services/ComponentRegistry';
import type { ComponentMetadata, ComponentCategory } from '../types/component-metadata';
import { ComponentInfoPanel, HOVER_DELAY, type HoverTarget, type PanelData } from './ComponentInfoPanel';
// Grace period after the pointer leaves a card before the datasheet popover
// hides — long enough to cross the gap onto the (interactive) panel. Must be
// comfortably larger than HOVER_DELAY so re-entering a card cancels the hide
// before it fires.
const HIDE_DELAY = 220;
/** Hover controls handed to each card so it can drive the shared popover. */
interface CardHoverApi {
show: (t: HoverTarget) => void;
cancelHide: () => void;
scheduleHide: () => void;
/** Current show generation — an armed show is void once this changes. */
showGen: () => number;
}
import type { BoardKind } from '../types/board';
import { BOARD_KIND_LABELS } from '../types/board';
import { isProBoardKind } from '../lib/proBoardGate';
import {
getProBoard,
listProBoards,
subscribeProBoards,
getProBoardsVersion,
} from '../lib/proBoardRegistry';
import {
ONLINE_ONLY_BOARD_ADS,
ONLINE_ONLY_COMPONENT_ADS,
ONLINE_EDITOR_URL,
type OnlineOnlyBoardAd,
type OnlineOnlyComponentAd,
} from '../lib/onlineOnlyBoards';
import raspberryPi3Svg from '../assets/Raspberry_Pi_3_illustration.svg';
import raspberryPi4Png from '../assets/raspberry-pi-4-board.png';
import raspberryPi5Png from '../assets/raspberry-pi-5-board.png';
import { Attiny85 } from './velxio-components/Attiny85';
import './velxio-components/Esp32Element'; // registers velxio-esp32
import './velxio-components/PiPicoWElement'; // registers velxio-pi-pico-w
import './velxio-components/Stm32BluePillElement'; // registers velxio-stm32-bluepill
import './velxio-components/Ssd1306I2cElement'; // registers velxio-ssd1306-i2c-4pin
// Register every wokwi tag that the picker might try to instantiate as a
// thumbnail. The picker calls `document.createElement(tagName)`, so any tag
// that isn't already a registered custom element renders as an empty
// HTMLUnknownElement (blank card preview).
import '@wokwi/elements';
import '../velxio-elements';
import './ComponentPickerModal.css';
interface ComponentPickerModalProps {
isOpen: boolean;
onClose: () => void;
onSelectComponent: (metadata: ComponentMetadata) => void;
onSelectBoard?: (kind: BoardKind) => void;
}
const BOARD_DESCRIPTIONS: Record<BoardKind, string> = {
'arduino-uno': '8-bit AVR, 32KB flash, 14 digital I/O',
'arduino-nano': 'Compact 8-bit AVR, same as Uno',
'arduino-mega': '8-bit AVR, 256KB flash, 54 digital I/O',
'raspberry-pi-pico': 'RP2040 dual-core Cortex-M0+',
'pi-pico-w': 'RP2040 + WiFi/BT, same emulator as Pico',
'raspberry-pi-3': 'ARM64 Cortex-A53 quad-core, Linux/Python (QEMU)',
'raspberry-pi-4': 'ARM64 Cortex-A72 quad-core, Linux/Python (QEMU)',
'raspberry-pi-5': 'ARM64 Cortex-A76 quad-core + RP1 I/O, Linux/Python (QEMU)',
esp32: 'Xtensa LX6 dual-core, WiFi+BT, 38 GPIO (QEMU)',
'esp32-devkit-c-v4': 'ESP32 DevKit C V4, official Espressif (QEMU)',
'esp32-cam': 'ESP32 + 2MP camera, microSD (QEMU)',
'wemos-lolin32-lite': 'Compact ESP32, LiPo battery support (QEMU)',
'esp32-s3': 'Xtensa LX7 dual-core, WiFi+BT, AI accel (QEMU)',
'xiao-esp32-s3': 'Seeed XIAO tiny form, 8MB flash+PSRAM (QEMU)',
'arduino-nano-esp32': 'Nano form-factor, ESP32-S3, RGB LED (QEMU)',
'esp32-c3': 'RISC-V single-core, WiFi+BLE, 22 GPIO (QEMU)',
'xiao-esp32-c3': 'Seeed XIAO ESP32-C3 mini board (QEMU)',
'aitewinrobot-esp32c3-supermini': 'ESP32-C3 SuperMini (QEMU)',
'stm32-bluepill': 'STM32F103C8 Cortex-M3, 64KB flash, 37 GPIO (QEMU)',
'stm32-blackpill': 'STM32F411CE Cortex-M4, 512KB flash, 50 GPIO (QEMU)',
'stm32-bluepill-f103cb': 'STM32F103CB Cortex-M3, 128KB flash, 37 GPIO (QEMU)',
'stm32-blackpill-f401': 'STM32F401CE Cortex-M4, 512KB flash, 50 GPIO (QEMU)',
'stm32-f4-discovery': 'STM32F407VG Cortex-M4, 1MB flash, 4 onboard LEDs (QEMU)',
'stm32-olimex-h405': 'Olimex STM32-H405, F405RG Cortex-M4, 1MB flash (QEMU)',
'stm32-netduino-plus2': 'Netduino Plus 2, STM32F405 Cortex-M4 (QEMU)',
'stm32-netduino2': 'Netduino 2, STM32F205 Cortex-M3 (QEMU, serial)',
attiny85: '8-bit AVR, 8KB flash, 6 GPIO (browser)',
};
const ALL_BOARDS: BoardKind[] = [
'arduino-uno',
'arduino-nano',
'arduino-mega',
'raspberry-pi-pico',
'pi-pico-w',
'raspberry-pi-3',
'raspberry-pi-4',
'raspberry-pi-5',
'esp32',
'esp32-devkit-c-v4',
'esp32-cam',
'wemos-lolin32-lite',
'esp32-s3',
'xiao-esp32-s3',
'arduino-nano-esp32',
'esp32-c3',
'xiao-esp32-c3',
'aitewinrobot-esp32c3-supermini',
'stm32-bluepill',
'stm32-blackpill',
'stm32-bluepill-f103cb',
'stm32-blackpill-f401',
'stm32-f4-discovery',
'stm32-olimex-h405',
'stm32-netduino-plus2',
'stm32-netduino2',
'attiny85',
];
export const ComponentPickerModal: React.FC<ComponentPickerModalProps> = ({
isOpen,
onClose,
onSelectComponent,
onSelectBoard,
}) => {
const { t } = useTranslation();
const [searchQuery, setSearchQuery] = useState('');
const [selectedCategory, setSelectedCategory] = useState<ComponentCategory | 'all' | 'boards'>(
'all',
);
const [registry] = useState(() => ComponentRegistry.getInstance());
const [isLoading, setIsLoading] = useState(true);
// Floating datasheet popover shown on card hover. A single instance is
// driven from here so only one panel ever exists in the DOM. Hiding is
// DEFERRED through a grace-period timer so the pointer can travel from the
// card onto the panel (to scroll a long doc or click Buy) without it
// vanishing: the card's leave arms the hide, the panel's enter cancels it.
const [hoverTarget, setHoverTarget] = useState<HoverTarget | null>(null);
const hideTimer = useRef<number | undefined>(undefined);
// Monotonic generation stamped when a card arms its show timer. Bumping it
// invalidates any already-armed show so a grid change (scroll/filter/search)
// can't pop a panel at a now-stale card rect after the pointer's card reflows.
const showGenRef = useRef(0);
const cancelHide = () => window.clearTimeout(hideTimer.current);
const showPanel = (t: HoverTarget) => {
window.clearTimeout(hideTimer.current);
setHoverTarget(t);
};
const scheduleHide = () => {
window.clearTimeout(hideTimer.current);
hideTimer.current = window.setTimeout(() => setHoverTarget(null), HIDE_DELAY);
};
// Immediate hide — used when the grid itself changes under the pointer
// (scroll, filter, search) so a stale panel never lingers. Also invalidates
// any armed (not-yet-fired) show timer.
const clearHover = () => {
showGenRef.current++;
window.clearTimeout(hideTimer.current);
setHoverTarget(null);
};
const hoverApi: CardHoverApi = {
show: showPanel,
cancelHide,
scheduleHide,
showGen: () => showGenRef.current,
};
// Clear any pending hide timer if the modal unmounts mid-hover.
useEffect(() => () => window.clearTimeout(hideTimer.current), []);
// The modal stays mounted (parent toggles `isOpen`), so reset the popover
// when it closes — otherwise a panel left showing at close (e.g. clicking a
// card to add it, or ESC while hovering) reappears detached on reopen.
useEffect(() => {
if (!isOpen) {
showGenRef.current++;
window.clearTimeout(hideTimer.current);
setHoverTarget(null);
}
}, [isOpen]);
// Wait for registry to load
useEffect(() => {
const loadRegistry = async () => {
await registry.load();
setIsLoading(false);
};
loadRegistry();
}, [registry]);
// Filter components based on search and category
const filteredComponents = useMemo(() => {
if (isLoading) return [];
let components = searchQuery ? registry.search(searchQuery) : registry.getAllComponents();
if (selectedCategory !== 'all') {
components = components.filter((c) => c.category === selectedCategory);
}
return components;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchQuery, selectedCategory, registry, isLoading, registryVersion]);
// Late-overlay registrations must re-render an already-mounted picker:
// the @pro import is dynamic, so boards/components can register AFTER the
// first render. Without these subscriptions the memos below freeze on the
// pre-registration state (boards missing, ONLINE ads instead of the real
// components - and which one you got depended on a reload race).
const proBoardsVersion = useSyncExternalStore(
subscribeProBoards,
getProBoardsVersion,
getProBoardsVersion,
);
const registryVersion = useSyncExternalStore(
registry.subscribe,
registry.getVersion,
registry.getVersion,
);
// Boards list: static OSS kinds + overlay-registered boards (proBoardRegistry).
const allBoards = useMemo(() => {
return [...ALL_BOARDS, ...(listProBoards().map((d) => d.kind) as BoardKind[])];
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [proBoardsVersion]);
// Online-only component ads: shown where the real component would sit, and
// hidden automatically in any build whose registry has the real component
// (the hosted overlay merges it in) — same contract as VISIBLE_BOARD_ADS.
const visibleComponentAds = useMemo(() => {
if (isLoading) return [];
const q = searchQuery.toLowerCase();
return ONLINE_ONLY_COMPONENT_ADS.filter(
(ad) =>
!registry.getById(ad.id) &&
(selectedCategory === 'all' || ad.category === selectedCategory) &&
(!q || ad.label.toLowerCase().includes(q)),
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [registry, isLoading, searchQuery, selectedCategory, registryVersion, proBoardsVersion]);
// Get available categories
const categories = useMemo(() => {
if (isLoading) return [];
return registry.getCategories();
}, [registry, isLoading]);
// Handle ESC key to close modal
useEffect(() => {
const handleEsc = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
if (isOpen) {
window.addEventListener('keydown', handleEsc);
return () => window.removeEventListener('keydown', handleEsc);
}
}, [isOpen, onClose]);
if (!isOpen) return null;
// Portal to <body>: the picker must escape the canvas subtree so no ancestor
// stacking context can pin it below floating panels (e.g. the AI chat).
return createPortal(
<div className="component-picker-overlay" onClick={onClose}>
<div className="component-picker-modal" onClick={(e) => e.stopPropagation()}>
{/* Header: title + inline search + category filter + close, all on
one row to maximise the space left for the components grid. */}
<div className="modal-header">
<h2>{t('editor.componentPicker.title')}</h2>
<div className="header-search-wrapper">
<svg
className="search-icon"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<circle cx="11" cy="11" r="7" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
<input
type="text"
className="search-input"
placeholder={t('editor.componentPicker.searchPlaceholder')}
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value);
clearHover();
}}
autoFocus
/>
{searchQuery && (
<button
className="clear-search-btn"
onClick={() => setSearchQuery('')}
aria-label={t('editor.componentPicker.clearSearch')}
>
X
</button>
)}
</div>
<select
className="category-select"
value={selectedCategory}
onChange={(e) => {
setSelectedCategory(e.target.value as ComponentCategory | 'all' | 'boards');
clearHover();
}}
aria-label="Filter by category"
>
<option value="all">{t('editor.componentPicker.allComponents')}</option>
{categories
.filter((c) => c !== 'boards')
.map((category) => (
<option key={category} value={category}>
{ComponentRegistry.getCategoryDisplayName(category)}
</option>
))}
{onSelectBoard && (
<option value="boards">{t('editor.componentPicker.boards')}</option>
)}
</select>
<button className="close-btn" onClick={onClose} aria-label={t('editor.componentPicker.close')}>
X
</button>
</div>
{/* Boards Panel */}
{selectedCategory === 'boards' ? (
<div className="components-grid" onScroll={clearHover}>
{allBoards.map((kind) => (
<BoardCard
key={kind}
kind={kind}
onSelect={() => {
onSelectBoard?.(kind);
onClose();
}}
hoverApi={hoverApi}
/>
))}
{visibleBoardAds().map((ad) => (
<OnlineOnlyBoardCard key={ad.id} ad={ad} />
))}
</div>
) : (
<>
{/* Single scrollable area wrapping both the boards row (only in
"All Components" view) and the components grid, so the modal
shows ONE scrollbar instead of two stacked ones. */}
<div className="components-scroll" onScroll={clearHover}>
{selectedCategory === 'all' && onSelectBoard && (
<div
className="components-grid components-grid--inline"
style={{ borderBottom: '1px solid #333', paddingBottom: 8, marginBottom: 4 }}
>
{allBoards.filter(
(k) =>
!searchQuery ||
BOARD_KIND_LABELS[k].toLowerCase().includes(searchQuery.toLowerCase()),
).map((kind) => (
<BoardCard
key={kind}
kind={kind}
onSelect={() => {
onSelectBoard(kind);
onClose();
}}
hoverApi={hoverApi}
/>
))}
{visibleBoardAds().filter(
(ad) =>
!searchQuery || ad.label.toLowerCase().includes(searchQuery.toLowerCase()),
).map((ad) => (
<OnlineOnlyBoardCard key={ad.id} ad={ad} />
))}
</div>
)}
<div className="components-grid components-grid--inline">
{isLoading ? (
<div className="loading-state">
<div className="spinner"></div>
<p>{t('editor.componentPicker.loading')}</p>
</div>
) : filteredComponents.length === 0 && visibleComponentAds.length === 0 ? (
<div className="no-results">
<p>{t('editor.componentPicker.noResults')}</p>
{searchQuery && (
<button
className="clear-filters-btn"
onClick={() => {
setSearchQuery('');
setSelectedCategory('all');
}}
>
{t('editor.componentPicker.clearFilters')}
</button>
)}
</div>
) : (
filteredComponents.map((component) => (
<ComponentCard
key={component.id}
component={component}
hoverApi={hoverApi}
onSelect={() => {
// Pro overlays can intercept clicks on pro_only
// components by setting window.__velxio_pro_gate__.
// Returning true means "handled — do not pass through".
if (component.pro_only) {
const gate = (window as unknown as {
__velxio_pro_gate__?: (c: typeof component) => boolean;
}).__velxio_pro_gate__;
if (gate && gate(component)) return;
}
onSelectComponent(component);
}}
/>
))
)}
{!isLoading &&
visibleComponentAds.map((ad) => (
<OnlineOnlyComponentCard key={ad.id} ad={ad} />
))}
</div>
</div>
{/* Footer Info */}
<div className="modal-footer">
<span className="component-count">
{filteredComponents.length} component{filteredComponents.length !== 1 ? 's' : ''}{' '}
available
</span>
</div>
</>
)}
</div>
{/* Floating datasheet popover (portals to <body>). Keyed on the anchor
so it remounts per card and re-measures its position cleanly. */}
{hoverTarget && (
<ComponentInfoPanel
key={`${hoverTarget.data.name}-${Math.round(hoverTarget.rect.left)}-${Math.round(
hoverTarget.rect.top,
)}`}
target={hoverTarget}
onPanelEnter={cancelHide}
onPanelLeave={scheduleHide}
/>
)}
</div>,
document.body
);
};
/**
* Shared hover behaviour for the picker cards: on enter/focus cancel any
* pending hide and arm a delayed "show panel"; on leave/blur cancel that arm
* and hand off to the modal's grace-period hide (so the pointer can travel
* onto the panel). Always clears its own arm timer on unmount.
*/
function useCardHover(buildData: () => PanelData, api: CardHoverApi) {
const timer = useRef<number | undefined>(undefined);
const start = (e: React.MouseEvent | React.FocusEvent) => {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
api.cancelHide();
window.clearTimeout(timer.current);
const gen = api.showGen();
timer.current = window.setTimeout(() => {
// Voided if a grid change (clearHover) bumped the generation meanwhile.
if (api.showGen() !== gen) return;
api.show({ data: buildData(), rect });
}, HOVER_DELAY);
};
const end = () => {
window.clearTimeout(timer.current);
api.scheduleHide();
};
useEffect(() => () => window.clearTimeout(timer.current), []);
return { onMouseEnter: start, onMouseLeave: end, onFocus: start, onBlur: end };
}
/**
* Component Card - Individual component display in the grid
*/
interface ComponentCardProps {
component: ComponentMetadata;
onSelect: () => void;
hoverApi: CardHoverApi;
}
// Passive components (resistor / capacitor / inductor) come with metadata
// thumbnails that already encode the preset value (color bands for resistors,
// value labels for caps/inductors). The live wokwi elements either ignore
// `value` visually or render it identically across presets, so for these we
// short-circuit to the SVG. Everything else still uses the live element so
// LEDs, displays, etc. preview correctly.
const PASSIVE_TAGS = new Set([
'wokwi-resistor',
'wokwi-capacitor',
'velxio-capacitor-electrolytic',
'wokwi-inductor',
]);
// Static illustrations for the Pi Linux family. A live velxio-raspberry-pi-*
// custom element at natural size + CSS scale keeps its unscaled layout box,
// so the 100px thumbnail clips it to a sliver — images render fully instead.
// Keyed by tagName because the registry's Pi Zero/1/2 entries deliberately
// reuse the Pi 3 board art.
const PI_BOARD_ART: Record<string, string> = {
'velxio-raspberry-pi-3': raspberryPi3Svg,
'velxio-raspberry-pi-4': raspberryPi4Png,
'velxio-raspberry-pi-5': raspberryPi5Png,
};
/** Gold PRO pill shown on cards for paid-gated boards (Pi Linux + STM32). */
const ProBadge: React.FC = () => (
<span
title="Pro feature — paid plan or Velxio Desktop"
style={{
position: 'absolute',
top: 8,
right: 8,
zIndex: 1,
padding: '3px 10px',
borderRadius: 999,
fontSize: 11,
fontWeight: 700,
letterSpacing: 0.6,
color: '#1a1205',
background: 'linear-gradient(180deg,#ffd566,#f5a623)',
boxShadow: '0 1px 4px rgba(0,0,0,0.35)',
}}
>
PRO
</span>
);
const ComponentCard: React.FC<ComponentCardProps> = ({ component, onSelect, hoverApi }) => {
const thumbnailRef = React.useRef<HTMLDivElement>(null);
const hover = useCardHover(
() => ({
id: component.id,
name: component.name,
category: ComponentRegistry.getCategoryDisplayName(component.category),
description: component.description,
pinCount: component.pinCount,
properties: component.properties,
tags: component.tags,
thumbnail: component.thumbnail,
pro_only: component.pro_only,
}),
hoverApi,
);
// Passives short-circuit to their preset SVG (value-encoded look).
const usePresetSvg =
PASSIVE_TAGS.has(component.tagName) &&
typeof component.thumbnail === 'string' &&
component.thumbnail.trim().startsWith('<svg');
const boardArt = PI_BOARD_ART[component.tagName];
// Render actual web component as thumbnail
React.useEffect(() => {
if (!thumbnailRef.current) return;
if (usePresetSvg) return; // SVG is rendered via dangerouslySetInnerHTML below
if (boardArt) return; // static illustration rendered below
// Create the actual wokwi element
const element = document.createElement(component.tagName);
// Scale factors for different component types
let scale = 0.5;
if (component.tagName.includes('arduino') || component.tagName.includes('esp32')) {
scale = 0.35; // Boards are larger, scale them down more
} else if (component.tagName.includes('lcd') || component.tagName.includes('display')) {
scale = 0.4; // Displays need a bit more space
}
(element as HTMLElement).style.transform = `scale(${scale})`;
(element as HTMLElement).style.transformOrigin = 'center center';
// Pass the preset's defaults through so variant-sensitive elements render
// the right look in the picker — e.g. wokwi-resistor color bands (value)
// or the M5Stack Chain matrix light/dark housing (mono). Same property
// assignment DynamicComponent performs when the part is placed, so any
// element that tolerates placement tolerates the preview.
for (const [key, val] of Object.entries(component.defaultValues ?? {})) {
try {
(element as any)[key] = val;
} catch {
/* read-only prop on some upstream element — skip */
}
}
// Set default properties for better preview appearance
if (component.tagName === 'wokwi-led') {
(element as any).value = true; // Turn on LED
(element as any).color = component.defaultValues?.color || 'red';
} else if (component.tagName === 'wokwi-rgb-led') {
(element as any).red = true;
(element as any).green = true;
(element as any).blue = true;
} else if (component.tagName === 'wokwi-pushbutton') {
(element as any).color = component.defaultValues?.color || 'red';
} else if (component.tagName === 'wokwi-lcd1602' || component.tagName === 'wokwi-lcd2004') {
(element as any).text = 'Hello World!';
}
thumbnailRef.current.innerHTML = '';
thumbnailRef.current.appendChild(element);
return () => {
if (thumbnailRef.current) {
thumbnailRef.current.innerHTML = '';
}
};
}, [component.tagName, component.defaultValues, usePresetSvg, boardArt]);
return (
<button className="component-card" onClick={onSelect} style={{ position: 'relative' }} {...hover}>
{isProBoardKind(component.id) && <ProBadge />}
<div className="card-thumbnail">
{boardArt ? (
<img
src={boardArt}
alt={component.name}
style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'contain' }}
/>
) : usePresetSvg ? (
<div
className="component-preview"
dangerouslySetInnerHTML={{ __html: component.thumbnail }}
/>
) : (
<div ref={thumbnailRef} className="component-preview" />
)}
</div>
<div className="card-content">
<div className="card-name">{component.name}</div>
{component.description && <div className="card-description">{component.description}</div>}
<div className="card-meta">
<span className="card-category">{component.category}</span>
{component.pinCount > 0 && <span className="card-pins">{component.pinCount} pins</span>}
</div>
</div>
</button>
);
};
// Tag name used to render a thumbnail for each board kind.
// Boards without a tag will show a generic chip icon.
const BOARD_TAG: Partial<Record<BoardKind, string>> = {
'arduino-uno': 'wokwi-arduino-uno',
'arduino-nano': 'wokwi-arduino-nano',
'arduino-mega': 'wokwi-arduino-mega',
'raspberry-pi-pico': 'wokwi-nano-rp2040-connect',
'pi-pico-w': 'velxio-pi-pico-w',
esp32: 'velxio-esp32',
'esp32-devkit-c-v4': 'velxio-esp32',
'esp32-cam': 'velxio-esp32',
'wemos-lolin32-lite': 'velxio-esp32',
'esp32-s3': 'velxio-esp32',
'xiao-esp32-s3': 'velxio-esp32',
'arduino-nano-esp32': 'velxio-esp32',
'esp32-c3': 'velxio-esp32',
'xiao-esp32-c3': 'velxio-esp32',
'aitewinrobot-esp32c3-supermini': 'velxio-esp32',
'stm32-bluepill': 'velxio-stm32-bluepill',
'stm32-blackpill': 'velxio-stm32-blackpill',
'stm32-bluepill-f103cb': 'velxio-stm32-bluepill-f103cb',
'stm32-blackpill-f401': 'velxio-stm32-blackpill-f401',
'stm32-f4-discovery': 'velxio-stm32-f4-discovery',
'stm32-olimex-h405': 'velxio-stm32-olimex-h405',
'stm32-netduino-plus2': 'velxio-stm32-netduino-plus2',
'stm32-netduino2': 'velxio-stm32-netduino2',
};
interface BoardCardProps {
kind: BoardKind;
onSelect: () => void;
hoverApi: CardHoverApi;
}
const BoardCard: React.FC<BoardCardProps> = ({ kind, onSelect, hoverApi }) => {
const thumbnailRef = React.useRef<HTMLDivElement>(null);
const hover = useCardHover(
() => ({
id: kind,
name: BOARD_KIND_LABELS[kind],
category: 'Boards',
description: BOARD_DESCRIPTIONS[kind] ?? getProBoard(kind)?.description ?? '',
pinCount: 0,
properties: [],
tags: [],
pro_only: isProBoardKind(kind),
}),
hoverApi,
);
React.useEffect(() => {
if (!thumbnailRef.current) return;
// Static-image boards handled below via reactThumbnail: the whole Pi
// Linux family uses board illustrations (a live custom element at
// natural size + CSS scale keeps its unscaled layout box, so the
// 100px thumbnail clips it to a narrow sliver).
if (
kind === 'raspberry-pi-3' ||
kind === 'raspberry-pi-4' ||
kind === 'raspberry-pi-5' ||
kind === 'attiny85'
)
return;
const tag = BOARD_TAG[kind] ?? getProBoard(kind)?.tag;
if (!tag) return;
const el = document.createElement(tag) as HTMLElement;
// Use setAttribute so observedAttributes + connectedCallback read the correct value
el.setAttribute('board-kind', kind);
el.style.transform = 'scale(0.28)';
el.style.transformOrigin = 'center center';
thumbnailRef.current.innerHTML = '';
thumbnailRef.current.appendChild(el);
return () => {
if (thumbnailRef.current) thumbnailRef.current.innerHTML = '';
};
}, [kind]);
const reactThumbnail =
kind === 'raspberry-pi-3' ? (
<img
src={raspberryPi3Svg}
alt="Raspberry Pi 3"
style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'contain' }}
/>
) : kind === 'raspberry-pi-4' ? (
<img
src={raspberryPi4Png}
alt="Raspberry Pi 4"
style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'contain' }}
/>
) : kind === 'raspberry-pi-5' ? (
<img
src={raspberryPi5Png}
alt="Raspberry Pi 5"
style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'contain' }}
/>
) : kind === 'attiny85' ? (
<div style={{ transform: 'scale(0.55)', transformOrigin: 'center center' }}>
<Attiny85 />
</div>
) : null;
return (
<button
className="component-card"
onClick={onSelect}
style={{ position: 'relative' }}
{...hover}
>
{isProBoardKind(kind) && <ProBadge />}
<div className="card-thumbnail">
{reactThumbnail ? reactThumbnail : <div ref={thumbnailRef} className="component-preview" />}
</div>
<div className="card-content">
<div className="card-name">{BOARD_KIND_LABELS[kind]}</div>
<div className="card-description">{BOARD_DESCRIPTIONS[kind] ?? getProBoard(kind)?.description}</div>
</div>
</button>
);
};
// ── Online-only board ads ───────────────────────────────────────────────────
// Boards implemented by the hosted editor (velxio.com), free to use there.
// Hidden automatically in any build that registers the real BoardKind.
/** Recomputed on access (not module load): overlay board registration patches
* BOARD_KIND_LABELS at mount, which must hide the corresponding ad. */
const visibleBoardAds = () => ONLINE_ONLY_BOARD_ADS.filter((ad) => !(ad.id in BOARD_KIND_LABELS));
/** Teal "ONLINE" pill: the board runs (free) in the hosted editor. */
const OnlineBadge: React.FC = () => (
<span
title="Free in the online editor — velxio.com"
style={{
position: 'absolute',
top: 8,
right: 8,
zIndex: 1,
padding: '3px 10px',
borderRadius: 999,
fontSize: 11,
fontWeight: 700,
letterSpacing: 0.6,
color: '#04211c',
background: 'linear-gradient(180deg,#5eead4,#14b8a6)',
boxShadow: '0 1px 4px rgba(0,0,0,0.35)',
}}
>
ONLINE
</span>
);
/** Advertisement card for a component only available in the hosted editor. */
const OnlineOnlyComponentCard: React.FC<{ ad: OnlineOnlyComponentAd }> = ({ ad }) => (
<button
className="component-card"
style={{ position: 'relative' }}
title={`${ad.label} — available in the online editor at velxio.com`}
onClick={() => window.open(ONLINE_EDITOR_URL, '_blank', 'noopener')}
>
<OnlineBadge />
<div className="card-thumbnail">
<div
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}
dangerouslySetInnerHTML={{ __html: ad.thumbnailSvg }}
/>
</div>
<div className="card-content">
<div className="card-name">{ad.label}</div>
<div className="card-description">{ad.description}</div>
</div>
</button>
);
/** Advertisement card for a board only available in the hosted editor. */
const OnlineOnlyBoardCard: React.FC<{ ad: OnlineOnlyBoardAd }> = ({ ad }) => (
<button
className="component-card"
style={{ position: 'relative' }}
title={`${ad.label} — free to use in the online editor at velxio.com`}
onClick={() => window.open(ONLINE_EDITOR_URL, '_blank', 'noopener')}
>
<OnlineBadge />
<div className="card-thumbnail">
<div
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}
dangerouslySetInnerHTML={{ __html: ad.thumbnailSvg }}
/>
</div>
<div className="card-content">
<div className="card-name">{ad.label}</div>
<div className="card-description">{ad.description}</div>
</div>
</button>
);