mirror of
https://github.com/samanhappy/mcphub.git
synced 2025-12-24 02:39:19 -05:00
Refactor cloud and market pages for improved functionality and UI consistency (#265)
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { BrowserRouter as Router, Route, Routes, Navigate } from 'react-router-dom';
|
import { BrowserRouter as Router, Route, Routes, Navigate, useParams } from 'react-router-dom';
|
||||||
import { AuthProvider } from './contexts/AuthContext';
|
import { AuthProvider } from './contexts/AuthContext';
|
||||||
import { ToastProvider } from './contexts/ToastContext';
|
import { ToastProvider } from './contexts/ToastContext';
|
||||||
import { ThemeProvider } from './contexts/ThemeContext';
|
import { ThemeProvider } from './contexts/ThemeContext';
|
||||||
@@ -12,10 +12,15 @@ import GroupsPage from './pages/GroupsPage';
|
|||||||
import UsersPage from './pages/UsersPage';
|
import UsersPage from './pages/UsersPage';
|
||||||
import SettingsPage from './pages/SettingsPage';
|
import SettingsPage from './pages/SettingsPage';
|
||||||
import MarketPage from './pages/MarketPage';
|
import MarketPage from './pages/MarketPage';
|
||||||
import CloudPage from './pages/CloudPage';
|
|
||||||
import LogsPage from './pages/LogsPage';
|
import LogsPage from './pages/LogsPage';
|
||||||
import { getBasePath } from './utils/runtime';
|
import { getBasePath } from './utils/runtime';
|
||||||
|
|
||||||
|
// Helper component to redirect cloud server routes to market
|
||||||
|
const CloudRedirect: React.FC = () => {
|
||||||
|
const { serverName } = useParams<{ serverName: string }>();
|
||||||
|
return <Navigate to={`/market/${serverName}?tab=cloud`} replace />;
|
||||||
|
};
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const basename = getBasePath();
|
const basename = getBasePath();
|
||||||
return (
|
return (
|
||||||
@@ -36,8 +41,12 @@ function App() {
|
|||||||
<Route path="/users" element={<UsersPage />} />
|
<Route path="/users" element={<UsersPage />} />
|
||||||
<Route path="/market" element={<MarketPage />} />
|
<Route path="/market" element={<MarketPage />} />
|
||||||
<Route path="/market/:serverName" element={<MarketPage />} />
|
<Route path="/market/:serverName" element={<MarketPage />} />
|
||||||
<Route path="/cloud" element={<CloudPage />} />
|
{/* Legacy cloud routes redirect to market with cloud tab */}
|
||||||
<Route path="/cloud/:serverName" element={<CloudPage />} />
|
<Route path="/cloud" element={<Navigate to="/market?tab=cloud" replace />} />
|
||||||
|
<Route
|
||||||
|
path="/cloud/:serverName"
|
||||||
|
element={<CloudRedirect />}
|
||||||
|
/>
|
||||||
<Route path="/logs" element={<LogsPage />} />
|
<Route path="/logs" element={<LogsPage />} />
|
||||||
<Route path="/settings" element={<SettingsPage />} />
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ const CloudServerCard: React.FC<CloudServerCardProps> = ({ server, onClick }) =>
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="bg-white border border-gray-200 rounded-xl p-6 hover:shadow-lg hover:border-blue-400 hover:-translate-y-1 transition-all duration-300 cursor-pointer group relative overflow-hidden h-full flex flex-col"
|
className="bg-white border border-gray-200 rounded-xl p-4 hover:shadow-lg hover:border-blue-400 hover:-translate-y-1 transition-all duration-300 cursor-pointer group relative overflow-hidden h-full flex flex-col"
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
>
|
>
|
||||||
{/* Background gradient overlay on hover */}
|
{/* Background gradient overlay on hover */}
|
||||||
@@ -68,15 +68,15 @@ const CloudServerCard: React.FC<CloudServerCardProps> = ({ server, onClick }) =>
|
|||||||
|
|
||||||
{/* Server Header */}
|
{/* Server Header */}
|
||||||
<div className="relative z-10 flex-1 flex flex-col">
|
<div className="relative z-10 flex-1 flex flex-col">
|
||||||
<div className="flex items-start justify-between mb-4">
|
<div className="flex items-start justify-between mb-3">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<h3 className="text-xl font-bold text-gray-900 group-hover:text-blue-600 transition-colors duration-200 mb-2 line-clamp-2">
|
<h3 className="text-lg font-bold text-gray-900 group-hover:text-blue-600 transition-colors duration-200 mb-2 line-clamp-2">
|
||||||
{server.title || server.name}
|
{server.title || server.name}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
{/* Author Section */}
|
{/* Author Section */}
|
||||||
<div className="flex items-center space-x-3 mb-3">
|
<div className="flex items-center space-x-2 mb-2">
|
||||||
<div className="w-8 h-8 bg-gradient-to-br from-blue-500 to-purple-600 rounded-full flex items-center justify-center text-white text-sm font-semibold">
|
<div className="w-7 h-7 bg-gradient-to-br from-blue-500 to-purple-600 rounded-full flex items-center justify-center text-white text-xs font-semibold">
|
||||||
{getAuthorInitials(server.author_name)}
|
{getAuthorInitials(server.author_name)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -99,15 +99,15 @@ const CloudServerCard: React.FC<CloudServerCardProps> = ({ server, onClick }) =>
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
<div className="mb-4 flex-1">
|
<div className="mb-3 flex-1">
|
||||||
<p className="text-gray-600 text-sm leading-relaxed line-clamp-3">
|
<p className="text-gray-600 text-sm leading-relaxed line-clamp-2">
|
||||||
{getDisplayDescription()}
|
{getDisplayDescription()}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tools Info */}
|
{/* Tools Info */}
|
||||||
{server.tools && server.tools.length > 0 && (
|
{server.tools && server.tools.length > 0 && (
|
||||||
<div className="mb-4">
|
<div className="mb-3">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<svg className="w-4 h-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-4 h-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||||
@@ -121,7 +121,7 @@ const CloudServerCard: React.FC<CloudServerCardProps> = ({ server, onClick }) =>
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Footer - 固定在底部 */}
|
{/* Footer - 固定在底部 */}
|
||||||
<div className="flex items-center justify-between pt-4 border-t border-gray-100 mt-auto">
|
<div className="flex items-center justify-between pt-3 border-t border-gray-100 mt-auto">
|
||||||
<div className="flex items-center space-x-2 text-xs text-gray-500">
|
<div className="flex items-center space-x-2 text-xs text-gray-500">
|
||||||
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
||||||
<path fillRule="evenodd" d="M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" clipRule="evenodd" />
|
<path fillRule="evenodd" d="M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" clipRule="evenodd" />
|
||||||
|
|||||||
@@ -10,6 +10,16 @@ interface MarketServerCardProps {
|
|||||||
const MarketServerCard: React.FC<MarketServerCardProps> = ({ server, onClick }) => {
|
const MarketServerCard: React.FC<MarketServerCardProps> = ({ server, onClick }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
// Get initials for avatar
|
||||||
|
const getAuthorInitials = (name: string) => {
|
||||||
|
return name
|
||||||
|
.split(' ')
|
||||||
|
.map(word => word.charAt(0))
|
||||||
|
.join('')
|
||||||
|
.toUpperCase()
|
||||||
|
.slice(0, 2);
|
||||||
|
};
|
||||||
|
|
||||||
// Intelligently calculate how many tags to display to ensure they fit in a single line
|
// Intelligently calculate how many tags to display to ensure they fit in a single line
|
||||||
const getTagsToDisplay = () => {
|
const getTagsToDisplay = () => {
|
||||||
if (!server.tags || server.tags.length === 0) {
|
if (!server.tags || server.tags.length === 0) {
|
||||||
@@ -80,70 +90,89 @@ const MarketServerCard: React.FC<MarketServerCardProps> = ({ server, onClick })
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="bg-white rounded-lg shadow-md p-5 hover:shadow-lg transition-all duration-200 cursor-pointer flex flex-col h-full page-card"
|
className="bg-white border border-gray-200 rounded-xl p-4 hover:shadow-lg hover:border-blue-400 hover:-translate-y-1 transition-all duration-300 cursor-pointer group relative overflow-hidden h-full flex flex-col"
|
||||||
onClick={() => onClick(server)}
|
onClick={() => onClick(server)}
|
||||||
>
|
>
|
||||||
<div className="flex justify-between items-start mb-3">
|
{/* Background gradient overlay on hover */}
|
||||||
<h3 className="text-lg font-semibold text-gray-900 line-clamp-1 mr-2">{server.display_name}</h3>
|
<div className="absolute inset-0 bg-gradient-to-br from-blue-50/0 to-purple-50/0 group-hover:from-blue-50/30 group-hover:to-purple-50/30 transition-all duration-300 pointer-events-none" />
|
||||||
{server.is_official && (
|
|
||||||
<span className="text-xs font-medium px-2.5 py-0.5 rounded flex-shrink-0 label-primary">
|
|
||||||
{t('market.official')}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="text-gray-600 text-sm mb-4 line-clamp-2 min-h-[40px]">{server.description}</p>
|
|
||||||
|
|
||||||
{/* Categories */}
|
{/* Server Header */}
|
||||||
<div className="flex flex-wrap gap-1 mb-2 min-h-[28px]">
|
<div className="relative z-10 flex-1 flex flex-col">
|
||||||
{server.categories?.length > 0 ? (
|
<div className="flex items-start justify-between mb-2">
|
||||||
server.categories.map((category, index) => (
|
<div className="flex-1">
|
||||||
<span
|
<h3 className="text-lg font-bold text-gray-900 group-hover:text-blue-600 transition-colors duration-200 mb-1 line-clamp-1 mr-2">
|
||||||
key={index}
|
{server.display_name}
|
||||||
className="bg-gray-100 text-gray-800 text-xs px-2 py-1.5 rounded whitespace-nowrap"
|
</h3>
|
||||||
>
|
|
||||||
{category}
|
|
||||||
</span>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<span className="text-xs text-gray-400 py-1">-</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tags */}
|
{/* Author Section */}
|
||||||
<div className="relative mb-3 min-h-[28px] overflow-x-auto">
|
<div className="flex items-center space-x-2 mb-1">
|
||||||
{server.tags?.length > 0 ? (
|
<div className="w-6 h-6 bg-gradient-to-br from-blue-500 to-purple-600 rounded-full flex items-center justify-center text-white text-xs font-semibold">
|
||||||
<div className="flex gap-1 items-center whitespace-nowrap">
|
{getAuthorInitials(server.author?.name || t('market.unknown'))}
|
||||||
{tagsToShow.map((tag, index) => (
|
</div>
|
||||||
<span
|
<div>
|
||||||
key={index}
|
<p className="text-xs font-medium text-gray-700">{server.author?.name || t('market.unknown')}</p>
|
||||||
className="bg-green-50 text-green-700 text-xs px-2 py-1 rounded flex-shrink-0 label-secondary"
|
</div>
|
||||||
>
|
</div>
|
||||||
#{tag}
|
</div>
|
||||||
</span>
|
|
||||||
))}
|
{/* Server Type Badge */}
|
||||||
{hasMore && (
|
<div className="flex flex-col items-end space-y-2">
|
||||||
<span className="bg-gray-100 text-gray-600 text-xs px-1.5 py-1 rounded flex-shrink-0">
|
{server.is_official && (
|
||||||
+{moreCount} {t('market.moreTags')}
|
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||||
|
{t('market.official')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<span className="text-xs text-gray-400 py-1">-</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-between items-center mt-auto pt-2 text-xs text-gray-500">
|
|
||||||
<div className="overflow-hidden">
|
|
||||||
<span className="whitespace-nowrap">{t('market.by')} </span>
|
|
||||||
<span className="font-medium whitespace-nowrap overflow-hidden text-ellipsis max-w-[120px] inline-block align-bottom">
|
|
||||||
{server.author?.name || t('market.unknown')}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center flex-shrink-0">
|
|
||||||
<svg className="h-4 w-4 mr-1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
{/* Description */}
|
||||||
<path fillRule="evenodd" d="M11.3 1.046A1 1 0 0112 2v5h4a1 1 0 01.82 1.573l-7 10A1 1 0 018 18v-5H4a1 1 0 01-.82-1.573l7-10a1 1 0 011.12-.38z" clipRule="evenodd" />
|
<div className="mb-2 flex-1">
|
||||||
</svg>
|
<p className="text-gray-600 text-sm leading-relaxed line-clamp-2 min-h-[36px]">
|
||||||
<span>{server.tools?.length || 0} {t('market.tools')}</span>
|
{server.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Categories */}
|
||||||
|
<div className="mb-2">
|
||||||
|
<div className="flex flex-wrap gap-1 min-h-[24px]">
|
||||||
|
{server.categories?.length > 0 ? (
|
||||||
|
server.categories.map((category, index) => (
|
||||||
|
<span
|
||||||
|
key={index}
|
||||||
|
className="bg-gray-100 text-gray-800 text-xs px-2 py-1 rounded whitespace-nowrap"
|
||||||
|
>
|
||||||
|
{category}
|
||||||
|
</span>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-gray-400 py-1">-</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tags */}
|
||||||
|
<div className="mb-2">
|
||||||
|
<div className="relative min-h-[24px] overflow-x-auto">
|
||||||
|
{server.tags?.length > 0 ? (
|
||||||
|
<div className="flex gap-1 items-center whitespace-nowrap">
|
||||||
|
{tagsToShow.map((tag, index) => (
|
||||||
|
<span
|
||||||
|
key={index}
|
||||||
|
className="bg-green-50 text-green-700 text-xs px-2 py-1 rounded flex-shrink-0"
|
||||||
|
>
|
||||||
|
#{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{hasMore && (
|
||||||
|
<span className="bg-gray-100 text-gray-600 text-xs px-1.5 py-1 rounded flex-shrink-0">
|
||||||
|
+{moreCount} {t('market.moreTags')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-gray-400 py-1">-</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -61,15 +61,6 @@ const Sidebar: React.FC<SidebarProps> = ({ collapsed }) => {
|
|||||||
</svg>
|
</svg>
|
||||||
),
|
),
|
||||||
}] : []),
|
}] : []),
|
||||||
{
|
|
||||||
path: '/cloud',
|
|
||||||
label: t('nav.cloud'),
|
|
||||||
icon: (
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
|
||||||
<path fillRule="evenodd" d="M5.5 17a4.5 4.5 0 01-1.44-8.765 4.5 4.5 0 018.302-3.046 3.5 3.5 0 014.504 4.272A4 4 0 0115 17H5.5zm3.75-2.75a.75.75 0 001.5 0V9.66l1.95 2.1a.75.75 0 101.1-1.02l-3.25-3.5a.75.75 0 00-1.1 0l-3.25 3.5a.75.75 0 101.1 1.02l1.95-2.1v4.59z" clipRule="evenodd" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: '/market',
|
path: '/market',
|
||||||
label: t('nav.market'),
|
label: t('nav.market'),
|
||||||
|
|||||||
@@ -1,344 +0,0 @@
|
|||||||
import React, { useState } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
|
||||||
import { CloudServer, ServerConfig } from '@/types';
|
|
||||||
import { useCloudData } from '@/hooks/useCloudData';
|
|
||||||
import { useToast } from '@/contexts/ToastContext';
|
|
||||||
import { apiPost } from '@/utils/fetchInterceptor';
|
|
||||||
import CloudServerCard from '@/components/CloudServerCard';
|
|
||||||
import CloudServerDetail from '@/components/CloudServerDetail';
|
|
||||||
import MCPRouterApiKeyError from '@/components/MCPRouterApiKeyError';
|
|
||||||
import Pagination from '@/components/ui/Pagination';
|
|
||||||
|
|
||||||
const CloudPage: React.FC = () => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { serverName } = useParams<{ serverName?: string }>();
|
|
||||||
const { showToast } = useToast();
|
|
||||||
const [installing, setInstalling] = useState(false);
|
|
||||||
const [installedServers, setInstalledServers] = useState<Set<string>>(new Set());
|
|
||||||
|
|
||||||
const {
|
|
||||||
servers,
|
|
||||||
allServers,
|
|
||||||
// categories,
|
|
||||||
loading,
|
|
||||||
error,
|
|
||||||
setError,
|
|
||||||
// searchServers,
|
|
||||||
// filterByCategory,
|
|
||||||
// filterByTag,
|
|
||||||
// selectedCategory,
|
|
||||||
// selectedTag,
|
|
||||||
fetchServerTools,
|
|
||||||
callServerTool,
|
|
||||||
// Pagination
|
|
||||||
currentPage,
|
|
||||||
totalPages,
|
|
||||||
changePage,
|
|
||||||
serversPerPage,
|
|
||||||
changeServersPerPage
|
|
||||||
} = useCloudData();
|
|
||||||
|
|
||||||
// const [searchQuery, setSearchQuery] = useState('');
|
|
||||||
|
|
||||||
// const handleSearch = (e: React.FormEvent) => {
|
|
||||||
// e.preventDefault();
|
|
||||||
// searchServers(searchQuery);
|
|
||||||
// };
|
|
||||||
|
|
||||||
// const handleCategoryClick = (category: string) => {
|
|
||||||
// filterByCategory(category);
|
|
||||||
// };
|
|
||||||
|
|
||||||
// const handleClearFilters = () => {
|
|
||||||
// setSearchQuery('');
|
|
||||||
// filterByCategory('');
|
|
||||||
// filterByTag('');
|
|
||||||
// };
|
|
||||||
|
|
||||||
const handleServerClick = (server: CloudServer) => {
|
|
||||||
navigate(`/cloud/${server.name}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleBackToList = () => {
|
|
||||||
navigate('/cloud');
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCallTool = async (serverName: string, toolName: string, args: Record<string, any>) => {
|
|
||||||
try {
|
|
||||||
const result = await callServerTool(serverName, toolName, args);
|
|
||||||
showToast(t('cloud.toolCallSuccess', { toolName }), 'success');
|
|
||||||
return result;
|
|
||||||
} catch (error) {
|
|
||||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
||||||
// Don't show toast for API key errors, let the component handle it
|
|
||||||
if (!isMCPRouterApiKeyError(errorMessage)) {
|
|
||||||
showToast(t('cloud.toolCallError', { toolName, error: errorMessage }), 'error');
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper function to check if error is MCPRouter API key not configured
|
|
||||||
const isMCPRouterApiKeyError = (errorMessage: string) => {
|
|
||||||
return errorMessage === 'MCPROUTER_API_KEY_NOT_CONFIGURED' ||
|
|
||||||
errorMessage.toLowerCase().includes('mcprouter api key not configured');
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePageChange = (page: number) => {
|
|
||||||
changePage(page);
|
|
||||||
// Scroll to top of page when changing pages
|
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleChangeItemsPerPage = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
|
||||||
const newValue = parseInt(e.target.value, 10);
|
|
||||||
changeServersPerPage(newValue);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle cloud server installation
|
|
||||||
const handleInstallCloudServer = async (server: CloudServer, config: ServerConfig) => {
|
|
||||||
try {
|
|
||||||
setInstalling(true);
|
|
||||||
|
|
||||||
const payload = {
|
|
||||||
name: server.name,
|
|
||||||
config: config
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await apiPost('/servers', payload);
|
|
||||||
|
|
||||||
if (!result.success) {
|
|
||||||
const errorMessage = result?.message || t('server.addError');
|
|
||||||
showToast(errorMessage, 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update installed servers set
|
|
||||||
setInstalledServers(prev => new Set(prev).add(server.name));
|
|
||||||
showToast(t('cloud.installSuccess', { name: server.title || server.name }), 'success');
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error installing cloud server:', error);
|
|
||||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
||||||
showToast(t('cloud.installError', { error: errorMessage }), 'error');
|
|
||||||
} finally {
|
|
||||||
setInstalling(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Render detailed view if a server name is in the URL
|
|
||||||
if (serverName) {
|
|
||||||
return (
|
|
||||||
<CloudServerDetail
|
|
||||||
serverName={serverName}
|
|
||||||
onBack={handleBackToList}
|
|
||||||
onCallTool={handleCallTool}
|
|
||||||
fetchServerTools={fetchServerTools}
|
|
||||||
onInstall={handleInstallCloudServer}
|
|
||||||
installing={installing}
|
|
||||||
isInstalled={installedServers.has(serverName)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className="flex justify-between items-center mb-8">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold text-gray-900 flex items-center">
|
|
||||||
{t('cloud.title')}
|
|
||||||
<span className="text-sm text-gray-500 font-normal ml-2">
|
|
||||||
{t('cloud.subtitle').includes('提供支持') ? '由 ' : 'Powered by '}
|
|
||||||
<a
|
|
||||||
href="https://mcprouter.co/"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="external-link"
|
|
||||||
>
|
|
||||||
MCPRouter
|
|
||||||
</a>
|
|
||||||
{t('cloud.subtitle').includes('提供支持') ? ' 提供支持' : ''}
|
|
||||||
</span>
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<>
|
|
||||||
{isMCPRouterApiKeyError(error) ? (
|
|
||||||
<MCPRouterApiKeyError />
|
|
||||||
) : (
|
|
||||||
<div className="bg-red-100 border-l-4 border-red-500 text-red-700 p-4 mb-6 error-box rounded-lg">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<p>{error}</p>
|
|
||||||
<button
|
|
||||||
onClick={() => setError(null)}
|
|
||||||
className="text-red-700 hover:text-red-900 transition-colors duration-200"
|
|
||||||
>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
|
||||||
<path fillRule="evenodd" d="M4.293 4.293a1 1 011.414 0L10 8.586l4.293-4.293a1 1 01.414 1.414L11.414 10l4.293 4.293a1 1 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 01-1.414-1.414L8.586 10 4.293 5.707a1 1 010-1.414z" clipRule="evenodd" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Search bar at the top
|
|
||||||
<div className="bg-white shadow rounded-lg p-6 mb-6 page-card">
|
|
||||||
<form onSubmit={handleSearch} className="flex space-x-4 mb-0">
|
|
||||||
<div className="flex-grow">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
|
||||||
placeholder={t('cloud.searchPlaceholder')}
|
|
||||||
className="shadow appearance-none border border-gray-200 rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline form-input"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="px-4 py-2 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 flex items-center btn-primary transition-all duration-200"
|
|
||||||
>
|
|
||||||
{t('cloud.search')}
|
|
||||||
</button>
|
|
||||||
{(searchQuery || selectedCategory || selectedTag) && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleClearFilters}
|
|
||||||
className="border border-gray-300 text-gray-700 font-medium py-2 px-4 rounded hover:bg-gray-50 btn-secondary transition-all duration-200"
|
|
||||||
>
|
|
||||||
{t('cloud.clearFilters')}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
*/}
|
|
||||||
|
|
||||||
<div className="flex flex-col md:flex-row gap-6">
|
|
||||||
{/* Left sidebar for filters
|
|
||||||
<div className="md:w-48 flex-shrink-0">
|
|
||||||
<div className="bg-white shadow rounded-lg p-4 mb-6 sticky top-4 page-card">
|
|
||||||
{categories.length > 0 ? (
|
|
||||||
<div className="mb-6">
|
|
||||||
<div className="flex justify-between items-center mb-3">
|
|
||||||
<h3 className="font-medium text-gray-900">{t('cloud.categories')}</h3>
|
|
||||||
{selectedCategory && (
|
|
||||||
<span className="text-xs text-blue-600 cursor-pointer hover:underline transition-colors duration-200" onClick={() => filterByCategory('')}>
|
|
||||||
{t('cloud.clearCategoryFilter')}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
{categories.map((category) => (
|
|
||||||
<button
|
|
||||||
key={category}
|
|
||||||
onClick={() => handleCategoryClick(category)}
|
|
||||||
className={`px-3 py-2 rounded text-sm text-left transition-all duration-200 ${selectedCategory === category
|
|
||||||
? 'bg-blue-100 text-blue-800 font-medium btn-primary'
|
|
||||||
: 'bg-gray-100 text-gray-800 hover:bg-gray-200 btn-secondary'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{category}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : loading ? (
|
|
||||||
<div className="mb-6">
|
|
||||||
<div className="mb-3">
|
|
||||||
<h3 className="font-medium text-gray-900">{t('cloud.categories')}</h3>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2 items-center py-4 loading-container">
|
|
||||||
<svg className="animate-spin h-6 w-6 text-blue-500 mb-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
|
||||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
|
||||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
|
||||||
</svg>
|
|
||||||
<p className="text-sm text-gray-600">{t('app.loading')}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="mb-6">
|
|
||||||
<div className="mb-3">
|
|
||||||
<h3 className="font-medium text-gray-900">{t('cloud.categories')}</h3>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-gray-600 py-2">{t('cloud.noCategories')}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
*/}
|
|
||||||
|
|
||||||
{/* Main content area */}
|
|
||||||
<div className="flex-grow">
|
|
||||||
{loading ? (
|
|
||||||
<div className="bg-white shadow rounded-lg p-6 flex items-center justify-center">
|
|
||||||
<div className="flex flex-col items-center">
|
|
||||||
<svg className="animate-spin h-10 w-10 text-blue-500 mb-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
|
||||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
|
||||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
|
||||||
</svg>
|
|
||||||
<p className="text-gray-600">{t('app.loading')}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : servers.length === 0 ? (
|
|
||||||
<div className="bg-white shadow rounded-lg p-6">
|
|
||||||
<p className="text-gray-600">{t('cloud.noServers')}</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3 gap-6">
|
|
||||||
{servers.map((server, index) => (
|
|
||||||
<CloudServerCard
|
|
||||||
key={index}
|
|
||||||
server={server}
|
|
||||||
onClick={handleServerClick}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-between items-center mb-4">
|
|
||||||
<div className="text-sm text-gray-500">
|
|
||||||
{t('cloud.showing', {
|
|
||||||
from: (currentPage - 1) * serversPerPage + 1,
|
|
||||||
to: Math.min(currentPage * serversPerPage, allServers.length),
|
|
||||||
total: allServers.length
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<Pagination
|
|
||||||
currentPage={currentPage}
|
|
||||||
totalPages={totalPages}
|
|
||||||
onPageChange={handlePageChange}
|
|
||||||
/>
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<label htmlFor="perPage" className="text-sm text-gray-600">
|
|
||||||
{t('cloud.perPage')}:
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
id="perPage"
|
|
||||||
value={serversPerPage}
|
|
||||||
onChange={handleChangeItemsPerPage}
|
|
||||||
className="border rounded p-1 text-sm btn-secondary outline-none"
|
|
||||||
>
|
|
||||||
<option value="6">6</option>
|
|
||||||
<option value="9">9</option>
|
|
||||||
<option value="12">12</option>
|
|
||||||
<option value="24">24</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6">
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default CloudPage;
|
|
||||||
@@ -1,11 +1,16 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||||
import { MarketServer, ServerConfig } from '@/types';
|
import { MarketServer, CloudServer, ServerConfig } from '@/types';
|
||||||
import { useMarketData } from '@/hooks/useMarketData';
|
import { useMarketData } from '@/hooks/useMarketData';
|
||||||
|
import { useCloudData } from '@/hooks/useCloudData';
|
||||||
import { useToast } from '@/contexts/ToastContext';
|
import { useToast } from '@/contexts/ToastContext';
|
||||||
|
import { apiPost } from '@/utils/fetchInterceptor';
|
||||||
import MarketServerCard from '@/components/MarketServerCard';
|
import MarketServerCard from '@/components/MarketServerCard';
|
||||||
import MarketServerDetail from '@/components/MarketServerDetail';
|
import MarketServerDetail from '@/components/MarketServerDetail';
|
||||||
|
import CloudServerCard from '@/components/CloudServerCard';
|
||||||
|
import CloudServerDetail from '@/components/CloudServerDetail';
|
||||||
|
import MCPRouterApiKeyError from '@/components/MCPRouterApiKeyError';
|
||||||
import Pagination from '@/components/ui/Pagination';
|
import Pagination from '@/components/ui/Pagination';
|
||||||
|
|
||||||
const MarketPage: React.FC = () => {
|
const MarketPage: React.FC = () => {
|
||||||
@@ -14,82 +19,140 @@ const MarketPage: React.FC = () => {
|
|||||||
const { serverName } = useParams<{ serverName?: string }>();
|
const { serverName } = useParams<{ serverName?: string }>();
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
|
|
||||||
|
// Get tab from URL search params, default to cloud market
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const currentTab = searchParams.get('tab') || 'cloud';
|
||||||
|
|
||||||
|
// Local market data
|
||||||
const {
|
const {
|
||||||
servers,
|
servers: localServers,
|
||||||
allServers,
|
allServers: allLocalServers,
|
||||||
categories,
|
categories: localCategories,
|
||||||
loading,
|
loading: localLoading,
|
||||||
error,
|
error: localError,
|
||||||
setError,
|
setError: setLocalError,
|
||||||
searchServers,
|
searchServers: searchLocalServers,
|
||||||
filterByCategory,
|
filterByCategory: filterLocalByCategory,
|
||||||
filterByTag,
|
filterByTag: filterLocalByTag,
|
||||||
selectedCategory,
|
selectedCategory: selectedLocalCategory,
|
||||||
selectedTag,
|
selectedTag: selectedLocalTag,
|
||||||
installServer,
|
installServer: installLocalServer,
|
||||||
fetchServerByName,
|
fetchServerByName: fetchLocalServerByName,
|
||||||
isServerInstalled,
|
isServerInstalled,
|
||||||
// Pagination
|
// Pagination
|
||||||
currentPage,
|
currentPage: localCurrentPage,
|
||||||
totalPages,
|
totalPages: localTotalPages,
|
||||||
changePage,
|
changePage: changeLocalPage,
|
||||||
serversPerPage,
|
serversPerPage: localServersPerPage,
|
||||||
changeServersPerPage
|
changeServersPerPage: changeLocalServersPerPage
|
||||||
} = useMarketData();
|
} = useMarketData();
|
||||||
|
|
||||||
|
// Cloud market data
|
||||||
|
const {
|
||||||
|
servers: cloudServers,
|
||||||
|
allServers: allCloudServers,
|
||||||
|
loading: cloudLoading,
|
||||||
|
error: cloudError,
|
||||||
|
setError: setCloudError,
|
||||||
|
fetchServerTools,
|
||||||
|
callServerTool,
|
||||||
|
// Pagination
|
||||||
|
currentPage: cloudCurrentPage,
|
||||||
|
totalPages: cloudTotalPages,
|
||||||
|
changePage: changeCloudPage,
|
||||||
|
serversPerPage: cloudServersPerPage,
|
||||||
|
changeServersPerPage: changeCloudServersPerPage
|
||||||
|
} = useCloudData();
|
||||||
|
|
||||||
const [selectedServer, setSelectedServer] = useState<MarketServer | null>(null);
|
const [selectedServer, setSelectedServer] = useState<MarketServer | null>(null);
|
||||||
|
const [selectedCloudServer, setSelectedCloudServer] = useState<CloudServer | null>(null);
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [installing, setInstalling] = useState(false);
|
const [installing, setInstalling] = useState(false);
|
||||||
|
const [installedCloudServers, setInstalledCloudServers] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
// Load server details if a server name is in the URL
|
// Load server details if a server name is in the URL
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadServerDetails = async () => {
|
const loadServerDetails = async () => {
|
||||||
if (serverName) {
|
if (serverName) {
|
||||||
const server = await fetchServerByName(serverName);
|
// Determine if it's a cloud or local server based on the current tab
|
||||||
if (server) {
|
if (currentTab === 'cloud') {
|
||||||
setSelectedServer(server);
|
// Try to find the server in cloud servers
|
||||||
|
const server = cloudServers.find(s => s.name === serverName);
|
||||||
|
if (server) {
|
||||||
|
setSelectedCloudServer(server);
|
||||||
|
} else {
|
||||||
|
// If server not found, navigate back to market page
|
||||||
|
navigate('/market?tab=cloud');
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// If server not found, navigate back to market page
|
// Local market
|
||||||
navigate('/market');
|
const server = await fetchLocalServerByName(serverName);
|
||||||
|
if (server) {
|
||||||
|
setSelectedServer(server);
|
||||||
|
} else {
|
||||||
|
// If server not found, navigate back to market page
|
||||||
|
navigate('/market?tab=local');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
setSelectedServer(null);
|
setSelectedServer(null);
|
||||||
|
setSelectedCloudServer(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
loadServerDetails();
|
loadServerDetails();
|
||||||
}, [serverName, fetchServerByName, navigate]);
|
}, [serverName, currentTab, cloudServers, fetchLocalServerByName, navigate]);
|
||||||
|
|
||||||
|
// Tab switching handler
|
||||||
|
const switchTab = (tab: 'local' | 'cloud') => {
|
||||||
|
const newSearchParams = new URLSearchParams(searchParams);
|
||||||
|
newSearchParams.set('tab', tab);
|
||||||
|
setSearchParams(newSearchParams);
|
||||||
|
// Clear any selected server when switching tabs
|
||||||
|
if (serverName) {
|
||||||
|
navigate('/market?' + newSearchParams.toString());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleSearch = (e: React.FormEvent) => {
|
const handleSearch = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
searchServers(searchQuery);
|
if (currentTab === 'local') {
|
||||||
|
searchLocalServers(searchQuery);
|
||||||
|
}
|
||||||
|
// Cloud search is not implemented in the original cloud page
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCategoryClick = (category: string) => {
|
const handleCategoryClick = (category: string) => {
|
||||||
filterByCategory(category);
|
if (currentTab === 'local') {
|
||||||
|
filterLocalByCategory(category);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClearFilters = () => {
|
const handleClearFilters = () => {
|
||||||
setSearchQuery('');
|
setSearchQuery('');
|
||||||
filterByCategory('');
|
if (currentTab === 'local') {
|
||||||
filterByTag('');
|
filterLocalByCategory('');
|
||||||
|
filterLocalByTag('');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleServerClick = (server: MarketServer) => {
|
const handleServerClick = (server: MarketServer | CloudServer) => {
|
||||||
navigate(`/market/${server.name}`);
|
if (currentTab === 'cloud') {
|
||||||
|
navigate(`/market/${server.name}?tab=cloud`);
|
||||||
|
} else {
|
||||||
|
navigate(`/market/${server.name}?tab=local`);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBackToList = () => {
|
const handleBackToList = () => {
|
||||||
navigate('/market');
|
navigate(`/market?tab=${currentTab}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleInstall = async (server: MarketServer, config: ServerConfig) => {
|
const handleLocalInstall = async (server: MarketServer, config: ServerConfig) => {
|
||||||
try {
|
try {
|
||||||
setInstalling(true);
|
setInstalling(true);
|
||||||
// Pass the server object and the config to the installServer function
|
const success = await installLocalServer(server, config);
|
||||||
const success = await installServer(server, config);
|
|
||||||
if (success) {
|
if (success) {
|
||||||
// Show success message using toast instead of alert
|
|
||||||
showToast(t('market.installSuccess', { serverName: server.display_name }), 'success');
|
showToast(t('market.installSuccess', { serverName: server.display_name }), 'success');
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -97,15 +160,75 @@ const MarketPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Handle cloud server installation
|
||||||
|
const handleCloudInstall = async (server: CloudServer, config: ServerConfig) => {
|
||||||
|
try {
|
||||||
|
setInstalling(true);
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
name: server.name,
|
||||||
|
config: config
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await apiPost('/servers', payload);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
const errorMessage = result?.message || t('server.addError');
|
||||||
|
showToast(errorMessage, 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update installed servers set
|
||||||
|
setInstalledCloudServers(prev => new Set(prev).add(server.name));
|
||||||
|
showToast(t('cloud.installSuccess', { name: server.title || server.name }), 'success');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error installing cloud server:', error);
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
showToast(t('cloud.installError', { error: errorMessage }), 'error');
|
||||||
|
} finally {
|
||||||
|
setInstalling(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCallTool = async (serverName: string, toolName: string, args: Record<string, any>) => {
|
||||||
|
try {
|
||||||
|
const result = await callServerTool(serverName, toolName, args);
|
||||||
|
showToast(t('cloud.toolCallSuccess', { toolName }), 'success');
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
// Don't show toast for API key errors, let the component handle it
|
||||||
|
if (!isMCPRouterApiKeyError(errorMessage)) {
|
||||||
|
showToast(t('cloud.toolCallError', { toolName, error: errorMessage }), 'error');
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper function to check if error is MCPRouter API key not configured
|
||||||
|
const isMCPRouterApiKeyError = (errorMessage: string) => {
|
||||||
|
return errorMessage === 'MCPROUTER_API_KEY_NOT_CONFIGURED' ||
|
||||||
|
errorMessage.toLowerCase().includes('mcprouter api key not configured');
|
||||||
|
};
|
||||||
|
|
||||||
const handlePageChange = (page: number) => {
|
const handlePageChange = (page: number) => {
|
||||||
changePage(page);
|
if (currentTab === 'local') {
|
||||||
|
changeLocalPage(page);
|
||||||
|
} else {
|
||||||
|
changeCloudPage(page);
|
||||||
|
}
|
||||||
// Scroll to top of page when changing pages
|
// Scroll to top of page when changing pages
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleChangeItemsPerPage = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
const handleChangeItemsPerPage = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||||
const newValue = parseInt(e.target.value, 10);
|
const newValue = parseInt(e.target.value, 10);
|
||||||
changeServersPerPage(newValue);
|
if (currentTab === 'local') {
|
||||||
|
changeLocalServersPerPage(newValue);
|
||||||
|
} else {
|
||||||
|
changeCloudServersPerPage(newValue);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Render detailed view if a server is selected
|
// Render detailed view if a server is selected
|
||||||
@@ -114,164 +237,201 @@ const MarketPage: React.FC = () => {
|
|||||||
<MarketServerDetail
|
<MarketServerDetail
|
||||||
server={selectedServer}
|
server={selectedServer}
|
||||||
onBack={handleBackToList}
|
onBack={handleBackToList}
|
||||||
onInstall={handleInstall}
|
onInstall={handleLocalInstall}
|
||||||
installing={installing}
|
installing={installing}
|
||||||
isInstalled={isServerInstalled(selectedServer.name)}
|
isInstalled={isServerInstalled(selectedServer.name)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Render cloud server detail if selected
|
||||||
|
if (selectedCloudServer) {
|
||||||
|
return (
|
||||||
|
<CloudServerDetail
|
||||||
|
serverName={selectedCloudServer.name}
|
||||||
|
onBack={handleBackToList}
|
||||||
|
onCallTool={handleCallTool}
|
||||||
|
fetchServerTools={fetchServerTools}
|
||||||
|
onInstall={handleCloudInstall}
|
||||||
|
installing={installing}
|
||||||
|
isInstalled={installedCloudServers.has(selectedCloudServer.name)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current data based on active tab
|
||||||
|
const isLocalTab = currentTab === 'local';
|
||||||
|
const servers = isLocalTab ? localServers : cloudServers;
|
||||||
|
const allServers = isLocalTab ? allLocalServers : allCloudServers;
|
||||||
|
const categories = isLocalTab ? localCategories : [];
|
||||||
|
const loading = isLocalTab ? localLoading : cloudLoading;
|
||||||
|
const error = isLocalTab ? localError : cloudError;
|
||||||
|
const setError = isLocalTab ? setLocalError : setCloudError;
|
||||||
|
const selectedCategory = isLocalTab ? selectedLocalCategory : '';
|
||||||
|
const selectedTag = isLocalTab ? selectedLocalTag : '';
|
||||||
|
const currentPage = isLocalTab ? localCurrentPage : cloudCurrentPage;
|
||||||
|
const totalPages = isLocalTab ? localTotalPages : cloudTotalPages;
|
||||||
|
const serversPerPage = isLocalTab ? localServersPerPage : cloudServersPerPage;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex justify-between items-center mb-8">
|
{/* Tab Navigation */}
|
||||||
<div>
|
<div className="mb-6">
|
||||||
<h1 className="text-2xl font-bold text-gray-900 flex items-center">
|
<div className="border-b border-gray-200">
|
||||||
{t('market.title')}
|
<nav className="-mb-px flex space-x-3">
|
||||||
<span className="text-sm text-gray-500 font-normal ml-2">{t('pages.market.title').split(' - ')[1]}</span>
|
<button
|
||||||
</h1>
|
onClick={() => switchTab('cloud')}
|
||||||
|
className={`py-2 px-1 border-b-2 font-medium text-lg hover:cursor-pointer transition-colors duration-200 ${!isLocalTab
|
||||||
|
? 'border-blue-500 text-blue-600'
|
||||||
|
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t('cloud.title')}
|
||||||
|
<span className="text-xs text-gray-400 font-normal ml-1">(
|
||||||
|
<a
|
||||||
|
href="https://mcprouter.co"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="external-link"
|
||||||
|
>
|
||||||
|
MCPRouter
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => switchTab('local')}
|
||||||
|
className={`py-2 px-1 border-b-2 font-medium text-lg hover:cursor-pointer transition-colors duration-200 ${isLocalTab
|
||||||
|
? 'border-blue-500 text-blue-600'
|
||||||
|
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t('market.title')}
|
||||||
|
<span className="text-xs text-gray-400 font-normal ml-1">(
|
||||||
|
<a
|
||||||
|
href="https://mcpm.sh"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="external-link"
|
||||||
|
>
|
||||||
|
MCPM
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="bg-red-100 border-l-4 border-red-500 text-red-700 p-4 mb-6 error-box rounded-lg">
|
<>
|
||||||
<div className="flex items-center justify-between">
|
{!isLocalTab && isMCPRouterApiKeyError(error) ? (
|
||||||
<p>{error}</p>
|
<MCPRouterApiKeyError />
|
||||||
|
) : (
|
||||||
|
<div className="bg-red-100 border-l-4 border-red-500 text-red-700 p-4 mb-6 error-box rounded-lg">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p>{error}</p>
|
||||||
|
<button
|
||||||
|
onClick={() => setError(null)}
|
||||||
|
className="text-red-700 hover:text-red-900 transition-colors duration-200"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path fillRule="evenodd" d="M4.293 4.293a1 1 011.414 0L10 8.586l4.293-4.293a1 1 01.414 1.414L11.414 10l4.293 4.293a1 1 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 01-1.414-1.414L8.586 10 4.293 5.707a1 1 010-1.414z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Search bar for local market only */}
|
||||||
|
{isLocalTab && (
|
||||||
|
<div className="bg-white shadow rounded-lg p-6 mb-6 page-card">
|
||||||
|
<form onSubmit={handleSearch} className="flex space-x-4 mb-0">
|
||||||
|
<div className="flex-grow">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
placeholder={t('market.searchPlaceholder')}
|
||||||
|
className="shadow appearance-none border border-gray-200 rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline form-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => setError(null)}
|
type="submit"
|
||||||
className="text-red-700 hover:text-red-900 transition-colors duration-200"
|
className="px-4 py-2 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 flex items-center btn-primary transition-all duration-200"
|
||||||
>
|
>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
{t('market.search')}
|
||||||
<path fillRule="evenodd" d="M4.293 4.293a1 1 011.414 0L10 8.586l4.293-4.293a1 1 01.414 1.414L11.414 10l4.293 4.293a1 1 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 01-1.414-1.414L8.586 10 4.293 5.707a1 1 010-1.414z" clipRule="evenodd" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
{(searchQuery || selectedCategory || selectedTag) && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClearFilters}
|
||||||
|
className="border border-gray-300 text-gray-700 font-medium py-2 px-4 rounded hover:bg-gray-50 btn-secondary transition-all duration-200"
|
||||||
|
>
|
||||||
|
{t('market.clearFilters')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Search bar at the top */}
|
|
||||||
<div className="bg-white shadow rounded-lg p-6 mb-6 page-card">
|
|
||||||
<form onSubmit={handleSearch} className="flex space-x-4 mb-0">
|
|
||||||
<div className="flex-grow">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
|
||||||
placeholder={t('market.searchPlaceholder')}
|
|
||||||
className="shadow appearance-none border border-gray-200 rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline form-input"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="px-4 py-2 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 flex items-center btn-primary transition-all duration-200"
|
|
||||||
>
|
|
||||||
{t('market.search')}
|
|
||||||
</button>
|
|
||||||
{(searchQuery || selectedCategory || selectedTag) && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleClearFilters}
|
|
||||||
className="border border-gray-300 text-gray-700 font-medium py-2 px-4 rounded hover:bg-gray-50 btn-secondary transition-all duration-200"
|
|
||||||
>
|
|
||||||
{t('market.clearFilters')}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col md:flex-row gap-6">
|
<div className="flex flex-col md:flex-row gap-6">
|
||||||
{/* Left sidebar for filters (without search) */}
|
{/* Left sidebar for filters (local market only) */}
|
||||||
<div className="md:w-48 flex-shrink-0">
|
{isLocalTab && (
|
||||||
<div className="bg-white shadow rounded-lg p-4 mb-6 sticky top-4 page-card">
|
<div className="md:w-48 flex-shrink-0">
|
||||||
{/* Categories */}
|
<div className="bg-white shadow rounded-lg p-4 mb-6 sticky top-4 page-card">
|
||||||
{categories.length > 0 ? (
|
{/* Categories */}
|
||||||
<div className="mb-6">
|
{categories.length > 0 ? (
|
||||||
<div className="flex justify-between items-center mb-3">
|
<div className="mb-6">
|
||||||
<h3 className="font-medium text-gray-900">{t('market.categories')}</h3>
|
<div className="flex justify-between items-center mb-3">
|
||||||
{selectedCategory && (
|
<h3 className="font-medium text-gray-900">{t('market.categories')}</h3>
|
||||||
<span className="text-xs text-blue-600 cursor-pointer hover:underline transition-colors duration-200" onClick={() => filterByCategory('')}>
|
{selectedCategory && (
|
||||||
{t('market.clearCategoryFilter')}
|
<span className="text-xs text-blue-600 cursor-pointer hover:underline transition-colors duration-200" onClick={() => filterLocalByCategory('')}>
|
||||||
</span>
|
{t('market.clearCategoryFilter')}
|
||||||
)}
|
</span>
|
||||||
</div>
|
)}
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
{categories.map((category) => (
|
|
||||||
<button
|
|
||||||
key={category}
|
|
||||||
onClick={() => handleCategoryClick(category)}
|
|
||||||
className={`px-3 py-2 rounded text-sm text-left transition-all duration-200 ${selectedCategory === category
|
|
||||||
? 'bg-blue-100 text-blue-800 font-medium btn-primary'
|
|
||||||
: 'bg-gray-100 text-gray-800 hover:bg-gray-200 btn-secondary'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{category}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : loading ? (
|
|
||||||
<div className="mb-6">
|
|
||||||
<div className="mb-3">
|
|
||||||
<h3 className="font-medium text-gray-900">{t('market.categories')}</h3>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2 items-center py-4 loading-container">
|
|
||||||
<svg className="animate-spin h-6 w-6 text-blue-500 mb-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
|
||||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
|
||||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
|
||||||
</svg>
|
|
||||||
<p className="text-sm text-gray-600">{t('app.loading')}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="mb-6">
|
|
||||||
<div className="mb-3">
|
|
||||||
<h3 className="font-medium text-gray-900">{t('market.categories')}</h3>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-gray-600 py-2">{t('market.noCategories')}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Tags */}
|
|
||||||
{/* {tags.length > 0 && (
|
|
||||||
<div className="mb-4">
|
|
||||||
<div className="flex justify-between items-center mb-3">
|
|
||||||
<div className="flex items-center">
|
|
||||||
<h3 className="font-medium text-gray-900">{t('market.tags')}</h3>
|
|
||||||
<button
|
|
||||||
onClick={toggleTagsVisibility}
|
|
||||||
className="ml-2 p-1 text-gray-600 hover:text-blue-600 hover:bg-gray-100 rounded-full"
|
|
||||||
aria-label={showTags ? t('market.hideTags') : t('market.showTags')}
|
|
||||||
>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" className={`h-5 w-5 transition-transform ${showTags ? 'rotate-180' : ''}`} viewBox="0 0 20 20" fill="currentColor">
|
|
||||||
<path fillRule="evenodd" d="M5.293 7.293a1 1 011.414 0L10 10.586l3.293-3.293a1 1 011.414 1.414l-4 4a1 1 01-1.414 0l-4-4a1 1 010-1.414z" clipRule="evenodd" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
{selectedTag && (
|
<div className="flex flex-col gap-2">
|
||||||
<span className="text-xs text-blue-600 cursor-pointer hover:underline" onClick={() => filterByTag('')}>
|
{categories.map((category) => (
|
||||||
{t('market.clearTagFilter')}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{showTags && (
|
|
||||||
<div className="flex flex-wrap gap-2 max-h-48 overflow-y-auto pr-2">
|
|
||||||
{tags.map((tag) => (
|
|
||||||
<button
|
<button
|
||||||
key={tag}
|
key={category}
|
||||||
onClick={() => handleTagClick(tag)}
|
onClick={() => handleCategoryClick(category)}
|
||||||
className={`px-2 py-1 rounded text-xs ${selectedTag === tag
|
className={`px-3 py-2 rounded text-sm text-left transition-all duration-200 ${selectedCategory === category
|
||||||
? 'bg-green-100 text-green-800 font-medium'
|
? 'bg-blue-100 text-blue-800 font-medium btn-primary'
|
||||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
: 'bg-gray-100 text-gray-800 hover:bg-gray-200 btn-secondary'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
#{tag}
|
{category}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</div>
|
) : loading ? (
|
||||||
)} */}
|
<div className="mb-6">
|
||||||
|
<div className="mb-3">
|
||||||
|
<h3 className="font-medium text-gray-900">{t('market.categories')}</h3>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2 items-center py-4 loading-container">
|
||||||
|
<svg className="animate-spin h-6 w-6 text-blue-500 mb-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
<p className="text-sm text-gray-600">{t('app.loading')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mb-6">
|
||||||
|
<div className="mb-3">
|
||||||
|
<h3 className="font-medium text-gray-900">{t('market.categories')}</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-600 py-2">{t('market.noCategories')}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
{/* Main content area */}
|
{/* Main content area */}
|
||||||
<div className="flex-grow">
|
<div className="flex-grow">
|
||||||
@@ -287,27 +447,43 @@ const MarketPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
) : servers.length === 0 ? (
|
) : servers.length === 0 ? (
|
||||||
<div className="bg-white shadow rounded-lg p-6">
|
<div className="bg-white shadow rounded-lg p-6">
|
||||||
<p className="text-gray-600">{t('market.noServers')}</p>
|
<p className="text-gray-600">{isLocalTab ? t('market.noServers') : t('cloud.noServers')}</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3 gap-6">
|
||||||
{servers.map((server, index) => (
|
{servers.map((server, index) => (
|
||||||
<MarketServerCard
|
isLocalTab ? (
|
||||||
key={index}
|
<MarketServerCard
|
||||||
server={server}
|
key={index}
|
||||||
onClick={handleServerClick}
|
server={server as MarketServer}
|
||||||
/>
|
onClick={handleServerClick}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<CloudServerCard
|
||||||
|
key={index}
|
||||||
|
server={server as CloudServer}
|
||||||
|
onClick={handleServerClick}
|
||||||
|
/>
|
||||||
|
)
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-between items-center mb-4">
|
<div className="flex justify-between items-center mb-4">
|
||||||
<div className="text-sm text-gray-500">
|
<div className="text-sm text-gray-500">
|
||||||
{t('market.showing', {
|
{isLocalTab ? (
|
||||||
from: (currentPage - 1) * serversPerPage + 1,
|
t('market.showing', {
|
||||||
to: Math.min(currentPage * serversPerPage, allServers.length),
|
from: (currentPage - 1) * serversPerPage + 1,
|
||||||
total: allServers.length
|
to: Math.min(currentPage * serversPerPage, allServers.length),
|
||||||
})}
|
total: allServers.length
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
t('cloud.showing', {
|
||||||
|
from: (currentPage - 1) * serversPerPage + 1,
|
||||||
|
to: Math.min(currentPage * serversPerPage, allServers.length),
|
||||||
|
total: allServers.length
|
||||||
|
})
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Pagination
|
<Pagination
|
||||||
currentPage={currentPage}
|
currentPage={currentPage}
|
||||||
@@ -316,7 +492,7 @@ const MarketPage: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<label htmlFor="perPage" className="text-sm text-gray-600">
|
<label htmlFor="perPage" className="text-sm text-gray-600">
|
||||||
{t('market.perPage')}:
|
{isLocalTab ? t('market.perPage') : t('cloud.perPage')}:
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
id="perPage"
|
id="perPage"
|
||||||
@@ -333,7 +509,6 @@ const MarketPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -60,15 +60,6 @@ const ServersPage: React.FC = () => {
|
|||||||
<div className="flex justify-between items-center mb-8">
|
<div className="flex justify-between items-center mb-8">
|
||||||
<h1 className="text-2xl font-bold text-gray-900">{t('pages.servers.title')}</h1>
|
<h1 className="text-2xl font-bold text-gray-900">{t('pages.servers.title')}</h1>
|
||||||
<div className="flex space-x-4">
|
<div className="flex space-x-4">
|
||||||
<button
|
|
||||||
onClick={() => navigate('/cloud')}
|
|
||||||
className="px-4 py-2 bg-green-100 text-green-800 rounded hover:bg-green-200 flex items-center btn-primary transition-all duration-200"
|
|
||||||
>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 mr-2" viewBox="0 0 20 20" fill="currentColor">
|
|
||||||
<path d="M5.5 16a3.5 3.5 0 01-.369-6.98 4 4 0 117.753-1.977A4.5 4.5 0 1113.5 16H11v-3.586l.293.293a1 1 0 001.414-1.414l-2-2a1 1 0 00-1.414 0l-2 2a1 1 0 001.414 1.414L9 12.414V16H5.5z" />
|
|
||||||
</svg>
|
|
||||||
{t('nav.cloud')}
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate('/market')}
|
onClick={() => navigate('/market')}
|
||||||
className="px-4 py-2 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 flex items-center btn-primary transition-all duration-200"
|
className="px-4 py-2 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 flex items-center btn-primary transition-all duration-200"
|
||||||
|
|||||||
@@ -196,7 +196,7 @@
|
|||||||
"users": "Users",
|
"users": "Users",
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
"changePassword": "Change Password",
|
"changePassword": "Change Password",
|
||||||
"market": "Local Market",
|
"market": "Market",
|
||||||
"cloud": "Cloud Market",
|
"cloud": "Cloud Market",
|
||||||
"logs": "Logs"
|
"logs": "Logs"
|
||||||
},
|
},
|
||||||
@@ -229,7 +229,7 @@
|
|||||||
"smartRouting": "Smart Routing"
|
"smartRouting": "Smart Routing"
|
||||||
},
|
},
|
||||||
"market": {
|
"market": {
|
||||||
"title": "Server Market - (Data from mcpm.sh)"
|
"title": "Market Hub - Local and Cloud Markets"
|
||||||
},
|
},
|
||||||
"logs": {
|
"logs": {
|
||||||
"title": "System Logs"
|
"title": "System Logs"
|
||||||
@@ -282,7 +282,7 @@
|
|||||||
"configureTools": "Configure Tools"
|
"configureTools": "Configure Tools"
|
||||||
},
|
},
|
||||||
"market": {
|
"market": {
|
||||||
"title": "Local Market",
|
"title": "Local Installation",
|
||||||
"official": "Official",
|
"official": "Official",
|
||||||
"by": "By",
|
"by": "By",
|
||||||
"unknown": "Unknown",
|
"unknown": "Unknown",
|
||||||
@@ -326,7 +326,7 @@
|
|||||||
"confirmAndInstall": "Confirm and Install"
|
"confirmAndInstall": "Confirm and Install"
|
||||||
},
|
},
|
||||||
"cloud": {
|
"cloud": {
|
||||||
"title": "Cloud Market",
|
"title": "Cloud Support",
|
||||||
"subtitle": "Powered by MCPRouter",
|
"subtitle": "Powered by MCPRouter",
|
||||||
"by": "By",
|
"by": "By",
|
||||||
"server": "Server",
|
"server": "Server",
|
||||||
|
|||||||
@@ -197,7 +197,7 @@
|
|||||||
"changePassword": "修改密码",
|
"changePassword": "修改密码",
|
||||||
"groups": "分组",
|
"groups": "分组",
|
||||||
"users": "用户",
|
"users": "用户",
|
||||||
"market": "本地市场",
|
"market": "市场",
|
||||||
"cloud": "云端市场",
|
"cloud": "云端市场",
|
||||||
"logs": "日志"
|
"logs": "日志"
|
||||||
},
|
},
|
||||||
@@ -230,7 +230,7 @@
|
|||||||
"title": "用户管理"
|
"title": "用户管理"
|
||||||
},
|
},
|
||||||
"market": {
|
"market": {
|
||||||
"title": "本地市场 - (数据来源于 mcpm.sh)"
|
"title": "市场中心 - 本地市场和云端市场"
|
||||||
},
|
},
|
||||||
"logs": {
|
"logs": {
|
||||||
"title": "系统日志"
|
"title": "系统日志"
|
||||||
@@ -283,7 +283,7 @@
|
|||||||
"configureTools": "配置工具"
|
"configureTools": "配置工具"
|
||||||
},
|
},
|
||||||
"market": {
|
"market": {
|
||||||
"title": "本地市场",
|
"title": "本地安装",
|
||||||
"official": "官方",
|
"official": "官方",
|
||||||
"by": "作者",
|
"by": "作者",
|
||||||
"unknown": "未知",
|
"unknown": "未知",
|
||||||
@@ -327,7 +327,7 @@
|
|||||||
"confirmAndInstall": "确认并安装"
|
"confirmAndInstall": "确认并安装"
|
||||||
},
|
},
|
||||||
"cloud": {
|
"cloud": {
|
||||||
"title": "云端市场",
|
"title": "云端支持",
|
||||||
"subtitle": "由 MCPRouter 提供支持",
|
"subtitle": "由 MCPRouter 提供支持",
|
||||||
"by": "作者",
|
"by": "作者",
|
||||||
"server": "服务器",
|
"server": "服务器",
|
||||||
|
|||||||
Reference in New Issue
Block a user