import { useEffect, useMemo, useState } from "react"; import { Check, ExternalLink, Loader2, PackagePlus, Search, ShieldAlert, } from "lucide-react"; import { useTranslation } from "react-i18next"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { SegmentedControl } from "@/components/ui/segmented-control"; import { fetchMarketplaceSkillTrends, fetchTrendingMarketplaceSkills, installMarketplaceSkill, searchMarketplaceSkills, } from "@/lib/api"; import { notifySkillsChanged } from "@/lib/skill-events"; import type { MarketplaceProvider, MarketplaceSkillSummary, SkillSummary, } from "@/lib/types"; import { cn } from "@/lib/utils"; import { useClient } from "@/providers/ClientProvider"; export function SkillsMarketplace({ installedSkills, installing, onInstallingChange, }: { installedSkills: SkillSummary[]; installing: string; onInstallingChange: (skillId: string) => void; }) { const { client, getToken } = useClient(); const { t } = useTranslation(); const [query, setQuery] = useState(""); const [results, setResults] = useState([]); const [trending, setTrending] = useState([]); const [trends, setTrends] = useState>({}); const [loading, setLoading] = useState(false); const [trendingLoading, setTrendingLoading] = useState(true); const [error, setError] = useState(""); const [provider, setProvider] = useState("all"); const [selected, setSelected] = useState(null); const installedNames = useMemo( () => new Set(installedSkills.map((skill) => skill.name)), [installedSkills], ); const visibleTrending = useMemo( () => provider === "all" ? trending : trending.filter((skill) => skill.provider === provider), [provider, trending], ); const visibleResults = useMemo( () => provider === "all" ? results : results.filter((skill) => skill.provider === provider), [provider, results], ); useEffect(() => { let cancelled = false; setTrendingLoading(true); fetchTrendingMarketplaceSkills(getToken()) .then((payload) => { if (cancelled) return; setTrending(payload.skills); }) .catch(() => { if (!cancelled) setTrending([]); }) .finally(() => { if (!cancelled) setTrendingLoading(false); }); return () => { cancelled = true; }; }, [getToken]); useEffect(() => { const skills = query.trim().length < 2 ? trending : results; const unresolved = skills.filter( (skill) => skill.provider === "skills_sh" && !(skill.id in trends), ); if (!unresolved.length) return; let cancelled = false; fetchMarketplaceSkillTrends(getToken(), unresolved.map((skill) => skill.id)) .then((payload) => { if (!cancelled) { setTrends((current) => ({ ...current, ...payload.trends })); } }) .catch(() => undefined); return () => { cancelled = true; }; }, [getToken, query, results, trending, trends]); useEffect(() => { const normalized = query.trim(); if (normalized.length < 2) { setResults([]); setLoading(false); setError(""); return; } let cancelled = false; const timer = window.setTimeout(() => { setLoading(true); setError(""); searchMarketplaceSkills(getToken(), normalized) .then((payload) => { if (cancelled) return; setResults(payload.skills); }) .catch((reason: unknown) => { if (cancelled) return; setResults([]); setError( reason instanceof Error ? reason.message : t("settings.skills.marketplaceSearchFailed", { defaultValue: "Could not search skill marketplaces.", }), ); }) .finally(() => { if (!cancelled) setLoading(false); }); }, 300); return () => { cancelled = true; window.clearTimeout(timer); }; }, [getToken, query, t]); const install = async (skill: MarketplaceSkillSummary) => { setSelected(null); onInstallingChange(skill.id); setError(""); try { const payload = await installMarketplaceSkill( client, skill.provider, skill.source, skill.skill_id, skill.version, ); notifySkillsChanged(payload); setResults((current) => current.map((item) => item.id === skill.id ? { ...item, installed: true } : item, ), ); setTrending((current) => current.map((item) => item.id === skill.id ? { ...item, installed: true } : item, ), ); } catch (reason) { setError( reason instanceof Error ? reason.message : t("settings.skills.marketplaceInstallFailed", { defaultValue: "Could not install this skill.", }), ); } finally { onInstallingChange(""); } }; return (
setQuery(event.target.value)} placeholder={t("settings.skills.marketplaceSearchPlaceholder", { defaultValue: "Search skills", })} aria-label={t("settings.skills.marketplaceSearchLabel", { defaultValue: "Search skills", })} className="h-11 rounded-[14px] bg-settings-surface pl-9" /> {loading ? ( ) : null}
{error ? (
{error}
) : null} {query.trim().length < 2 ? (

{t("settings.skills.marketplaceTrendingTitle", { defaultValue: "Trending by marketplace", })}

{provider !== "all" ? ( {t("settings.skills.marketplaceViewAll", { defaultValue: "View all" })} ) : null}
{trendingLoading ? ( ) : visibleTrending.length ? ( ) : (
{t("settings.skills.marketplaceTrendingUnavailable", { defaultValue: "Trending skills are temporarily unavailable.", })}
)}
) : !loading && visibleResults.length === 0 && !error ? (
{t("settings.skills.marketplaceEmpty", { query: query.trim(), defaultValue: "No skills found for “{{query}}”.", })}
) : (
)} { if (!open) setSelected(null); }} >
{t("settings.skills.marketplaceConfirmTitle", { name: selected?.name ?? "", defaultValue: "Install {{name}}?", })} {t("settings.skills.marketplaceConfirmDescription", { source: selected?.source ?? "", provider: selected ? providerLabel(selected.provider) : "", defaultValue: "This third-party skill comes from {{provider}} ({{source}}) and may include instructions or executable scripts.", })} {selected ? : null} {selected?.source} {selected?.version ? v{selected.version} : null}
{t("common.cancel", { defaultValue: "Cancel" })} { if (selected) void install(selected); }} > {t("settings.skills.marketplaceConfirmInstall", { defaultValue: "Install skill", })}
); } function ProviderFilter({ value, onChange, }: { value: MarketplaceProvider; onChange: (provider: MarketplaceProvider) => void; }) { const { t } = useTranslation(); const providers: MarketplaceProvider[] = ["all", "skills_sh", "skillhub"]; return ( ({ value: provider, label: ( <> {provider !== "all" ? : null} {provider === "all" ? t("settings.skills.marketplaceProviderAll", { defaultValue: "All" }) : providerLabel(provider)} ), }))} onChange={onChange} /> ); } function MarketplaceSkillGroups({ skills, installedNames, installing, trends, grouped, onSelect, }: { skills: MarketplaceSkillSummary[]; installedNames: Set; installing: string; trends: Record; grouped: boolean; onSelect: (skill: MarketplaceSkillSummary) => void; }) { const { t } = useTranslation(); const providers: Array> = [ "skills_sh", "skillhub", ]; if (!grouped) { return ( ); } return (
{providers.map((provider) => { const providerSkills = skills.filter((skill) => skill.provider === provider); if (!providerSkills.length) return null; return (
); })}
); } function MarketplaceSkillList({ skills, installedNames, installing, trends, onSelect, }: { skills: MarketplaceSkillSummary[]; installedNames: Set; installing: string; trends: Record; onSelect: (skill: MarketplaceSkillSummary) => void; }) { return (
{skills.map((skill) => ( ))}
); } function MarketplaceSkillRow({ skill, installed, isInstalling, installBusy, trend, onSelect, }: { skill: MarketplaceSkillSummary; installed: boolean; isInstalling: boolean; installBusy: boolean; trend?: number[]; onSelect: (skill: MarketplaceSkillSummary) => void; }) { const { t } = useTranslation(); const actionLabel = t( isInstalling ? "settings.skills.marketplaceInstalling" : installed ? "settings.skills.marketplaceInstalled" : "settings.skills.marketplaceInstall", ); return (
{skill.rank ? ( #{skill.rank} ) : null}

{skill.name}

{skill.source} {skill.version ? · v{skill.version} : null} · {skill.metric === "installs_24h" ? t("settings.skills.marketplaceInstalls24h", { count: skill.installs, formattedCount: skill.installs.toLocaleString(), defaultValue: "{{formattedCount}} installs / 24h", }) : t("settings.skills.marketplaceInstalls", { count: skill.installs, formattedCount: skill.installs.toLocaleString(), defaultValue: "{{formattedCount}} installs", })}
{skill.provider === "skills_sh" ? : null}
); } function ProviderMark({ provider, }: { provider: Exclude; }) { return ( {providerLabel(provider)} ); } function ProviderDot({ provider, }: { provider: Exclude; }) { return ( ); } function providerLabel(provider: Exclude): string { return provider === "skillhub" ? "SkillHub" : "skills.sh"; } function providerUrl(provider: Exclude): string { return provider === "skillhub" ? "https://skillhub.cn" : "https://skills.sh/trending"; } function TrendSparkline({ values }: { values?: number[] }) { const { t } = useTranslation(); const trendLabel = t("settings.skills.marketplaceTrendLabel", { defaultValue: "8-week install trend", }); if (values === undefined) { return ; } if (values.length < 2) { return ( {t("settings.skills.marketplaceNoTrend", { defaultValue: "No trend yet" })} ); } const width = 96; const height = 30; const padding = 2; const min = Math.min(...values); const max = Math.max(...values); const range = Math.max(max - min, 1); const points = values.map((value, index) => ({ x: padding + (index / (values.length - 1)) * (width - padding * 2), y: padding + ((max - value) / range) * (height - padding * 2), })); const line = points.slice(1).reduce((path, point, index) => { const previous = points[index]; const middle = (previous.x + point.x) / 2; return `${path} C ${middle} ${previous.y}, ${middle} ${point.y}, ${point.x} ${point.y}`; }, `M ${points[0].x} ${points[0].y}`); const area = `${line} L ${points.at(-1)?.x ?? width} ${height} L ${points[0].x} ${height} Z`; return ( {trendLabel} ); } function TrendingSkeleton() { return (
{Array.from({ length: 5 }, (_, index) => (
))}
); }