feat(picker): online-only COMPONENT ad cards + overlay datasheet seam

Components that only exist in the hosted editor now advertise themselves in
the picker exactly like the online-only boards do: ONLINE_ONLY_COMPONENT_ADS
(same module as the board ads) renders an ONLINE-badged card that links to
velxio.com, auto-hidden in any build whose ComponentRegistry has the real
component (the hosted overlay merges it in — no per-build switches). First
entries: the M5Stack Chain RGB/Mono 8x8 matrices. componentDocs gains
registerComponentDoc(id, raw) so an overlay can supply the hover datasheet
for components it injects at runtime.
This commit is contained in:
David Montero Crespo 2026-07-22 20:39:54 +02:00
parent c4ef03b6df
commit 9e8381e032
3 changed files with 89 additions and 1 deletions

View File

@ -35,8 +35,10 @@ import { BOARD_KIND_LABELS } from '../types/board';
import { isProBoardKind } from '../lib/proBoardGate';
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';
@ -203,6 +205,20 @@ export const ComponentPickerModal: React.FC<ComponentPickerModalProps> = ({
return components;
}, [searchQuery, selectedCategory, registry, isLoading]);
// 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)),
);
}, [registry, isLoading, searchQuery, selectedCategory]);
// Get available categories
const categories = useMemo(() => {
if (isLoading) return [];
@ -357,7 +373,7 @@ export const ComponentPickerModal: React.FC<ComponentPickerModalProps> = ({
<div className="spinner"></div>
<p>{t('editor.componentPicker.loading')}</p>
</div>
) : filteredComponents.length === 0 ? (
) : filteredComponents.length === 0 && visibleComponentAds.length === 0 ? (
<div className="no-results">
<p>{t('editor.componentPicker.noResults')}</p>
{searchQuery && (
@ -393,6 +409,10 @@ export const ComponentPickerModal: React.FC<ComponentPickerModalProps> = ({
/>
))
)}
{!isLoading &&
visibleComponentAds.map((ad) => (
<OnlineOnlyComponentCard key={ad.id} ad={ad} />
))}
</div>
</div>
@ -764,6 +784,28 @@ const OnlineBadge: React.FC = () => (
</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

View File

@ -44,6 +44,18 @@ for (const path in modules) {
const cache = new Map<string, ComponentDoc | null>();
/**
* Overlay seam: a private build (velxio.com) can register the datasheet
* markdown for a component it injects at runtime the import.meta.glob above
* only sees files committed in this tree. Same raw format as the .md files
* (optional front-matter + body). Last write wins; the parsed cache for the
* id is invalidated so a re-render picks the new doc up.
*/
export function registerComponentDoc(id: string, raw: string): void {
byId[id] = () => Promise.resolve(raw);
cache.delete(id);
}
/**
* Split an optional `---`-delimited front-matter block off the top of a
* Markdown string. Only a leading block counts, so table separators

File diff suppressed because one or more lines are too long