feat: add MCP marketplace and custom scrollbar styles
Add comprehensive CSS styles for the MCP Marketplace interface, including card hover effects, responsive grid layout, modal backdrop blur, category badges, rating stars, install buttons, and loading animations. Also include custom scrollbar styling for the model selector to enhance user experience and visual consistency.
This commit is contained in:
1 parent
d5dd3a9f09
commit
ce010d39ee
31 files changed
+4127
-45
No files matched your search
@@ -0,0 +1,174 @@
|
||||
"use client";
|
||||
|
||||
import { Package, Star, Download, Eye, ExternalLink, CheckCircle, Zap } from "lucide-react";
|
||||
import { MCPProvider } from "@/lib/types/mcp";
|
||||
|
||||
interface MCPCardProps {
|
||||
mcp: MCPProvider;
|
||||
onInstall: () => void;
|
||||
onUninstall: () => void;
|
||||
onViewDetails: () => void;
|
||||
}
|
||||
|
||||
export function MCPCard({ mcp, onInstall, onUninstall, onViewDetails }: MCPCardProps) {
|
||||
const getCategoryColor = (category: string) => {
|
||||
switch (category) {
|
||||
case "map": return "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200";
|
||||
case "browser": return "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200";
|
||||
case "office": return "bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200";
|
||||
case "search": return "bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200";
|
||||
case "database": return "bg-indigo-100 text-indigo-800 dark:bg-indigo-900 dark:text-indigo-200";
|
||||
case "finance": return "bg-emerald-100 text-emerald-800 dark:bg-emerald-900 dark:text-emerald-200";
|
||||
case "code": return "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200";
|
||||
case "chart": return "bg-pink-100 text-pink-800 dark:bg-pink-900 dark:text-pink-200";
|
||||
case "payment": return "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200";
|
||||
default: return "bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200";
|
||||
}
|
||||
};
|
||||
|
||||
const formatDownloads = (downloads: number) => {
|
||||
if (downloads >= 1000000) return `${(downloads / 1000000).toFixed(1)}M`;
|
||||
if (downloads >= 1000) return `${(downloads / 1000).toFixed(1)}K`;
|
||||
return downloads.toString();
|
||||
};
|
||||
|
||||
const isOfficial = mcp.tags?.includes('official') || false;
|
||||
const toolCount = mcp.tools?.length || mcp.capabilities?.length || 0;
|
||||
|
||||
return (
|
||||
<div className="bg-[var(--surface)] border border-[var(--border)] rounded-lg p-4 hover:shadow-md transition-all duration-200 hover:border-[var(--primary)]/50 mcp-card">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{mcp.iconUrl ? (
|
||||
<img
|
||||
src={mcp.iconUrl}
|
||||
alt={mcp.name}
|
||||
className="h-8 w-8 rounded-md object-cover"
|
||||
onError={(e) => {
|
||||
e.currentTarget.style.display = 'none';
|
||||
e.currentTarget.nextElementSibling?.classList.remove('hidden');
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<div className={`p-2 bg-[var(--primary)]/10 rounded-md ${mcp.iconUrl ? 'hidden' : ''}`}>
|
||||
<Package className="h-4 w-4 text-[var(--primary)]" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold text-sm">{mcp.name}</h3>
|
||||
{isOfficial && (
|
||||
<CheckCircle className="h-3 w-3 text-green-500" />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-[var(--muted-foreground)]">by {mcp.author}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className={`px-2 py-1 rounded-full text-xs font-medium mcp-category-badge ${getCategoryColor(mcp.category)}`}>
|
||||
{mcp.category}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-sm text-[var(--muted-foreground)] mb-3 line-clamp-2">
|
||||
{mcp.description}
|
||||
</p>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="flex items-center gap-4 mb-3 text-xs text-[var(--muted-foreground)]">
|
||||
<div className="flex items-center gap-1">
|
||||
<Star className="h-3 w-3 fill-yellow-400 text-yellow-400" />
|
||||
<span>{mcp.rating}</span>
|
||||
<span>({mcp.totalRatings})</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Download className="h-3 w-3" />
|
||||
<span>{formatDownloads(mcp.downloads)}</span>
|
||||
</div>
|
||||
{toolCount > 0 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Zap className="h-3 w-3" />
|
||||
<span>{toolCount} tools</span>
|
||||
</div>
|
||||
)}
|
||||
<span className="text-xs bg-[var(--surface)] px-2 py-1 rounded">
|
||||
v{mcp.version}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<div className="flex flex-wrap gap-1 mb-4">
|
||||
{mcp.tags.slice(0, 3).map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="px-2 py-1 bg-[var(--background)] text-xs rounded-md border border-[var(--border)] mcp-tag"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
{mcp.tags.length > 3 && (
|
||||
<span className="px-2 py-1 bg-[var(--background)] text-xs rounded-md border border-[var(--border)] mcp-tag">
|
||||
+{mcp.tags.length - 3}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={onViewDetails}
|
||||
className="flex-1 flex items-center justify-center gap-2 px-3 py-2 bg-[var(--background)] border border-[var(--border)] rounded-md hover:bg-[var(--surface)] transition-colors text-sm"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
Details
|
||||
</button>
|
||||
|
||||
{mcp.installed ? (
|
||||
<button
|
||||
onClick={onUninstall}
|
||||
className="px-3 py-2 bg-red-500 text-white rounded-md hover:bg-red-600 transition-colors text-sm mcp-install-button"
|
||||
>
|
||||
Uninstall
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={onInstall}
|
||||
className="px-3 py-2 bg-[var(--primary)] text-[var(--primary-foreground)] rounded-md hover:bg-[var(--primary)]/90 transition-colors text-sm mcp-install-button"
|
||||
>
|
||||
Install
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Links */}
|
||||
{(mcp.repository || mcp.homepage) && (
|
||||
<div className="mt-3 pt-3 border-t border-[var(--border)]">
|
||||
<div className="flex gap-2">
|
||||
{mcp.repository && (
|
||||
<a
|
||||
href={mcp.repository}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-xs text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
Repository
|
||||
</a>
|
||||
)}
|
||||
{mcp.homepage && (
|
||||
<a
|
||||
href={mcp.homepage}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-xs text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
Homepage
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { ChevronDown, ChevronRight } from "lucide-react";
|
||||
|
||||
interface MCPCategory {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
interface MCPCategoryFilterProps {
|
||||
primaryCategories: MCPCategory[];
|
||||
otherCategories: MCPCategory[];
|
||||
selectedCategory: string;
|
||||
onSelectCategory: (categoryId: string) => void;
|
||||
}
|
||||
|
||||
export function MCPCategoryFilter({
|
||||
primaryCategories,
|
||||
otherCategories,
|
||||
selectedCategory,
|
||||
onSelectCategory,
|
||||
}: MCPCategoryFilterProps) {
|
||||
const [showOthers, setShowOthers] = useState(false);
|
||||
|
||||
const getCategoryIcon = (categoryName: string) => {
|
||||
const iconMap: Record<string, string> = {
|
||||
MAP: "🗺️",
|
||||
BROWSER: "🌐",
|
||||
OFFICE: "📄",
|
||||
CODE: "💻",
|
||||
DATABASE: "🗄️",
|
||||
SEARCH: "🔍",
|
||||
PAYMENT: "💳",
|
||||
CHART: "📊",
|
||||
FINANCE: "💰",
|
||||
OTHER: "📦",
|
||||
};
|
||||
return iconMap[categoryName.toUpperCase()] || "📦";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<h3 className="font-semibold text-sm text-[var(--foreground)] mb-3">
|
||||
Categories
|
||||
</h3>
|
||||
|
||||
{/* All Categories */}
|
||||
<button
|
||||
onClick={() => onSelectCategory("all")}
|
||||
className={`w-full flex items-center justify-between px-3 py-2 rounded-md text-sm transition-colors ${
|
||||
selectedCategory === "all"
|
||||
? "mcp-category-active"
|
||||
: "hover:bg-[var(--surface)] text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center gap-2">📦 All Categories</span>
|
||||
<span className="text-xs bg-[var(--background)] px-2 py-1 rounded-full">
|
||||
{primaryCategories.reduce((sum, cat) => sum + cat.count, 0) +
|
||||
otherCategories.reduce((sum, cat) => sum + cat.count, 0)}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Primary Categories */}
|
||||
<div className="space-y-1">
|
||||
<h4 className="text-xs font-medium text-[var(--muted-foreground)] uppercase tracking-wide">
|
||||
Principais
|
||||
</h4>
|
||||
{primaryCategories.map((category) => (
|
||||
<button
|
||||
key={category.id}
|
||||
onClick={() => onSelectCategory(category.id)}
|
||||
className={`w-full flex items-center justify-between px-3 py-2 rounded-md text-sm transition-colors ${
|
||||
selectedCategory === category.id
|
||||
? "mcp-category-active"
|
||||
: "hover:bg-[var(--surface)] text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
{getCategoryIcon(category.name)} {category.name}
|
||||
</span>
|
||||
<span className="text-xs bg-[var(--background)] px-2 py-1 rounded-full">
|
||||
{category.count}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Other Categories */}
|
||||
{otherCategories.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<button
|
||||
onClick={() => setShowOthers(!showOthers)}
|
||||
className="w-full flex items-center justify-between px-3 py-2 rounded-md text-sm transition-colors hover:bg-[var(--surface)] text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
{showOthers ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
<span className="text-xs font-medium uppercase tracking-wide">
|
||||
Outras ({otherCategories.length})
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{showOthers && (
|
||||
<div className="ml-4 space-y-1">
|
||||
{otherCategories.map((category) => (
|
||||
<button
|
||||
key={category.id}
|
||||
onClick={() => onSelectCategory(category.id)}
|
||||
className={`w-full flex items-center justify-between px-3 py-2 rounded-md text-sm transition-colors ${
|
||||
selectedCategory === category.id
|
||||
? "mcp-category-active"
|
||||
: "hover:bg-[var(--surface)] text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
📦 {category.name}
|
||||
</span>
|
||||
<span className="text-xs bg-[var(--background)] px-2 py-1 rounded-full">
|
||||
{category.count}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { X, Package, Star, Download, ExternalLink, CheckCircle } from "lucide-react";
|
||||
import { MCPProvider } from "@/lib/types/mcp";
|
||||
import { MCPRatingStars } from "./MCPRatingStars";
|
||||
|
||||
interface MCPDetailModalProps {
|
||||
mcp: MCPProvider;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onInstall: () => void;
|
||||
}
|
||||
|
||||
export function MCPDetailModal({ mcp, isOpen, onClose, onInstall }: MCPDetailModalProps) {
|
||||
const [activeTab, setActiveTab] = useState<"about" | "capabilities" | "screenshots" | "configuration">("about");
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const formatDownloads = (downloads: number) => {
|
||||
if (downloads >= 1000000) return `${(downloads / 1000000).toFixed(1)}M`;
|
||||
if (downloads >= 1000) return `${(downloads / 1000).toFixed(1)}K`;
|
||||
return downloads.toString();
|
||||
};
|
||||
|
||||
const getCategoryColor = (category: string) => {
|
||||
switch (category) {
|
||||
case "map": return "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200";
|
||||
case "browser": return "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200";
|
||||
case "office": return "bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200";
|
||||
case "search": return "bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200";
|
||||
case "database": return "bg-indigo-100 text-indigo-800 dark:bg-indigo-900 dark:text-indigo-200";
|
||||
case "finance": return "bg-emerald-100 text-emerald-800 dark:bg-emerald-900 dark:text-emerald-200";
|
||||
case "code": return "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200";
|
||||
case "chart": return "bg-pink-100 text-pink-800 dark:bg-pink-900 dark:text-pink-200";
|
||||
case "payment": return "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200";
|
||||
default: return "bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="fixed inset-0 bg-black/50" onClick={onClose} />
|
||||
|
||||
<div className="relative w-full max-w-4xl h-[90vh] bg-[var(--background)] border border-[var(--border)] rounded-lg shadow-xl mcp-detail-modal">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-[var(--border)]">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-3 bg-[var(--primary)]/10 rounded-lg">
|
||||
<Package className="h-8 w-8 text-[var(--primary)]" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold">{mcp.name}</h2>
|
||||
<p className="text-[var(--muted-foreground)]">by {mcp.author}</p>
|
||||
</div>
|
||||
<span className={`px-3 py-1 rounded-full text-sm font-medium mcp-category-badge ${getCategoryColor(mcp.category)}`}>
|
||||
{mcp.category}
|
||||
</span>
|
||||
{mcp.installed && (
|
||||
<div className="flex items-center gap-1 text-green-600">
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
<span className="text-sm font-medium">Installed</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-[var(--surface)] rounded-md transition-colors"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="p-6 border-b border-[var(--border)]">
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<MCPRatingStars rating={mcp.rating} size="md" />
|
||||
<span className="text-sm text-[var(--muted-foreground)]">
|
||||
({mcp.totalRatings} reviews)
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Download className="h-4 w-4 text-[var(--muted-foreground)]" />
|
||||
<span className="text-sm">{formatDownloads(mcp.downloads)} downloads</span>
|
||||
</div>
|
||||
<span className="text-sm text-[var(--muted-foreground)]">Version {mcp.version}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-[var(--border)]">
|
||||
{[
|
||||
{ id: "about", label: "About" },
|
||||
{ id: "capabilities", label: "Capabilities" },
|
||||
{ id: "configuration", label: "Configuration" },
|
||||
{ id: "screenshots", label: "Screenshots" },
|
||||
].map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id as any)}
|
||||
className={`px-6 py-3 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === tab.id
|
||||
? "border-[var(--primary)] text-[var(--primary)]"
|
||||
: "border-transparent text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 p-6 overflow-auto">
|
||||
{activeTab === "about" && (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3">Description</h3>
|
||||
<p className="text-[var(--muted-foreground)] leading-relaxed">
|
||||
{mcp.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3">Tags</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{mcp.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="px-3 py-1 bg-[var(--surface)] text-sm rounded-md border border-[var(--border)] mcp-tag"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(mcp.repository || mcp.homepage) && (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3">Links</h3>
|
||||
<div className="flex gap-4">
|
||||
{mcp.repository && (
|
||||
<a
|
||||
href={mcp.repository}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 text-[var(--primary)] hover:underline"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
Repository
|
||||
</a>
|
||||
)}
|
||||
{mcp.homepage && (
|
||||
<a
|
||||
href={mcp.homepage}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 text-[var(--primary)] hover:underline"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
Homepage
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "capabilities" && (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3">Capabilities</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{(mcp.tools || mcp.capabilities || []).map((capability, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center gap-2 p-3 bg-[var(--surface)] rounded-md border border-[var(--border)]"
|
||||
>
|
||||
<CheckCircle className="h-4 w-4 text-green-500" />
|
||||
<span className="text-sm">
|
||||
{typeof capability === 'string' ? capability : (capability as any).name || String(capability)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "configuration" && (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3">Installation Configuration</h3>
|
||||
{mcp.config && mcp.config.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{mcp.config.map((configItem, index) => (
|
||||
<div key={index} className="bg-[var(--surface)] border border-[var(--border)] rounded-lg p-4">
|
||||
<h4 className="font-medium mb-3">Configuration {index + 1}</h4>
|
||||
<div className="space-y-3">
|
||||
{configItem.mcpServers && (
|
||||
<div>
|
||||
<h5 className="text-sm font-medium text-[var(--muted-foreground)] mb-2">Command</h5>
|
||||
<code className="block bg-[var(--background)] p-2 rounded text-sm">
|
||||
{configItem.mcpServers.command} {configItem.mcpServers.arguments?.join(' ')}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
{configItem.mcpServers?.env && Object.keys(configItem.mcpServers.env).length > 0 && (
|
||||
<div>
|
||||
<h5 className="text-sm font-medium text-[var(--muted-foreground)] mb-2">Environment Variables</h5>
|
||||
<div className="space-y-2">
|
||||
{Object.entries(configItem.mcpServers.env).map(([key, value]) => (
|
||||
<div key={key} className="flex items-center gap-2">
|
||||
<code className="bg-[var(--background)] px-2 py-1 rounded text-sm font-mono">
|
||||
{key}={String(value)}
|
||||
</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-[var(--muted-foreground)]">
|
||||
<Package className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
||||
<p>No configuration available for this MCP</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{mcp.tools && mcp.tools.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3">Available Tools</h3>
|
||||
<div className="space-y-3">
|
||||
{mcp.tools.map((tool, index) => (
|
||||
<div key={index} className="bg-[var(--surface)] border border-[var(--border)] rounded-lg p-4">
|
||||
<h4 className="font-medium mb-2">{tool.name}</h4>
|
||||
<p className="text-sm text-[var(--muted-foreground)] mb-3">{tool.description}</p>
|
||||
{tool.input_schema && (
|
||||
<div>
|
||||
<h5 className="text-sm font-medium text-[var(--muted-foreground)] mb-2">Parameters</h5>
|
||||
<div className="space-y-2">
|
||||
{Object.entries(tool.input_schema.properties || {}).map(([param, schema]) => (
|
||||
<div key={param} className="flex items-center gap-2">
|
||||
<code className="bg-[var(--background)] px-2 py-1 rounded text-sm font-mono">
|
||||
{param}
|
||||
</code>
|
||||
<span className="text-xs text-[var(--muted-foreground)]">
|
||||
{typeof schema === 'object' && schema ? (schema as any).type || 'any' : 'any'}
|
||||
{tool.input_schema.required?.includes(param) && (
|
||||
<span className="text-red-500 ml-1">*</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "screenshots" && (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3">Screenshots</h3>
|
||||
{mcp.screenshotUrls && mcp.screenshotUrls.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{mcp.screenshotUrls.map((url, index) => (
|
||||
<img
|
||||
key={index}
|
||||
src={url}
|
||||
alt={`Screenshot ${index + 1}`}
|
||||
className="w-full h-48 object-cover rounded-md border border-[var(--border)]"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12 text-[var(--muted-foreground)]">
|
||||
<Package className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
||||
<p>No screenshots available</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-6 border-t border-[var(--border)]">
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 bg-[var(--surface)] border border-[var(--border)] rounded-md hover:bg-[var(--background)] transition-colors"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
{!mcp.installed && (
|
||||
<button
|
||||
onClick={onInstall}
|
||||
className="px-4 py-2 bg-[var(--primary)] text-[var(--primary-foreground)] rounded-md hover:bg-[var(--primary)]/90 transition-colors mcp-install-button"
|
||||
>
|
||||
Install MCP
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { X, Search, Filter, Package, Star, Download, Eye } from "lucide-react";
|
||||
import { MCPProvider } from "@/lib/types/mcp";
|
||||
import { MCPCard } from "./MCPCard";
|
||||
import { MCPCategoryFilter } from "./MCPCategoryFilter";
|
||||
import { MCPSearchBar } from "./MCPSearchBar";
|
||||
|
||||
interface MCPMarketplaceModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function MCPMarketplaceModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
}: MCPMarketplaceModalProps) {
|
||||
const [mcps, setMcps] = useState<MCPProvider[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedCategory, setSelectedCategory] = useState("all");
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState("");
|
||||
const [sortBy, setSortBy] = useState<
|
||||
"rating" | "downloads" | "name" | "recent" | "total_ratings" | "updated_at"
|
||||
>("rating");
|
||||
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc");
|
||||
const [selectedMCP, setSelectedMCP] = useState<MCPProvider | null>(null);
|
||||
const [syncStatus, setSyncStatus] = useState<{
|
||||
isSyncing: boolean;
|
||||
lastSync: number | null;
|
||||
totalCached: number;
|
||||
cacheValid: boolean;
|
||||
} | null>(null);
|
||||
const [categories, setCategories] = useState<{
|
||||
primary: Array<{ category: string; count: number }>;
|
||||
others: Array<{ category: string; count: number }>;
|
||||
}>({ primary: [], others: [] });
|
||||
|
||||
const fetchMCPs = useCallback(async () => {
|
||||
console.log("Fetching MCPs with params:", {
|
||||
selectedCategory,
|
||||
debouncedSearchTerm,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
});
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (selectedCategory !== "all")
|
||||
params.append("category", selectedCategory);
|
||||
if (debouncedSearchTerm) params.append("search", debouncedSearchTerm);
|
||||
params.append("sort", sortBy);
|
||||
params.append("order", sortOrder);
|
||||
params.append("limit", "2000"); // Aumentado para 2000
|
||||
|
||||
const response = await fetch(`/api/mcp/list?${params}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
setMcps(data.mcps);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching MCPs:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [selectedCategory, debouncedSearchTerm, sortBy, sortOrder]);
|
||||
|
||||
const fetchCategories = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetch("/api/mcp/categories");
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
setCategories(data.categories);
|
||||
console.log("📊 Categories loaded:", data.categories);
|
||||
} else {
|
||||
console.error("Failed to fetch categories:", data.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching categories:", error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleInstall = async (mcpId: string) => {
|
||||
try {
|
||||
const response = await fetch("/api/mcp/install", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ mcpId }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
// Atualizar estado local
|
||||
setMcps((prev) =>
|
||||
prev.map((mcp) =>
|
||||
mcp.id === mcpId ? { ...mcp, installed: true } : mcp
|
||||
)
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error installing MCP:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUninstall = async (mcpId: string) => {
|
||||
try {
|
||||
const response = await fetch("/api/mcp/uninstall", {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ mcpId }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
// Atualizar estado local
|
||||
setMcps((prev) =>
|
||||
prev.map((mcp) =>
|
||||
mcp.id === mcpId ? { ...mcp, installed: false } : mcp
|
||||
)
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error uninstalling MCP:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleViewDetails = (mcp: MCPProvider) => {
|
||||
setSelectedMCP(mcp);
|
||||
};
|
||||
|
||||
// Carregar MCPs e categorias quando o modal abrir
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
fetchMCPs();
|
||||
fetchCategories();
|
||||
}
|
||||
}, [isOpen]); // Removido fetchMCPs das dependências
|
||||
|
||||
// Debounce para searchTerm
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setDebouncedSearchTerm(searchTerm);
|
||||
}, 500);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchTerm]);
|
||||
|
||||
// Fetch sync status when modal opens
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
fetch("/api/mcp/sync")
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
setSyncStatus({
|
||||
isSyncing: data.isSyncing,
|
||||
lastSync: data.lastSync,
|
||||
totalCached: data.totalCached,
|
||||
cacheValid: data.cacheValid,
|
||||
});
|
||||
})
|
||||
.catch(console.error);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Recarregar MCPs quando filtros mudarem (só se modal estiver aberto)
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
fetchMCPs();
|
||||
}
|
||||
}, [selectedCategory, debouncedSearchTerm, sortBy, sortOrder, isOpen]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="fixed inset-0 bg-black/50" onClick={onClose} />
|
||||
|
||||
<div className="relative w-full max-w-7xl h-[95vh] bg-[var(--background)] border border-[var(--border)] rounded-lg shadow-xl flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-[var(--border)] flex-shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<Package className="h-6 w-6 text-[var(--primary)]" />
|
||||
<h2 className="text-xl font-semibold">MCP Marketplace</h2>
|
||||
<span className="text-sm text-[var(--muted-foreground)]">
|
||||
{mcps.length} MCPs available
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-[var(--surface)] rounded-md transition-colors"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Sync Status Banner */}
|
||||
{syncStatus?.isSyncing && (
|
||||
<div className="bg-blue-50 border-b border-blue-200 px-4 py-2 text-sm text-blue-700 flex-shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="animate-spin h-4 w-4 border-2 border-blue-600 border-t-transparent rounded-full"></div>
|
||||
<span>
|
||||
Sincronizando marketplace... {syncStatus.totalCached} servidores
|
||||
carregados
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{syncStatus?.lastSync && !syncStatus.isSyncing && (
|
||||
<div className="bg-green-50 border-b border-green-200 px-4 py-2 text-sm text-green-700 flex-shrink-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<span>
|
||||
Cache local ativo • {syncStatus.totalCached} servidores • Última
|
||||
atualização: {new Date(syncStatus.lastSync).toLocaleString()}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
fetch("/api/mcp/sync", { method: "POST" })
|
||||
.then(() => {
|
||||
// Recarregar status após iniciar sync
|
||||
fetch("/api/mcp/sync")
|
||||
.then((r) => r.json())
|
||||
.then((data) =>
|
||||
setSyncStatus({
|
||||
isSyncing: data.isSyncing,
|
||||
lastSync: data.lastSync,
|
||||
totalCached: data.totalCached,
|
||||
cacheValid: data.cacheValid,
|
||||
})
|
||||
);
|
||||
})
|
||||
.catch(console.error);
|
||||
}}
|
||||
className="px-2 py-1 bg-green-600 text-white text-xs rounded hover:bg-green-700 transition-colors"
|
||||
>
|
||||
Atualizar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex flex-1 min-h-0">
|
||||
{/* Sidebar */}
|
||||
<div className="w-64 border-r border-[var(--border)] p-4 flex-shrink-0 overflow-y-auto">
|
||||
<MCPCategoryFilter
|
||||
primaryCategories={categories.primary.map((cat) => ({
|
||||
id: cat.category.toLowerCase(),
|
||||
name: cat.category,
|
||||
icon: cat.category,
|
||||
count: cat.count,
|
||||
}))}
|
||||
otherCategories={categories.others.map((cat) => ({
|
||||
id: cat.category.toLowerCase(),
|
||||
name: cat.category,
|
||||
icon: cat.category,
|
||||
count: cat.count,
|
||||
}))}
|
||||
selectedCategory={selectedCategory}
|
||||
onSelectCategory={setSelectedCategory}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
{/* Search and Filters */}
|
||||
<div className="p-4 border-b border-[var(--border)] flex-shrink-0">
|
||||
<div className="flex gap-4 items-center">
|
||||
<MCPSearchBar
|
||||
value={searchTerm}
|
||||
onChange={setSearchTerm}
|
||||
onSearch={() => {
|
||||
// O useEffect já cuida de recarregar quando searchTerm muda
|
||||
}}
|
||||
/>
|
||||
<div className="flex gap-2 items-center">
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={(e) => setSortBy(e.target.value as any)}
|
||||
className="px-3 py-2 bg-[var(--surface)] border border-[var(--border)] rounded-md text-sm"
|
||||
>
|
||||
<option value="rating">Por Avaliação</option>
|
||||
<option value="downloads">Por Downloads</option>
|
||||
<option value="name">Por Nome</option>
|
||||
<option value="recent">Mais Recentes</option>
|
||||
<option value="total_ratings">Por Nº de Reviews</option>
|
||||
<option value="updated_at">Atualizados Recentemente</option>
|
||||
</select>
|
||||
|
||||
<button
|
||||
onClick={() =>
|
||||
setSortOrder((prev) => (prev === "asc" ? "desc" : "asc"))
|
||||
}
|
||||
className="px-3 py-2 border border-[var(--border)] rounded-md hover:bg-[var(--surface)] transition-colors"
|
||||
title={
|
||||
sortOrder === "asc"
|
||||
? "Ordem Crescente"
|
||||
: "Ordem Decrescente"
|
||||
}
|
||||
>
|
||||
{sortOrder === "asc" ? "↑" : "↓"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* MCP Grid */}
|
||||
<div className="flex-1 p-4 overflow-y-auto">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[var(--primary)]"></div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{mcps.map((mcp) => (
|
||||
<MCPCard
|
||||
key={mcp.id}
|
||||
mcp={mcp}
|
||||
onInstall={() => handleInstall(mcp.id)}
|
||||
onUninstall={() => handleUninstall(mcp.id)}
|
||||
onViewDetails={() => handleViewDetails(mcp)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && mcps.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center h-64 text-[var(--muted-foreground)]">
|
||||
<Package className="h-12 w-12 mb-4 opacity-50" />
|
||||
<p>No MCPs found</p>
|
||||
<p className="text-sm">
|
||||
Try adjusting your search or filters
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
import { Star } from "lucide-react";
|
||||
|
||||
interface MCPRatingStarsProps {
|
||||
rating: number;
|
||||
size?: "sm" | "md" | "lg";
|
||||
showNumber?: boolean;
|
||||
}
|
||||
|
||||
export function MCPRatingStars({
|
||||
rating,
|
||||
size = "sm",
|
||||
showNumber = true,
|
||||
}: MCPRatingStarsProps) {
|
||||
const sizeClasses = {
|
||||
sm: "h-3 w-3",
|
||||
md: "h-4 w-4",
|
||||
lg: "h-5 w-5",
|
||||
};
|
||||
|
||||
const stars = [];
|
||||
const fullStars = Math.floor(rating);
|
||||
const hasHalfStar = rating % 1 >= 0.5;
|
||||
|
||||
// Renderizar estrelas cheias
|
||||
for (let i = 0; i < fullStars; i++) {
|
||||
stars.push(
|
||||
<Star
|
||||
key={i}
|
||||
className={`${sizeClasses[size]} fill-yellow-400 text-yellow-400`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Renderizar meia estrela se necessário
|
||||
if (hasHalfStar) {
|
||||
stars.push(
|
||||
<div key="half" className="relative">
|
||||
<Star className={`${sizeClasses[size]} text-gray-300`} />
|
||||
<div
|
||||
className="absolute inset-0 overflow-hidden"
|
||||
style={{ width: "50%" }}
|
||||
>
|
||||
<Star
|
||||
className={`${sizeClasses[size]} fill-yellow-400 text-yellow-400`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Renderizar estrelas vazias
|
||||
const emptyStars = 5 - Math.ceil(rating);
|
||||
for (let i = 0; i < emptyStars; i++) {
|
||||
stars.push(
|
||||
<Star
|
||||
key={`empty-${i}`}
|
||||
className={`${sizeClasses[size]} text-gray-300`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex">{stars}</div>
|
||||
{showNumber && (
|
||||
<span className="text-sm text-[var(--muted-foreground)] ml-1">
|
||||
{rating.toFixed(1)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import { Search } from "lucide-react";
|
||||
|
||||
interface MCPSearchBarProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onSearch: () => void;
|
||||
}
|
||||
|
||||
export function MCPSearchBar({ value, onChange, onSearch }: MCPSearchBarProps) {
|
||||
const handleKeyPress = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
onSearch();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-[var(--muted-foreground)]" />
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onKeyPress={handleKeyPress}
|
||||
placeholder="Search MCPs..."
|
||||
className="w-full pl-10 pr-4 py-2 bg-[var(--surface)] border border-[var(--border)] rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-[var(--primary)] focus:border-transparent mcp-search-input"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user