| |
| |
| |
| |
| |
| |
| |
| |
| 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'; |
| |
| 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>); |
| } |
| |
| 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>); |
| } |
| |
| 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>); |
| } |
| |
| 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); |
| } |
| }; |
| |
| 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> |
|
|
| {} |
| <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 && (<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 && (<div className="flex items-center gap-2 text-red-400 text-sm py-4"> |
| <AlertCircle size={14}/> |
| {error} |
| </div>)} |
|
|
| {} |
| {!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>)} |
|
|
| {} |
| {!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>)} |
|
|
| {} |
| {detailItem && (<MatrixHubDetailsDrawer item={detailItem} onClose={() => setDetailItem(null)}/>)} |
| </div>); |
| } |
|
|