HomePilot / frontend /src /ui /mcp /MatrixHubCatalogPanel.jsx
HomePilot Deploy Bot
chore(hf): sync HomePilot to HF Space
921d377
Raw
History Blame Contribute Delete
14.8 kB
/**
* MatrixHubCatalogPanel β€” browse and install MCP servers from the MatrixHub catalog.
*
* MatrixHub (https://github.com/agent-matrix/matrix-hub) is an optional
* secondary catalog source for MCP servers, agents, and tools.
*
* Phase 11 β€” fully additive, does not modify any existing component.
*/
import React, { useState } from 'react';
import { Search, RefreshCw, Globe, Star, ExternalLink, AlertCircle, ChevronDown, Copy, Check, X as XIcon, } from 'lucide-react';
import { useMatrixHub } from './useMatrixHub';
// ── Server card ──────────────────────────────────────────────────────────
function MatrixHubServerCard({ item, onInstall, onViewDetails, }) {
return (<div className="flex flex-col gap-3 p-5 rounded-2xl transition-all duration-200 border bg-white/5 hover:bg-white/[0.07] border-white/10 hover:border-white/20 h-full">
{/* Header */}
<div className="flex justify-between items-start gap-3">
<div className="flex items-center gap-3 min-w-0">
<div className="w-10 h-10 shrink-0 rounded-xl bg-gradient-to-br from-violet-500/20 to-indigo-500/20 border border-white/10 flex items-center justify-center text-violet-400">
<Globe size={18} strokeWidth={2}/>
</div>
<div className="min-w-0">
<h3 className="font-semibold text-sm text-white truncate">{item.name}</h3>
{item.version && (<span className="text-xs text-white/40">v{item.version}</span>)}
</div>
</div>
{/* Score badge */}
{item.score_final > 0 && (<span className="flex items-center gap-1 text-xs text-amber-300/80 bg-amber-500/10 px-2 py-0.5 rounded-full shrink-0">
<Star size={10}/>
{item.score_final.toFixed(1)}
</span>)}
</div>
{/* Description */}
<p className="text-sm text-white/60 leading-relaxed line-clamp-2 flex-1">
{item.summary || 'No description available.'}
</p>
{/* Tags row */}
<div className="flex flex-wrap items-center gap-1.5">
{item.capabilities?.slice(0, 3).map((cap) => (<span key={cap} className="text-xs px-2 py-0.5 rounded-full font-medium bg-violet-500/20 text-violet-300">
{cap}
</span>))}
{item.frameworks?.slice(0, 2).map((fw) => (<span key={fw} className="text-xs px-2 py-0.5 rounded-full bg-white/5 text-white/40">
{fw}
</span>))}
</div>
{/* Action buttons */}
<div className="pt-1 flex items-center gap-2">
<button onClick={onViewDetails} className="flex-1 flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium bg-violet-500/20 hover:bg-violet-500/30 text-violet-300 rounded-lg transition-colors justify-center">
<Globe size={14}/>
View Details
</button>
{(item.install_url || item.source_url || item.homepage) && (<a href={item.install_url || item.source_url || item.homepage || '#'} target="_blank" rel="noopener noreferrer" className="flex items-center gap-1 px-2.5 py-1.5 text-xs font-medium text-white/40 hover:text-white/70 bg-white/5 hover:bg-white/10 rounded-lg transition-colors" title="Open source">
<ExternalLink size={13}/>
</a>)}
</div>
</div>);
}
// ── Details drawer ──────────────────────────────────────────────────────
function MatrixHubDetailsDrawer({ item, onClose, }) {
const [copiedUrl, setCopiedUrl] = useState(false);
const copyToClipboard = (text) => {
navigator.clipboard.writeText(text).catch(() => { });
setCopiedUrl(true);
setTimeout(() => setCopiedUrl(false), 2000);
};
return (<div className="fixed inset-0 z-50 flex justify-end" onClick={onClose}>
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm"/>
<div className="relative w-full max-w-md bg-[#0b0b12] border-l border-white/10 h-full overflow-y-auto animate-slide-in-right" onClick={(e) => e.stopPropagation()}>
{/* Header */}
<div className="sticky top-0 bg-[#0b0b12]/95 backdrop-blur border-b border-white/10 px-6 py-4 flex items-center justify-between z-10">
<div className="flex items-center gap-3">
<div className="w-10 h-10 shrink-0 rounded-xl bg-gradient-to-br from-violet-500/20 to-indigo-500/20 border border-white/10 flex items-center justify-center text-violet-400">
<Globe size={18} strokeWidth={2}/>
</div>
<div>
<h2 className="text-base font-semibold text-white">{item.name}</h2>
<div className="flex items-center gap-2 mt-0.5">
{item.version && <span className="text-xs text-white/40">v{item.version}</span>}
{item.score_final > 0 && (<>
<span className="text-white/20">Β·</span>
<span className="flex items-center gap-1 text-xs text-amber-300/70">
<Star size={10}/>
{item.score_final.toFixed(1)}
</span>
</>)}
</div>
</div>
</div>
<button onClick={onClose} className="p-2 text-white/50 hover:text-white hover:bg-white/10 rounded-xl transition-colors">
<XIcon size={18}/>
</button>
</div>
{/* Body */}
<div className="px-6 py-6 space-y-5">
<p className="text-sm text-white/60 leading-relaxed">{item.summary}</p>
{/* Capabilities */}
{item.capabilities?.length > 0 && (<div>
<h3 className="text-xs font-semibold text-white/40 uppercase tracking-wide mb-2">Capabilities</h3>
<div className="flex flex-wrap gap-1.5">
{item.capabilities.map((c) => (<span key={c} className="text-xs px-2.5 py-1 rounded-full bg-violet-500/20 text-violet-300 border border-violet-500/10">
{c}
</span>))}
</div>
</div>)}
{/* Frameworks */}
{item.frameworks?.length > 0 && (<div>
<h3 className="text-xs font-semibold text-white/40 uppercase tracking-wide mb-2">Frameworks</h3>
<div className="flex flex-wrap gap-1.5">
{item.frameworks.map((f) => (<span key={f} className="text-xs px-2.5 py-1 rounded-full bg-cyan-500/20 text-cyan-300 border border-cyan-500/10">
{f}
</span>))}
</div>
</div>)}
{/* Providers */}
{item.providers?.length > 0 && (<div>
<h3 className="text-xs font-semibold text-white/40 uppercase tracking-wide mb-2">Providers</h3>
<div className="flex flex-wrap gap-1.5">
{item.providers.map((p) => (<span key={p} className="text-xs px-2.5 py-1 rounded-full bg-white/5 text-white/50 border border-white/5">
{p}
</span>))}
</div>
</div>)}
{/* Snippet / install command */}
{item.snippet && (<div>
<h3 className="text-xs font-semibold text-white/40 uppercase tracking-wide mb-2">Install Snippet</h3>
<div className="relative">
<pre className="text-xs text-white/60 bg-black/40 border border-white/10 rounded-xl p-4 font-mono overflow-x-auto whitespace-pre-wrap">
{item.snippet}
</pre>
<button onClick={() => copyToClipboard(item.snippet || '')} className="absolute top-2 right-2 p-1.5 text-white/30 hover:text-white/60 bg-white/5 hover:bg-white/10 rounded-lg transition-colors" title="Copy snippet">
{copiedUrl ? <Check size={12} className="text-emerald-400"/> : <Copy size={12}/>}
</button>
</div>
</div>)}
{/* Links */}
<div className="space-y-2">
{item.homepage && (<a href={item.homepage} target="_blank" rel="noopener noreferrer" className="flex items-center gap-2 w-full px-4 py-3 text-sm font-medium text-cyan-300 bg-cyan-500/10 hover:bg-cyan-500/20 rounded-xl transition-colors border border-cyan-500/20">
<Globe size={16}/>
Homepage
</a>)}
{item.source_url && (<a href={item.source_url} target="_blank" rel="noopener noreferrer" className="flex items-center gap-2 w-full px-4 py-3 text-sm font-medium text-white/60 bg-white/5 hover:bg-white/10 rounded-xl transition-colors border border-white/10">
<ExternalLink size={16}/>
Source Code
</a>)}
{item.manifest_url && (<a href={item.manifest_url} target="_blank" rel="noopener noreferrer" className="flex items-center gap-2 w-full px-4 py-3 text-sm font-medium text-white/60 bg-white/5 hover:bg-white/10 rounded-xl transition-colors border border-white/10">
<ExternalLink size={16}/>
Manifest
</a>)}
</div>
</div>
</div>
<style>{`
@keyframes slide-in-right {
from { transform: translateX(100%); }
to { transform: translateX(0); }
}
.animate-slide-in-right {
animation: slide-in-right 0.2s ease-out;
}
`}</style>
</div>);
}
// ── Capability filter ────────────────────────────────────────────────────
function CapabilityFilter({ capabilities, selected, onSelect, }) {
if (capabilities.length === 0)
return null;
return (<div className="relative">
<select value={selected} onChange={(e) => onSelect(e.target.value)} className="appearance-none bg-white/5 border border-white/10 rounded-lg pl-3 pr-8 py-1.5 text-xs text-white/70 focus:outline-none focus:border-violet-500/50 cursor-pointer">
<option value="">All Capabilities</option>
{capabilities.map((c) => (<option key={c} value={c}>{c}</option>))}
</select>
<ChevronDown size={12} className="absolute right-2 top-1/2 -translate-y-1/2 text-white/30 pointer-events-none"/>
</div>);
}
// ── Main panel ───────────────────────────────────────────────────────────
export function MatrixHubCatalogPanel({ hubUrl, enabled = true }) {
const { items, total, loading, error, healthy, search, setSearch, capabilities, refresh, } = useMatrixHub({ hubUrl, enabled });
const [refreshing, setRefreshing] = useState(false);
const [capFilter, setCapFilter] = useState('');
const [detailItem, setDetailItem] = useState(null);
const filteredItems = capFilter
? items.filter((i) => i.capabilities?.includes(capFilter))
: items;
const handleRefresh = async () => {
setRefreshing(true);
try {
await refresh();
}
finally {
setRefreshing(false);
}
};
// Not reachable
if (!enabled || healthy === false) {
return (<div className="rounded-2xl border border-white/10 bg-white/5 p-6">
<div className="flex items-center gap-3 mb-3">
<Globe size={20} className="text-violet-400"/>
<h3 className="text-sm font-semibold text-white">MatrixHub Catalog</h3>
<span className="text-xs px-2 py-0.5 rounded-full bg-white/10 text-white/40 font-medium">
{healthy === false ? 'Unreachable' : 'Disabled'}
</span>
</div>
<p className="text-sm text-white/50">
{healthy === false
? `Cannot connect to MatrixHub at ${hubUrl}. Make sure it is running.`
: 'MatrixHub integration is disabled.'}
</p>
</div>);
}
return (<div className="space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="flex items-center gap-3 text-xs text-white/40">
<span>{total} servers</span>
{capabilities.length > 0 && (<>
<span className="w-px h-3 bg-white/10"/>
<span>{capabilities.length} capabilities</span>
</>)}
</div>
</div>
<button onClick={handleRefresh} disabled={refreshing} className="flex items-center gap-2 px-3 py-1.5 text-xs font-medium text-white/60 hover:text-white bg-white/5 hover:bg-white/10 rounded-lg transition-colors disabled:opacity-50">
<RefreshCw size={14} className={refreshing ? 'animate-spin' : ''}/>
Refresh
</button>
</div>
{/* Filters */}
<div className="flex flex-wrap items-center gap-3">
<div className="relative flex-1 min-w-[200px]">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-white/30"/>
<input type="text" value={search} onChange={(e) => setSearch(e.target.value)} placeholder="Search MatrixHub..." className="w-full bg-white/5 border border-white/10 rounded-xl pl-10 pr-4 py-2.5 text-sm text-white placeholder-white/30 focus:outline-none focus:border-violet-500/50 transition-colors"/>
</div>
<CapabilityFilter capabilities={capabilities} selected={capFilter} onSelect={setCapFilter}/>
</div>
{/* Loading */}
{loading && (<div className="flex items-center justify-center py-12 text-white/40">
<div className="w-5 h-5 border-2 border-white/20 border-t-violet-400 rounded-full animate-spin mr-3"/>
Loading MatrixHub catalog...
</div>)}
{/* Error */}
{error && (<div className="flex items-center gap-2 text-red-400 text-sm py-4">
<AlertCircle size={14}/>
{error}
</div>)}
{/* Grid */}
{!loading && filteredItems.length > 0 && (<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{filteredItems.map((item) => (<MatrixHubServerCard key={item.id} item={item} onInstall={() => { }} onViewDetails={() => setDetailItem(item)}/>))}
</div>)}
{/* Empty */}
{!loading && !error && filteredItems.length === 0 && (search || capFilter) && (<div className="flex flex-col items-center justify-center h-32 text-white/40">
<Search size={28} className="mb-2 opacity-30"/>
<p className="text-sm">No servers match your search.</p>
</div>)}
{/* Details drawer */}
{detailItem && (<MatrixHubDetailsDrawer item={detailItem} onClose={() => setDetailItem(null)}/>)}
</div>);
}