import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useI18n } from '../i18n'; import { localizeSkillDescription, localizeSkillName, localizeSkillPrompt, } from '../i18n/content'; import type { Dict } from '../i18n/types'; import { fetchSkillExample } from '../providers/registry'; import { exportAsHtml, exportAsPdf, exportAsZip } from '../runtime/exports'; import { buildSrcdoc } from '../runtime/srcdoc'; import type { SkillSummary, Surface } from '../types'; import { Icon } from './Icon'; import { PreviewModal } from './PreviewModal'; import { AnimatePresence } from 'motion/react'; type TranslateFn = (key: keyof Dict, vars?: Record) => string; interface Props { skills: SkillSummary[]; onUsePrompt: (skill: SkillSummary) => void; } type ModeFilter = | 'all' | 'prototype-desktop' | 'prototype-mobile' | 'deck' | 'document' | 'orbit' | 'live'; type SurfaceFilter = 'all' | Surface; type ScenarioFilter = string; const SURFACE_PILLS: { value: SurfaceFilter; labelKey: keyof Dict }[] = [ { value: 'all', labelKey: 'examples.modeAll' }, { value: 'web', labelKey: 'examples.surfaceWeb' }, { value: 'image', labelKey: 'examples.surfaceImage' }, { value: 'video', labelKey: 'examples.surfaceVideo' }, { value: 'audio', labelKey: 'examples.surfaceAudio' }, ]; const MODE_PILLS: { value: ModeFilter; labelKey: keyof Dict }[] = [ { value: 'all', labelKey: 'examples.modeAll' }, { value: 'prototype-desktop', labelKey: 'examples.modePrototypeDesktop' }, { value: 'prototype-mobile', labelKey: 'examples.modePrototypeMobile' }, { value: 'deck', labelKey: 'examples.modeDeck' }, { value: 'document', labelKey: 'examples.modeDocument' }, { value: 'orbit', labelKey: 'examples.modeOrbit' }, { value: 'live', labelKey: 'examples.modeLive' }, ]; const SCENARIO_LABEL_KEY: Record = { general: 'examples.scenarioGeneral', engineering: 'examples.scenarioEngineering', product: 'examples.scenarioProduct', design: 'examples.scenarioDesign', marketing: 'examples.scenarioMarketing', sales: 'examples.scenarioSales', finance: 'examples.scenarioFinance', hr: 'examples.scenarioHr', operations: 'examples.scenarioOperations', support: 'examples.scenarioSupport', legal: 'examples.scenarioLegal', education: 'examples.scenarioEducation', personal: 'examples.scenarioPersonal', }; function scenarioLabel(t: TranslateFn, tag: string): string { const key = SCENARIO_LABEL_KEY[tag]; if (key) return t(key); return tag.charAt(0).toUpperCase() + tag.slice(1); } const SCENARIO_ORDER = [ 'engineering', 'product', 'design', 'marketing', 'sales', 'finance', 'hr', 'operations', 'support', 'legal', 'education', 'personal', 'general', ]; function matchesMode(skill: SkillSummary, filter: ModeFilter): boolean { if (filter === 'all') return true; if (filter === 'deck') return skill.mode === 'deck'; if (filter === 'prototype-desktop') return skill.mode === 'prototype' && (skill.platform ?? 'desktop') === 'desktop'; if (filter === 'prototype-mobile') return skill.mode === 'prototype' && skill.platform === 'mobile'; if (filter === 'document') return skill.mode === 'template'; if (filter === 'orbit') return skill.scenario === 'orbit'; // Live artifacts ride on the prototype mode but want their own bucket so // refreshable / connector-backed samples are easy to find without // scrolling through every desktop prototype. The parent live-artifact // skill and every derived `live-artifact:` card share the // `live` scenario, so they all light up here together. if (filter === 'live') return skill.scenario === 'live'; return true; } function surfaceOf(skill: SkillSummary): Surface { if (skill.surface) return skill.surface; if (skill.mode === 'image' || skill.mode === 'video' || skill.mode === 'audio') return skill.mode; return 'web'; } function matchesSurface(skill: SkillSummary, filter: SurfaceFilter): boolean { return filter === 'all' || surfaceOf(skill) === filter; } function quotePrompt(locale: string, text: string): string { return locale === 'de' ? `„${text}“` : `“${text}”`; } export function ExamplesTab({ skills: rawSkills, onUsePrompt }: Props) { const { locale, t } = useI18n(); // Skills tagged `aggregatesExamples: true` are containers whose preview // would just duplicate one of their derived `:` cards // (e.g. live-artifact ships a sample gallery under `examples/`). Drop // them up front so every count, filter, and rendered card downstream // sees only the user-facing entries. The full listing is still passed // through for `findSkillById` lookups elsewhere in the app. // Deduplicate by skill.id to prevent duplicate cards (issue #2889). const skills = useMemo(() => { const filtered = rawSkills.filter((s) => !s.aggregatesExamples); const seen = new Map(); for (const skill of filtered) { if (!seen.has(skill.id)) { seen.set(skill.id, skill); } } return Array.from(seen.values()); }, [rawSkills]); // Hold preview HTML per skill across re-renders so cards never re-flicker. const [previews, setPreviews] = useState>({}); // Track per-skill fetch failures separately so the preview modal can show // an actionable error / retry state instead of staying stuck at "loading". // Issue #860. const [previewErrors, setPreviewErrors] = useState>({}); // Track per-skill "no shipped preview" results separately from errors so // the modal can render a calm placeholder for skills whose // `od.preview.type` isn't `html` (image / markdown / …) without the // generic "Couldn't load this example." copy. Value is the raw preview // kind so future copy can specialise per-kind. Issue #897. const [previewUnavailable, setPreviewUnavailable] = useState< Record >({}); // Synchronous in-flight set: state updates are batched, so two parallel // loadPreview calls (e.g. card hover firing simultaneously with modal // open) could both pass the "is anything cached?" check before either // setState landed. The ref check happens before any await so the second // caller sees the first one already running and exits early. const inFlightRef = useRef>(new Set()); const [surfaceFilter, setSurfaceFilter] = useState('all'); const [modeFilter, setModeFilter] = useState('all'); const [scenarioFilter, setScenarioFilter] = useState('all'); // Free-text search filters by skill name + description + prompt so users // can find a known example by typing any associated word ("airbnb", // "wireframe", "deck") without having to click through filter pills first. const [search, setSearch] = useState(''); const [previewSkillId, setPreviewSkillId] = useState(null); const loadPreview = useCallback( async (id: string) => { // Race guard: synchronous check before any state read so two parallel // calls (hover + modal open) cannot both fall through. if (inFlightRef.current.has(id)) return; // Skip the fetch when we already hold a terminal result for this // skill. A prior error must not short-circuit (we want Retry); a // prior successful html or "no shipped preview" verdict can — the // verdict is metadata-driven and won't change between renders. if ( previews[id] !== undefined && previewErrors[id] === undefined ) return; if (previewUnavailable[id] !== undefined) return; const skill = rawSkills.find((s) => s.id === id); const previewType = skill?.previewType ?? 'html'; inFlightRef.current.add(id); try { // Reset all three branches before firing so a retry from the // error UI immediately swaps to "loading" instead of flashing // the old error / unavailable state. setPreviewErrors((prev) => { if (prev[id] === undefined) return prev; const next = { ...prev }; delete next[id]; return next; }); setPreviewUnavailable((prev) => { if (prev[id] === undefined) return prev; const next = { ...prev }; delete next[id]; return next; }); setPreviews((prev) => ({ ...prev, [id]: null })); const result = await fetchSkillExample(id, previewType); if ('html' in result) { setPreviews((prev) => ({ ...prev, [id]: result.html })); } else if ('unavailable' in result) { setPreviewUnavailable((prev) => ({ ...prev, [id]: result.kind })); setPreviews((prev) => { if (prev[id] === undefined) return prev; const next = { ...prev }; delete next[id]; return next; }); } else { setPreviewErrors((prev) => ({ ...prev, [id]: result.error })); setPreviews((prev) => { if (prev[id] === undefined) return prev; const next = { ...prev }; delete next[id]; return next; }); } } finally { inFlightRef.current.delete(id); } }, [previews, previewErrors, previewUnavailable, rawSkills], ); // Keep a ref to the latest loadPreview so the onView handler passed to // PreviewModal can have a stable identity. Without this, the inline // `() => loadPreview(...)` arrow rebuilds on every state change and // PreviewModal's `useEffect(() => onView?.(activeId), [activeId, onView])` // re-fires on each render, turning a persistent fetch failure into an // automatic retry loop that flashes past the error UI. const loadPreviewRef = useRef(loadPreview); useEffect(() => { loadPreviewRef.current = loadPreview; }, [loadPreview]); // Mirror the active skill id into a ref so onPreviewView can fetch the // selected skill instead of the modal's internal view id. PreviewModal // calls onView(activeId), where activeId is the modal-local view id // ('preview' in this component); forwarding that id straight into // fetchSkillExample would request /api/skills/preview/example instead // of the user's selected skill, leaving Retry unable to recover. const activeSkillIdRef = useRef(null); useEffect(() => { activeSkillIdRef.current = previewSkillId; }, [previewSkillId]); const onPreviewView = useCallback(() => { const skillId = activeSkillIdRef.current; if (skillId !== null) void loadPreviewRef.current(skillId); }, []); // Open the modal for a card. We always trigger a preview fetch even if // the card hasn't been hovered yet — the modal needs the HTML. const openPreview = useCallback( (id: string) => { setPreviewSkillId(id); void loadPreview(id); }, [loadPreview], ); const previewSkill = useMemo( () => (previewSkillId ? skills.find((s) => s.id === previewSkillId) ?? null : null), [skills, previewSkillId], ); const modeCounts = useMemo(() => { const surfaceScoped = skills.filter((skill) => matchesSurface(skill, surfaceFilter)); const c: Record = { all: surfaceScoped.length, 'prototype-desktop': 0, 'prototype-mobile': 0, deck: 0, document: 0, orbit: 0, live: 0, }; for (const s of surfaceScoped) { if (matchesMode(s, 'prototype-desktop')) c['prototype-desktop']++; if (matchesMode(s, 'prototype-mobile')) c['prototype-mobile']++; if (matchesMode(s, 'deck')) c.deck++; if (matchesMode(s, 'document')) c.document++; if (matchesMode(s, 'orbit')) c.orbit++; if (matchesMode(s, 'live')) c.live++; } return c; }, [skills, surfaceFilter]); const surfaceCounts = useMemo(() => { const counts: Record = { all: skills.length, web: 0, image: 0, video: 0, audio: 0 }; for (const s of skills) counts[surfaceOf(s)]++; return counts; }, [skills]); const scenarioCounts = useMemo(() => { const counts = new Map(); for (const s of skills) { if (!matchesSurface(s, surfaceFilter) || !matchesMode(s, modeFilter)) continue; const tag = s.scenario || 'general'; counts.set(tag, (counts.get(tag) ?? 0) + 1); } return counts; }, [skills, surfaceFilter, modeFilter]); const scenarioOptions = useMemo(() => { const have = new Set(scenarioCounts.keys()); const ordered: string[] = []; for (const k of SCENARIO_ORDER) if (have.has(k)) ordered.push(k); for (const k of [...have].sort()) if (!ordered.includes(k)) ordered.push(k); return ordered; }, [scenarioCounts]); const scenarioAllCount = useMemo( () => [...scenarioCounts.values()].reduce((total, count) => total + count, 0), [scenarioCounts], ); const filtered = useMemo(() => { const q = search.trim().toLowerCase(); const matched = skills.filter((s) => { if (!matchesSurface(s, surfaceFilter) || !matchesMode(s, modeFilter)) return false; if (scenarioFilter !== 'all' && (s.scenario || 'general') !== scenarioFilter) return false; if (!q) return true; const name = localizeSkillName(locale, s); const desc = localizeSkillDescription(locale, s); const prompt = localizeSkillPrompt(locale, s) || ''; const haystack = `${name} ${s.name} ${desc} ${prompt} ${s.scenario ?? ''}`.toLowerCase(); return haystack.includes(q); }); // Featured magazine-style examples float to the top (lower priority // number wins). Non-featured skills keep their server-side order so // contributors can still author SKILL.md alphabetically. return matched .map((s, idx) => ({ s, idx })) .sort((a, b) => { const aRank = typeof a.s.featured === 'number' ? a.s.featured : Number.POSITIVE_INFINITY; const bRank = typeof b.s.featured === 'number' ? b.s.featured : Number.POSITIVE_INFINITY; if (aRank !== bRank) return aRank - bRank; return a.idx - b.idx; }) .map(({ s }) => s); }, [skills, surfaceFilter, modeFilter, scenarioFilter, search, locale]); if (skills.length === 0) { return
{t('examples.emptyNoSkills')}
; } return (
setSearch(e.target.value)} placeholder={t('examples.searchPlaceholder')} aria-label={t('examples.searchAria')} />
{t('examples.surfaceLabel')} {SURFACE_PILLS.map((p) => ( ))}
{t('examples.typeLabel')} {MODE_PILLS.map((p) => ( ))}
{scenarioOptions.length > 1 ? (
{t('examples.scenarioLabel')} {scenarioOptions.map((tag) => ( ))}
) : null}
{filtered.length === 0 ? (
{t('examples.emptyNoMatch')}
) : (
{filtered.map((skill) => ( void loadPreview(skill.id)} onUsePrompt={() => onUsePrompt(skill)} onOpenPreview={() => openPreview(skill.id)} /> ))}
)} {(() => { if (!previewSkill) return null; const unavailableKind = previewUnavailable[previewSkill.id]; return ( localizeSkillName(locale, previewSkill)} onClose={() => setPreviewSkillId(null)} /> ); })()}
); } function ExampleCard({ skill, html, unavailableKind, onLoad, onUsePrompt, onOpenPreview, }: { skill: SkillSummary; html: string | null | undefined; // When set, the card iframe stays empty and the placeholder copy // explains there's no shipped HTML preview for this skill (the // `od.preview.type` is image / markdown / …) — the user gets a // Use-this-prompt CTA instead of a loading shimmer that never // resolves. Issue #897. unavailableKind?: string | undefined; onLoad: () => void; onUsePrompt: () => void; onOpenPreview: () => void; }) { const { locale, t } = useI18n(); const [hovered, setHovered] = useState(false); const [shareOpen, setShareOpen] = useState(false); const [intersected, setIntersected] = useState(false); const shareRef = useRef(null); const cardRef = useRef(null); // Eagerly request the preview HTML once the card scrolls near the viewport. // The 800px bottom rootMargin prefetches cards that are about to be // scrolled into view so the iframe is ready by the time the user reaches // it. Hover (below) is kept as a fallback for environments that lack // IntersectionObserver or for cards already visible on first paint that // somehow miss the initial observation. useEffect(() => { if (intersected) return; const node = cardRef.current; if (!node) return; if (typeof IntersectionObserver === 'undefined') { setIntersected(true); onLoad(); return; } const obs = new IntersectionObserver( (entries) => { for (const entry of entries) { if (entry.isIntersecting) { setIntersected(true); onLoad(); obs.disconnect(); break; } } }, { rootMargin: '0px 0px 800px 0px' }, ); obs.observe(node); return () => obs.disconnect(); }, [intersected, onLoad]); useEffect(() => { if (!shareOpen) return; const onDoc = (e: MouseEvent) => { if (!shareRef.current) return; if (!shareRef.current.contains(e.target as Node)) setShareOpen(false); }; const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setShareOpen(false); }; document.addEventListener('mousedown', onDoc); document.addEventListener('keydown', onKey); return () => { document.removeEventListener('mousedown', onDoc); document.removeEventListener('keydown', onKey); }; }, [shareOpen]); const displayName = localizeSkillName(locale, skill); const exportTitle = displayName; const isMobile = skill.platform === 'mobile'; const isDeck = skill.mode === 'deck'; const displayPrompt = localizeSkillPrompt(locale, skill); const displayDescription = localizeSkillDescription(locale, skill).slice(0, 240); return (
{ setHovered(true); onLoad(); }} onMouseLeave={() => setHovered(false)} >
{ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onOpenPreview(); } }} > {html ? ( <>