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:
parent
66268507c8
commit
16c3089c5f
|
|
@ -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]})` : ''}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -391,7 +391,8 @@
|
|||
},
|
||||
"inspector": {
|
||||
"propertiesTab": "Eigenschaften",
|
||||
"datasheetTab": "Datenblatt"
|
||||
"datasheetTab": "Datenblatt",
|
||||
"examplesTitle": "Beispielprojekte"
|
||||
}
|
||||
},
|
||||
"about": {
|
||||
|
|
|
|||
|
|
@ -393,7 +393,8 @@
|
|||
},
|
||||
"inspector": {
|
||||
"propertiesTab": "Properties",
|
||||
"datasheetTab": "Datasheet"
|
||||
"datasheetTab": "Datasheet",
|
||||
"examplesTitle": "Example projects"
|
||||
}
|
||||
},
|
||||
"about": {
|
||||
|
|
|
|||
|
|
@ -393,7 +393,8 @@
|
|||
},
|
||||
"inspector": {
|
||||
"propertiesTab": "Propiedades",
|
||||
"datasheetTab": "Ficha técnica"
|
||||
"datasheetTab": "Ficha técnica",
|
||||
"examplesTitle": "Proyectos de ejemplo"
|
||||
}
|
||||
},
|
||||
"about": {
|
||||
|
|
|
|||
|
|
@ -391,7 +391,8 @@
|
|||
},
|
||||
"inspector": {
|
||||
"propertiesTab": "Propriétés",
|
||||
"datasheetTab": "Fiche technique"
|
||||
"datasheetTab": "Fiche technique",
|
||||
"examplesTitle": "Projets d’exemple"
|
||||
}
|
||||
},
|
||||
"about": {
|
||||
|
|
|
|||
|
|
@ -391,7 +391,8 @@
|
|||
},
|
||||
"inspector": {
|
||||
"propertiesTab": "Proprietà",
|
||||
"datasheetTab": "Scheda tecnica"
|
||||
"datasheetTab": "Scheda tecnica",
|
||||
"examplesTitle": "Progetti di esempio"
|
||||
}
|
||||
},
|
||||
"about": {
|
||||
|
|
|
|||
|
|
@ -391,7 +391,8 @@
|
|||
},
|
||||
"inspector": {
|
||||
"propertiesTab": "プロパティ",
|
||||
"datasheetTab": "データシート"
|
||||
"datasheetTab": "データシート",
|
||||
"examplesTitle": "サンプルプロジェクト"
|
||||
}
|
||||
},
|
||||
"about": {
|
||||
|
|
|
|||
|
|
@ -391,7 +391,8 @@
|
|||
},
|
||||
"inspector": {
|
||||
"propertiesTab": "Propriedades",
|
||||
"datasheetTab": "Ficha técnica"
|
||||
"datasheetTab": "Ficha técnica",
|
||||
"examplesTitle": "Projetos de exemplo"
|
||||
}
|
||||
},
|
||||
"about": {
|
||||
|
|
|
|||
|
|
@ -391,7 +391,8 @@
|
|||
},
|
||||
"inspector": {
|
||||
"propertiesTab": "Свойства",
|
||||
"datasheetTab": "Спецификация"
|
||||
"datasheetTab": "Спецификация",
|
||||
"examplesTitle": "Примеры проектов"
|
||||
}
|
||||
},
|
||||
"about": {
|
||||
|
|
|
|||
|
|
@ -391,7 +391,8 @@
|
|||
},
|
||||
"inspector": {
|
||||
"propertiesTab": "属性",
|
||||
"datasheetTab": "数据手册"
|
||||
"datasheetTab": "数据手册",
|
||||
"examplesTitle": "示例项目"
|
||||
}
|
||||
},
|
||||
"about": {
|
||||
|
|
|
|||
Loading…
Reference in New Issue