feat(inspector+gallery): example links in the Properties tab; dynamic board tabs in /examples

The part inspector (right-click dialog) now lists the gallery examples that
use the part at the bottom of the Properties tab — matched by metadata id
against boardType/boardFilter/boards[].boardKind/components[].type, so it
works for components and boards alike, including overlay-registered ones
(re-derived via the pro-examples version subscription). Each link opens the
live editor (/example/:id) in a new tab. i18n key in all 9 locales.

The /examples board filter combo was a fixed list, so examples for
runtime-registered boards (XIAO, M5Stack, Raspberry Pi, ...) were reachable
only under "All". The gallery now appends a tab per board kind found in the
registered examples, labelled from BOARD_KIND_LABELS.
This commit is contained in:
David Montero Crespo 2026-08-07 06:00:40 +02:00
parent 66268507c8
commit 16c3089c5f
12 changed files with 170 additions and 18 deletions

View File

@ -4,9 +4,16 @@
* Displays a gallery of example Arduino projects that users can load and run
*/
import React, { useState, useCallback } from 'react';
import React, { useState, useCallback, useSyncExternalStore } from 'react';
import { useTranslation } from 'react-i18next';
import { exampleProjects, type ExampleProject } from '../../data/examples';
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';
@ -72,6 +79,34 @@ export const ExamplesGallery: React.FC<ExamplesGalleryProps> = ({ onLoadExample
const [copiedId, setCopiedId] = useState<string | null>(null);
const [search, setSearch] = useState<string>('');
// 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);
useSyncExternalStore(subscribeProBoards, 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<string, string>)[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}`;
@ -115,10 +150,10 @@ export const ExamplesGallery: React.FC<ExamplesGalleryProps> = ({ onLoadExample
return searchTokens.every((tok) => hay.includes(tok));
})
.sort((a, b) => {
// Order by board following the BOARD_TABS order (so Arduino Uno comes
// Order by board following the boardTabs order (so Arduino Uno comes
// first), then alphabetically by title within each board.
const ra = BOARD_TABS.findIndex((t) => t.id === getBoardFilter(a));
const rb = BOARD_TABS.findIndex((t) => t.id === getBoardFilter(b));
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 ?? '');
});
@ -216,7 +251,7 @@ export const ExamplesGallery: React.FC<ExamplesGalleryProps> = ({ onLoadExample
example: ExampleProject,
): { label: string; color: string; bg: string } | null => {
const bf = getBoardFilter(example);
const tab = BOARD_TABS.find((t) => t.id === bf);
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 };
};
@ -236,7 +271,7 @@ export const ExamplesGallery: React.FC<ExamplesGalleryProps> = ({ onLoadExample
// 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 = BOARD_TABS.find((tb) => tb.id === selectedBoard);
const boardTabForChip = boardTabs.find((tb) => tb.id === selectedBoard);
const activeChips: { key: string; label: string; onClear: () => void }[] = [];
if (selectedBoard !== 'all' && boardTabForChip) {
activeChips.push({
@ -326,7 +361,7 @@ export const ExamplesGallery: React.FC<ExamplesGalleryProps> = ({ onLoadExample
onChange={(e) => setSelectedBoard(e.target.value)}
aria-label={t('examples.filters.boardLabel', 'Board')}
>
{BOARD_TABS.map((tab) => (
{boardTabs.map((tab) => (
<option key={tab.id} value={tab.id}>
{tab.id === 'all' ? t('examples.filters.allBoards', 'All boards') : tab.label}
{boardCounts[tab.id] != null ? ` (${boardCounts[tab.id]})` : ''}

View File

@ -489,6 +489,45 @@
padding-top: 6px;
}
/* ── Gallery examples that use this part (bottom of the Properties tab) ── */
.pid-examples {
margin-top: 10px;
border-top: 1px dashed #444;
padding-top: 8px;
display: flex;
flex-direction: column;
gap: 4px;
}
.pid-examples-title {
color: #999;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.04em;
margin-bottom: 2px;
}
.pid-example-link {
display: inline-flex;
align-items: center;
gap: 6px;
color: #7ab8f5;
font-size: 12px;
text-decoration: none;
line-height: 1.5;
}
.pid-example-link svg {
flex: none;
opacity: 0.7;
}
.pid-example-link:hover {
color: #a5d0ff;
text-decoration: underline;
}
/* ── Mobile: full sheet, columns stack ───────────────────────────────── */
@media (max-width: 560px) {

View File

@ -20,10 +20,17 @@
* undo, colors and running-state gating.
*/
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
import React, { useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import type { ComponentMetadata } from '../../types/component-metadata';
import {
exampleProjects,
subscribeProExamples,
getProExamplesVersion,
type ExampleProject,
} from '../../data/examples';
import { stripBrandPrefix } from '../../utils/exampleToBuildNetlistInput';
import { SdCardPanel } from './SdCardPanel';
import type { UploadedSdFile } from '../../utils/sdCardFiles';
import { useSimulatorStore } from '../../store/useSimulatorStore';
@ -64,6 +71,24 @@ function displayCategory(cat: string): string {
return cat ? cat.charAt(0).toUpperCase() + cat.slice(1) : cat;
}
/**
* Gallery examples that actually use this part. The id doubles as both keys
* the gallery uses: a component's metadata id (examples reference it in
* components[].type, brand prefix stripped) and a board's kind (boardType /
* boardFilter / boards[].boardKind) — the board inspector passes boardKind as
* metadata.id, so one lookup serves both dialogs.
*/
function examplesForPart(id: string): ExampleProject[] {
return exampleProjects.filter(
(ex) =>
ex.boardType === id ||
ex.boardFilter === id ||
ex.boards?.some((b) => b.boardKind === id) ||
// Overlay-registered examples are cast in, so components may be absent.
(ex.components ?? []).some((c) => c.type === id || stripBrandPrefix(c.type) === id),
);
}
/**
* An action shown in the left column instead of Rotate. Boards do not rotate;
* they carry Board Options / Flash instead, and the canvas owns what those do.
@ -141,6 +166,12 @@ export const PartInspectorDialog: React.FC<PartInspectorDialogProps> = ({
const doc = useComponentDoc(componentMetadata.id);
// Pro examples register asynchronously (dynamic overlay import): the
// version subscription re-derives the list the moment they land, so a
// dialog opened early does not miss them.
useSyncExternalStore(subscribeProExamples, getProExamplesVersion);
const partExamples = examplesForPart(componentMetadata.id);
// ── Keyboard binding capture (unchanged from the old dialog) ─────────────
const [capturingKey, setCapturingKey] = useState(false);
const allComponents = useSimulatorStore((s) => s.components);
@ -656,6 +687,44 @@ export const PartInspectorDialog: React.FC<PartInspectorDialogProps> = ({
))}
</div>
)}
{/* Gallery examples that use this part — each opens the live
editor (/example/:id) in a new tab, so the running project
is never disturbed. */}
{partExamples.length > 0 && (
<div className="pid-examples">
<div className="pid-examples-title">
{t('editor.inspector.examplesTitle')}
</div>
{partExamples.slice(0, 8).map((ex) => (
<a
key={ex.id}
className="pid-example-link"
href={`/example/${ex.id}`}
target="_blank"
rel="noopener noreferrer"
title={ex.description}
>
<svg
width="11"
height="11"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
<polyline points="15 3 21 3 21 9" />
<line x1="10" y1="14" x2="21" y2="3" />
</svg>
<span>{ex.title}</span>
</a>
))}
</div>
)}
</div>
) : (
<div className="pid-tab-body pid-tab-body--doc">

View File

@ -391,7 +391,8 @@
},
"inspector": {
"propertiesTab": "Eigenschaften",
"datasheetTab": "Datenblatt"
"datasheetTab": "Datenblatt",
"examplesTitle": "Beispielprojekte"
}
},
"about": {

View File

@ -393,7 +393,8 @@
},
"inspector": {
"propertiesTab": "Properties",
"datasheetTab": "Datasheet"
"datasheetTab": "Datasheet",
"examplesTitle": "Example projects"
}
},
"about": {

View File

@ -393,7 +393,8 @@
},
"inspector": {
"propertiesTab": "Propiedades",
"datasheetTab": "Ficha técnica"
"datasheetTab": "Ficha técnica",
"examplesTitle": "Proyectos de ejemplo"
}
},
"about": {

View File

@ -391,7 +391,8 @@
},
"inspector": {
"propertiesTab": "Propriétés",
"datasheetTab": "Fiche technique"
"datasheetTab": "Fiche technique",
"examplesTitle": "Projets d’exemple"
}
},
"about": {

View File

@ -391,7 +391,8 @@
},
"inspector": {
"propertiesTab": "Proprietà",
"datasheetTab": "Scheda tecnica"
"datasheetTab": "Scheda tecnica",
"examplesTitle": "Progetti di esempio"
}
},
"about": {

View File

@ -391,7 +391,8 @@
},
"inspector": {
"propertiesTab": "プロパティ",
"datasheetTab": "データシート"
"datasheetTab": "データシート",
"examplesTitle": "サンプルプロジェクト"
}
},
"about": {

View File

@ -391,7 +391,8 @@
},
"inspector": {
"propertiesTab": "Propriedades",
"datasheetTab": "Ficha técnica"
"datasheetTab": "Ficha técnica",
"examplesTitle": "Projetos de exemplo"
}
},
"about": {

View File

@ -391,7 +391,8 @@
},
"inspector": {
"propertiesTab": "Свойства",
"datasheetTab": "Спецификация"
"datasheetTab": "Спецификация",
"examplesTitle": "Примеры проектов"
}
},
"about": {

View File

@ -391,7 +391,8 @@
},
"inspector": {
"propertiesTab": "属性",
"datasheetTab": "数据手册"
"datasheetTab": "数据手册",
"examplesTitle": "示例项目"
}
},
"about": {