mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
754 lines
28 KiB
TypeScript
754 lines
28 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
import { useI18n } from '../i18n';
|
|
import {
|
|
localizeSkillDescription,
|
|
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';
|
|
|
|
type TranslateFn = (key: keyof Dict, vars?: Record<string, string | number>) => 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<string, keyof Dict> = {
|
|
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:<example>` 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 `<parent>:<child>` 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.
|
|
const skills = useMemo(
|
|
() => rawSkills.filter((s) => !s.aggregatesExamples),
|
|
[rawSkills],
|
|
);
|
|
// Hold preview HTML per skill across re-renders so cards never re-flicker.
|
|
const [previews, setPreviews] = useState<Record<string, string | null>>({});
|
|
// 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<Record<string, string>>({});
|
|
// 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<string, string>
|
|
>({});
|
|
// 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<Set<string>>(new Set());
|
|
const [surfaceFilter, setSurfaceFilter] = useState<SurfaceFilter>('all');
|
|
const [modeFilter, setModeFilter] = useState<ModeFilter>('all');
|
|
const [scenarioFilter, setScenarioFilter] = useState<ScenarioFilter>('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<string | null>(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<string | null>(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<ModeFilter, number> = {
|
|
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<SurfaceFilter, number> = { 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<string, number>();
|
|
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 desc = localizeSkillDescription(locale, s);
|
|
const prompt = localizeSkillPrompt(locale, s) || '';
|
|
const haystack = `${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 <div className="tab-empty">{t('examples.emptyNoSkills')}</div>;
|
|
}
|
|
|
|
return (
|
|
<div className="tab-panel examples-panel">
|
|
<div className="examples-toolbar">
|
|
<div className="examples-search">
|
|
<span className="search-icon" aria-hidden>
|
|
<Icon name="search" size={13} />
|
|
</span>
|
|
<input
|
|
type="search"
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
placeholder={t('examples.searchPlaceholder')}
|
|
aria-label={t('examples.searchAria')}
|
|
/>
|
|
</div>
|
|
<div
|
|
className="examples-filter-row"
|
|
role="tablist"
|
|
aria-label={t('examples.surfaceLabel')}
|
|
>
|
|
<span className="examples-filter-label">{t('examples.surfaceLabel')}</span>
|
|
{SURFACE_PILLS.map((p) => (
|
|
<button
|
|
key={p.value}
|
|
type="button"
|
|
role="tab"
|
|
aria-selected={surfaceFilter === p.value}
|
|
className={`filter-pill ${surfaceFilter === p.value ? 'active' : ''}`}
|
|
onClick={() => {
|
|
setSurfaceFilter(p.value);
|
|
setModeFilter('all');
|
|
setScenarioFilter('all');
|
|
}}
|
|
>
|
|
{t(p.labelKey)}
|
|
<span className="filter-pill-count">{surfaceCounts[p.value]}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
<div
|
|
className="examples-filter-row"
|
|
role="tablist"
|
|
aria-label={t('examples.typeLabel')}
|
|
>
|
|
<span className="examples-filter-label">{t('examples.typeLabel')}</span>
|
|
{MODE_PILLS.map((p) => (
|
|
<button
|
|
key={p.value}
|
|
type="button"
|
|
role="tab"
|
|
aria-selected={modeFilter === p.value}
|
|
className={`filter-pill ${modeFilter === p.value ? 'active' : ''}`}
|
|
onClick={() => {
|
|
setModeFilter(p.value);
|
|
setScenarioFilter('all');
|
|
}}
|
|
>
|
|
{t(p.labelKey)}
|
|
<span className="filter-pill-count">{modeCounts[p.value]}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
{scenarioOptions.length > 1 ? (
|
|
<div
|
|
className="examples-filter-row"
|
|
role="tablist"
|
|
aria-label={t('examples.scenarioLabel')}
|
|
>
|
|
<span className="examples-filter-label">
|
|
{t('examples.scenarioLabel')}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
className={`filter-pill ${scenarioFilter === 'all' ? 'active' : ''}`}
|
|
onClick={() => setScenarioFilter('all')}
|
|
>
|
|
{t('examples.modeAll')}
|
|
<span className="filter-pill-count">{scenarioAllCount}</span>
|
|
</button>
|
|
{scenarioOptions.map((tag) => (
|
|
<button
|
|
key={tag}
|
|
type="button"
|
|
className={`filter-pill ${scenarioFilter === tag ? 'active' : ''}`}
|
|
onClick={() => setScenarioFilter(tag)}
|
|
>
|
|
{scenarioLabel(t, tag)}
|
|
<span className="filter-pill-count">{scenarioCounts.get(tag) ?? 0}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
|
|
{filtered.length === 0 ? (
|
|
<div className="tab-empty">{t('examples.emptyNoMatch')}</div>
|
|
) : (
|
|
<div className="examples-list">
|
|
{filtered.map((skill) => (
|
|
<ExampleCard
|
|
key={skill.id}
|
|
skill={skill}
|
|
html={previews[skill.id]}
|
|
unavailableKind={previewUnavailable[skill.id]}
|
|
onLoad={() => void loadPreview(skill.id)}
|
|
onUsePrompt={() => onUsePrompt(skill)}
|
|
onOpenPreview={() => openPreview(skill.id)}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
{(() => {
|
|
if (!previewSkill) return null;
|
|
const unavailableKind = previewUnavailable[previewSkill.id];
|
|
return (
|
|
<PreviewModal
|
|
title={previewSkill.name}
|
|
subtitle={
|
|
localizeSkillPrompt(locale, previewSkill)
|
|
?? localizeSkillDescription(locale, previewSkill).slice(0, 160)
|
|
}
|
|
views={[
|
|
{
|
|
id: 'preview',
|
|
label: t('examples.previewLabel'),
|
|
html: previews[previewSkill.id],
|
|
error: previewErrors[previewSkill.id] ?? null,
|
|
// Skills declared with a non-html `od.preview.type` ship
|
|
// no fetchable example; route the kind into the modal so
|
|
// it can render a calm "no shipped preview" placeholder
|
|
// instead of bouncing through the error state. Issue #897.
|
|
unavailable: unavailableKind
|
|
? { kind: unavailableKind }
|
|
: null,
|
|
deck: previewSkill.mode === 'deck',
|
|
},
|
|
]}
|
|
// Stable identity (see onPreviewView definition) so PreviewModal's
|
|
// mount-time onView effect doesn't re-fire on every state update;
|
|
// the Retry button reaches loadPreview through the same handler.
|
|
// Issue #860.
|
|
onView={onPreviewView}
|
|
exportTitleFor={() => previewSkill.name}
|
|
onClose={() => setPreviewSkillId(null)}
|
|
/>
|
|
);
|
|
})()}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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<HTMLDivElement | null>(null);
|
|
const cardRef = useRef<HTMLDivElement | null>(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 exportTitle = skill.name;
|
|
const isMobile = skill.platform === 'mobile';
|
|
const isDeck = skill.mode === 'deck';
|
|
const displayPrompt = localizeSkillPrompt(locale, skill);
|
|
const displayDescription = localizeSkillDescription(locale, skill).slice(0, 240);
|
|
|
|
return (
|
|
<div
|
|
ref={cardRef}
|
|
className="example-card"
|
|
data-testid={`example-card-${skill.id}`}
|
|
onMouseEnter={() => {
|
|
setHovered(true);
|
|
onLoad();
|
|
}}
|
|
onMouseLeave={() => setHovered(false)}
|
|
>
|
|
<div
|
|
className="example-preview"
|
|
role="button"
|
|
tabIndex={0}
|
|
title={t('common.openPreview')}
|
|
onClick={onOpenPreview}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
e.preventDefault();
|
|
onOpenPreview();
|
|
}
|
|
}}
|
|
>
|
|
{html ? (
|
|
<>
|
|
<iframe
|
|
title={`${skill.name} ${t('examples.previewLabel').toLowerCase()}`}
|
|
sandbox="allow-scripts"
|
|
srcDoc={buildSrcdoc(html)}
|
|
tabIndex={-1}
|
|
/>
|
|
<span className="example-preview-overlay" aria-hidden="true">
|
|
{t('examples.openPreview')}
|
|
</span>
|
|
</>
|
|
) : unavailableKind ? (
|
|
// Non-HTML preview kinds (image / markdown / …) ship no
|
|
// fetchable artifact today — show a quiet "no preview"
|
|
// placeholder so the user doesn't keep hovering waiting for
|
|
// a render that won't come, and steer them at "Use this
|
|
// prompt" via the card CTA. Issue #897.
|
|
<div
|
|
className="example-preview-placeholder example-preview-placeholder-unavailable"
|
|
data-testid={`example-card-unavailable-${skill.id}`}
|
|
>
|
|
{t('examples.unavailablePlaceholder', { kind: unavailableKind })}
|
|
</div>
|
|
) : (
|
|
<div className="example-preview-placeholder">
|
|
{hovered || intersected
|
|
? t('examples.loadingPreview')
|
|
: t('examples.hoverPreview')}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="example-meta">
|
|
<div className="example-name">{skill.name}</div>
|
|
<div className="example-tags">
|
|
<span className={`example-tag ${isMobile ? 'platform-mobile' : ''} ${isDeck ? 'mode-deck' : ''}`}>
|
|
{tagForSkill(skill, t)}
|
|
</span>
|
|
{skill.scenario && skill.scenario !== 'general' ? (
|
|
<span className="example-tag">
|
|
{scenarioLabel(t, skill.scenario)}
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
<div className="example-prompt">
|
|
{displayPrompt ? quotePrompt(locale, displayPrompt) : displayDescription}
|
|
</div>
|
|
<div className="example-card-actions">
|
|
<button
|
|
className="primary example-cta"
|
|
data-testid={`example-use-prompt-${skill.id}`}
|
|
onClick={onUsePrompt}
|
|
>
|
|
{t('examples.usePrompt')}
|
|
</button>
|
|
<button
|
|
className="ghost"
|
|
onClick={onOpenPreview}
|
|
title={t('examples.previewModalTitle')}
|
|
>
|
|
{t('examples.openPreview')}
|
|
</button>
|
|
<div className="share-menu" ref={shareRef}>
|
|
<button
|
|
className="ghost"
|
|
aria-haspopup="menu"
|
|
aria-expanded={shareOpen}
|
|
disabled={!html}
|
|
title={
|
|
html
|
|
? t('examples.shareTitle')
|
|
: unavailableKind
|
|
? t('examples.shareUnavailable', { kind: unavailableKind })
|
|
: t('examples.shareLoadFirst')
|
|
}
|
|
onClick={() => setShareOpen((v) => !v)}
|
|
>
|
|
{t('examples.shareMenu')}
|
|
</button>
|
|
{shareOpen && html ? (
|
|
<div className="share-menu-popover" role="menu">
|
|
<button
|
|
type="button"
|
|
className="share-menu-item"
|
|
role="menuitem"
|
|
onClick={() => {
|
|
setShareOpen(false);
|
|
exportAsPdf(html, exportTitle, { deck: isDeck });
|
|
}}
|
|
>
|
|
<span className="share-menu-icon">📄</span>
|
|
<span>
|
|
{isDeck
|
|
? t('examples.exportPdfAllSlides')
|
|
: t('common.exportPdf')}
|
|
</span>
|
|
</button>
|
|
{isDeck ? (
|
|
<button
|
|
type="button"
|
|
className="share-menu-item"
|
|
role="menuitem"
|
|
title={t('examples.exportPptxLocked')}
|
|
disabled
|
|
>
|
|
<span className="share-menu-icon">📊</span>
|
|
<span>{t('examples.exportPptxLocked')}</span>
|
|
</button>
|
|
) : null}
|
|
<div className="share-menu-divider" />
|
|
<button
|
|
type="button"
|
|
className="share-menu-item"
|
|
role="menuitem"
|
|
onClick={() => {
|
|
setShareOpen(false);
|
|
exportAsZip(html, exportTitle);
|
|
}}
|
|
>
|
|
<span className="share-menu-icon">🗜</span>
|
|
<span>{t('common.exportZip')}</span>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="share-menu-item"
|
|
role="menuitem"
|
|
onClick={() => {
|
|
setShareOpen(false);
|
|
exportAsHtml(html, exportTitle);
|
|
}}
|
|
>
|
|
<span className="share-menu-icon">🌐</span>
|
|
<span>{t('common.exportHtml')}</span>
|
|
</button>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function tagForSkill(skill: SkillSummary, t: TranslateFn): string {
|
|
if (skill.mode === 'image' || skill.surface === 'image') return t('examples.tagImage');
|
|
if (skill.mode === 'video' || skill.surface === 'video') return t('examples.tagVideo');
|
|
if (skill.mode === 'audio' || skill.surface === 'audio') return t('examples.tagAudio');
|
|
if (skill.mode === 'deck') return t('examples.tagSlideDeck');
|
|
if (skill.mode === 'template') return t('examples.tagTemplate');
|
|
if (skill.mode === 'design-system') return t('examples.tagDesignSystem');
|
|
if (skill.platform === 'mobile') return t('examples.tagMobilePrototype');
|
|
return t('examples.tagDesktopPrototype');
|
|
}
|