feat: improve agents and collections list page UX
- Convert from table to card-based grid layout for better visual hierarchy - Add search functionality for both agents and collections - Add relative timestamps (e.g., "2h ago", "Yesterday") - Display provider names with colored text for agents - Add "Copy MCP URL" quick action for collections - Improve empty states with more descriptive messaging - Add loading skeletons that match new card design - Show collection MCP readiness status - Add help section explaining MCP integration
This commit is contained in:
parent
e73b7955a3
commit
a7b4c31881
2 changed files with 601 additions and 312 deletions
|
|
@ -1,21 +1,12 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import type { AIProvider } from '@tpmjs/types/agent';
|
import type { AIProvider } from '@tpmjs/types/agent';
|
||||||
import { Badge } from '@tpmjs/ui/Badge/Badge';
|
|
||||||
import { Button } from '@tpmjs/ui/Button/Button';
|
import { Button } from '@tpmjs/ui/Button/Button';
|
||||||
import { Icon } from '@tpmjs/ui/Icon/Icon';
|
import { Icon } from '@tpmjs/ui/Icon/Icon';
|
||||||
import {
|
import { Input } from '@tpmjs/ui/Input/Input';
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableEmpty,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from '@tpmjs/ui/Table/Table';
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { DashboardLayout } from '~/components/dashboard/DashboardLayout';
|
import { DashboardLayout } from '~/components/dashboard/DashboardLayout';
|
||||||
|
|
||||||
interface Agent {
|
interface Agent {
|
||||||
|
|
@ -31,29 +22,166 @@ interface Agent {
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PROVIDER_DISPLAY_NAMES: Record<AIProvider, string> = {
|
const PROVIDER_INFO: Record<AIProvider, { name: string; color: string; bgColor: string }> = {
|
||||||
OPENAI: 'OpenAI',
|
OPENAI: { name: 'OpenAI', color: 'text-emerald-600 dark:text-emerald-400', bgColor: 'bg-emerald-500/10' },
|
||||||
ANTHROPIC: 'Anthropic',
|
ANTHROPIC: { name: 'Anthropic', color: 'text-orange-600 dark:text-orange-400', bgColor: 'bg-orange-500/10' },
|
||||||
GOOGLE: 'Google',
|
GOOGLE: { name: 'Google', color: 'text-blue-600 dark:text-blue-400', bgColor: 'bg-blue-500/10' },
|
||||||
GROQ: 'Groq',
|
GROQ: { name: 'Groq', color: 'text-purple-600 dark:text-purple-400', bgColor: 'bg-purple-500/10' },
|
||||||
MISTRAL: 'Mistral',
|
MISTRAL: { name: 'Mistral', color: 'text-red-600 dark:text-red-400', bgColor: 'bg-red-500/10' },
|
||||||
};
|
};
|
||||||
|
|
||||||
const PROVIDER_COLORS: Record<AIProvider, 'default' | 'secondary' | 'outline'> = {
|
function formatRelativeDate(dateString: string): string {
|
||||||
OPENAI: 'default',
|
|
||||||
ANTHROPIC: 'secondary',
|
|
||||||
GOOGLE: 'outline',
|
|
||||||
GROQ: 'outline',
|
|
||||||
MISTRAL: 'outline',
|
|
||||||
};
|
|
||||||
|
|
||||||
function formatDate(dateString: string): string {
|
|
||||||
const date = new Date(dateString);
|
const date = new Date(dateString);
|
||||||
return date.toLocaleDateString('en-US', {
|
const now = new Date();
|
||||||
month: 'short',
|
const diffMs = now.getTime() - date.getTime();
|
||||||
day: 'numeric',
|
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||||
year: 'numeric',
|
|
||||||
});
|
if (diffDays === 0) {
|
||||||
|
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
||||||
|
if (diffHours === 0) {
|
||||||
|
const diffMinutes = Math.floor(diffMs / (1000 * 60));
|
||||||
|
return diffMinutes <= 1 ? 'Just now' : `${diffMinutes}m ago`;
|
||||||
|
}
|
||||||
|
return `${diffHours}h ago`;
|
||||||
|
}
|
||||||
|
if (diffDays === 1) return 'Yesterday';
|
||||||
|
if (diffDays < 7) return `${diffDays}d ago`;
|
||||||
|
if (diffDays < 30) return `${Math.floor(diffDays / 7)}w ago`;
|
||||||
|
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function AgentCard({
|
||||||
|
agent,
|
||||||
|
onDelete,
|
||||||
|
isDeleting,
|
||||||
|
}: {
|
||||||
|
agent: Agent;
|
||||||
|
onDelete: (id: string) => void;
|
||||||
|
isDeleting: boolean;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const providerInfo = PROVIDER_INFO[agent.provider];
|
||||||
|
const totalTools = agent.toolCount + agent.collectionCount;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="bg-surface border border-border rounded-xl p-5 hover:border-primary/50 hover:shadow-md transition-all cursor-pointer group"
|
||||||
|
onClick={() => router.push(`/dashboard/agents/${agent.id}`)}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-start justify-between mb-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center group-hover:bg-primary/20 transition-colors">
|
||||||
|
<Icon icon="terminal" size="md" className="text-primary" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h3 className="font-semibold text-foreground truncate group-hover:text-primary transition-colors">
|
||||||
|
{agent.name}
|
||||||
|
</h3>
|
||||||
|
<div className="flex items-center gap-2 mt-0.5">
|
||||||
|
<span className={`text-xs font-medium ${providerInfo.color}`}>
|
||||||
|
{providerInfo.name}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-foreground-tertiary">•</span>
|
||||||
|
<span className="text-xs text-foreground-tertiary truncate">
|
||||||
|
{agent.modelId}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-foreground-tertiary whitespace-nowrap">
|
||||||
|
{formatRelativeDate(agent.updatedAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
{agent.description ? (
|
||||||
|
<p className="text-sm text-foreground-secondary line-clamp-2 mb-4 min-h-[2.5rem]">
|
||||||
|
{agent.description}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-foreground-tertiary italic mb-4 min-h-[2.5rem]">
|
||||||
|
No description
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="flex items-center gap-4 mb-4 text-sm">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Icon icon="puzzle" size="xs" className="text-foreground-tertiary" />
|
||||||
|
<span className="text-foreground-secondary">
|
||||||
|
{totalTools} tool{totalTools !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{agent.collectionCount > 0 && (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Icon icon="folder" size="xs" className="text-foreground-tertiary" />
|
||||||
|
<span className="text-foreground-secondary">
|
||||||
|
{agent.collectionCount} collection{agent.collectionCount !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center gap-2 pt-4 border-t border-border">
|
||||||
|
<Link
|
||||||
|
href={`/dashboard/agents/${agent.id}/chat`}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
<Button size="sm" className="w-full">
|
||||||
|
<Icon icon="message" size="xs" className="mr-1.5" />
|
||||||
|
Chat
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href={`/dashboard/agents/${agent.id}`}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<Button size="sm" variant="secondary">
|
||||||
|
<Icon icon="edit" size="xs" />
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDelete(agent.id);
|
||||||
|
}}
|
||||||
|
disabled={isDeleting}
|
||||||
|
className="text-foreground-tertiary hover:text-error"
|
||||||
|
>
|
||||||
|
<Icon icon="trash" size="xs" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AgentCardSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="bg-surface border border-border rounded-xl p-5">
|
||||||
|
<div className="flex items-start justify-between mb-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-surface-secondary animate-pulse" />
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="h-4 w-32 bg-surface-secondary rounded animate-pulse" />
|
||||||
|
<div className="h-3 w-24 bg-surface-secondary rounded animate-pulse" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="h-3 w-16 bg-surface-secondary rounded animate-pulse" />
|
||||||
|
</div>
|
||||||
|
<div className="h-10 bg-surface-secondary rounded animate-pulse mb-4" />
|
||||||
|
<div className="h-4 w-20 bg-surface-secondary rounded animate-pulse mb-4" />
|
||||||
|
<div className="flex gap-2 pt-4 border-t border-border">
|
||||||
|
<div className="flex-1 h-8 bg-surface-secondary rounded animate-pulse" />
|
||||||
|
<div className="h-8 w-8 bg-surface-secondary rounded animate-pulse" />
|
||||||
|
<div className="h-8 w-8 bg-surface-secondary rounded animate-pulse" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AgentsPage(): React.ReactElement {
|
export default function AgentsPage(): React.ReactElement {
|
||||||
|
|
@ -62,6 +190,7 @@ export default function AgentsPage(): React.ReactElement {
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
|
||||||
const fetchAgents = useCallback(async () => {
|
const fetchAgents = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -89,8 +218,19 @@ export default function AgentsPage(): React.ReactElement {
|
||||||
fetchAgents();
|
fetchAgents();
|
||||||
}, [fetchAgents]);
|
}, [fetchAgents]);
|
||||||
|
|
||||||
const handleDelete = async (id: string, e: React.MouseEvent) => {
|
const filteredAgents = useMemo(() => {
|
||||||
e.stopPropagation();
|
if (!searchQuery.trim()) return agents;
|
||||||
|
const query = searchQuery.toLowerCase();
|
||||||
|
return agents.filter(
|
||||||
|
(agent) =>
|
||||||
|
agent.name.toLowerCase().includes(query) ||
|
||||||
|
agent.description?.toLowerCase().includes(query) ||
|
||||||
|
agent.modelId.toLowerCase().includes(query) ||
|
||||||
|
PROVIDER_INFO[agent.provider].name.toLowerCase().includes(query)
|
||||||
|
);
|
||||||
|
}, [agents, searchQuery]);
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
if (!confirm('Are you sure you want to delete this agent? This action cannot be undone.')) {
|
if (!confirm('Are you sure you want to delete this agent? This action cannot be undone.')) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -130,10 +270,14 @@ export default function AgentsPage(): React.ReactElement {
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="text-center py-16">
|
<div className="text-center py-16">
|
||||||
<Icon icon="alertCircle" size="lg" className="mx-auto text-error mb-4" />
|
<div className="w-16 h-16 rounded-full bg-error/10 flex items-center justify-center mx-auto mb-4">
|
||||||
<h2 className="text-lg font-medium text-foreground mb-2">Error</h2>
|
<Icon icon="alertCircle" size="lg" className="text-error" />
|
||||||
<p className="text-foreground-secondary mb-4">{error}</p>
|
</div>
|
||||||
<Button onClick={fetchAgents}>Try Again</Button>
|
<h2 className="text-lg font-medium text-foreground mb-2">Something went wrong</h2>
|
||||||
|
<p className="text-foreground-secondary mb-6 max-w-md mx-auto">{error}</p>
|
||||||
|
<Button onClick={fetchAgents}>
|
||||||
|
Try Again
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</DashboardLayout>
|
</DashboardLayout>
|
||||||
);
|
);
|
||||||
|
|
@ -142,9 +286,7 @@ export default function AgentsPage(): React.ReactElement {
|
||||||
return (
|
return (
|
||||||
<DashboardLayout
|
<DashboardLayout
|
||||||
title="Agents"
|
title="Agents"
|
||||||
subtitle={
|
subtitle="Create and manage AI agents with custom tools"
|
||||||
agents.length > 0 ? `${agents.length} agent${agents.length !== 1 ? 's' : ''}` : undefined
|
|
||||||
}
|
|
||||||
actions={
|
actions={
|
||||||
<Link href="/dashboard/agents/new">
|
<Link href="/dashboard/agents/new">
|
||||||
<Button>
|
<Button>
|
||||||
|
|
@ -154,138 +296,99 @@ export default function AgentsPage(): React.ReactElement {
|
||||||
</Link>
|
</Link>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="bg-surface border border-border rounded-lg overflow-hidden">
|
{/* Search and Filters */}
|
||||||
<Table>
|
{(agents.length > 0 || searchQuery) && (
|
||||||
<TableHeader>
|
<div className="mb-6">
|
||||||
<TableRow>
|
<div className="relative">
|
||||||
<TableHead className="w-[300px]">Name</TableHead>
|
<Icon
|
||||||
<TableHead>Provider</TableHead>
|
icon="search"
|
||||||
<TableHead>Tools</TableHead>
|
size="sm"
|
||||||
<TableHead>Updated</TableHead>
|
className="absolute left-3 top-1/2 -translate-y-1/2 text-foreground-tertiary"
|
||||||
<TableHead className="w-[140px] text-right">Actions</TableHead>
|
/>
|
||||||
</TableRow>
|
<Input
|
||||||
</TableHeader>
|
type="text"
|
||||||
<TableBody>
|
placeholder="Search agents by name, model, or provider..."
|
||||||
{isLoading ? (
|
value={searchQuery}
|
||||||
// Loading skeleton
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
<>
|
className="pl-10"
|
||||||
{[0, 1, 2].map((idx) => (
|
/>
|
||||||
<TableRow key={`agent-skeleton-${idx}`}>
|
</div>
|
||||||
<TableCell>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
)}
|
||||||
<div className="w-8 h-8 rounded-lg bg-surface-secondary animate-pulse" />
|
|
||||||
<div className="space-y-1.5">
|
{/* Loading State */}
|
||||||
<div className="h-4 w-32 bg-surface-secondary rounded animate-pulse" />
|
{isLoading && (
|
||||||
<div className="h-3 w-48 bg-surface-secondary rounded animate-pulse" />
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
</div>
|
{[0, 1, 2, 3, 4, 5].map((idx) => (
|
||||||
</div>
|
<AgentCardSkeleton key={`skeleton-${idx}`} />
|
||||||
</TableCell>
|
))}
|
||||||
<TableCell>
|
</div>
|
||||||
<div className="h-5 w-20 bg-surface-secondary rounded animate-pulse" />
|
)}
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
{/* Empty State */}
|
||||||
<div className="h-4 w-12 bg-surface-secondary rounded animate-pulse" />
|
{!isLoading && agents.length === 0 && (
|
||||||
</TableCell>
|
<div className="text-center py-16 bg-surface border border-border rounded-xl">
|
||||||
<TableCell>
|
<div className="w-20 h-20 rounded-full bg-primary/10 flex items-center justify-center mx-auto mb-6">
|
||||||
<div className="h-4 w-24 bg-surface-secondary rounded animate-pulse" />
|
<Icon icon="terminal" size="lg" className="text-primary" />
|
||||||
</TableCell>
|
</div>
|
||||||
<TableCell>
|
<h2 className="text-xl font-semibold text-foreground mb-2">Create your first agent</h2>
|
||||||
<div className="h-8 w-24 bg-surface-secondary rounded animate-pulse ml-auto" />
|
<p className="text-foreground-secondary mb-8 max-w-md mx-auto">
|
||||||
</TableCell>
|
AI agents can chat with users and use tools from your collections.
|
||||||
</TableRow>
|
Create an agent to get started with AI-powered conversations.
|
||||||
))}
|
</p>
|
||||||
</>
|
<div className="flex flex-col sm:flex-row items-center justify-center gap-3">
|
||||||
) : agents.length === 0 ? (
|
<Link href="/dashboard/agents/new">
|
||||||
<TableEmpty
|
<Button size="lg">
|
||||||
colSpan={5}
|
<Icon icon="plus" size="sm" className="mr-2" />
|
||||||
icon={
|
Create Agent
|
||||||
<div className="w-16 h-16 rounded-full bg-primary/10 flex items-center justify-center">
|
</Button>
|
||||||
<Icon icon="terminal" size="lg" className="text-primary" />
|
</Link>
|
||||||
</div>
|
<Link href="/tool/tool-search">
|
||||||
}
|
<Button size="lg" variant="outline">
|
||||||
title="No agents yet"
|
<Icon icon="search" size="sm" className="mr-2" />
|
||||||
description="Create your first AI agent to start chatting with tools. Agents can use any tools from your collections or individual tools."
|
Browse Tools
|
||||||
action={
|
</Button>
|
||||||
<Link href="/dashboard/agents/new">
|
</Link>
|
||||||
<Button>
|
</div>
|
||||||
<Icon icon="plus" size="sm" className="mr-2" />
|
</div>
|
||||||
Create Your First Agent
|
)}
|
||||||
</Button>
|
|
||||||
</Link>
|
{/* No Results State */}
|
||||||
}
|
{!isLoading && agents.length > 0 && filteredAgents.length === 0 && (
|
||||||
|
<div className="text-center py-16 bg-surface border border-border rounded-xl">
|
||||||
|
<div className="w-16 h-16 rounded-full bg-foreground/5 flex items-center justify-center mx-auto mb-4">
|
||||||
|
<Icon icon="search" size="lg" className="text-foreground-tertiary" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-lg font-medium text-foreground mb-2">No agents found</h2>
|
||||||
|
<p className="text-foreground-secondary mb-4">
|
||||||
|
No agents match "{searchQuery}"
|
||||||
|
</p>
|
||||||
|
<Button variant="secondary" onClick={() => setSearchQuery('')}>
|
||||||
|
Clear Search
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Agents Grid */}
|
||||||
|
{!isLoading && filteredAgents.length > 0 && (
|
||||||
|
<>
|
||||||
|
{searchQuery && (
|
||||||
|
<p className="text-sm text-foreground-tertiary mb-4">
|
||||||
|
Showing {filteredAgents.length} of {agents.length} agents
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{filteredAgents.map((agent) => (
|
||||||
|
<AgentCard
|
||||||
|
key={agent.id}
|
||||||
|
agent={agent}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
isDeleting={deletingId === agent.id}
|
||||||
/>
|
/>
|
||||||
) : (
|
))}
|
||||||
agents.map((agent) => (
|
</div>
|
||||||
<TableRow
|
</>
|
||||||
key={agent.id}
|
)}
|
||||||
interactive
|
|
||||||
onClick={() => router.push(`/dashboard/agents/${agent.id}`)}
|
|
||||||
className="cursor-pointer"
|
|
||||||
>
|
|
||||||
<TableCell>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="w-8 h-8 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0">
|
|
||||||
<Icon icon="terminal" size="sm" className="text-primary" />
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="font-medium text-foreground truncate">{agent.name}</p>
|
|
||||||
{agent.description && (
|
|
||||||
<p className="text-sm text-foreground-tertiary truncate max-w-[250px]">
|
|
||||||
{agent.description}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Badge variant={PROVIDER_COLORS[agent.provider]} size="sm">
|
|
||||||
{PROVIDER_DISPLAY_NAMES[agent.provider]}
|
|
||||||
</Badge>
|
|
||||||
<span className="text-xs text-foreground-tertiary">{agent.modelId}</span>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<span className="text-foreground-secondary">
|
|
||||||
{agent.toolCount > 0 && agent.collectionCount > 0
|
|
||||||
? `${agent.toolCount} + ${agent.collectionCount} collections`
|
|
||||||
: agent.collectionCount > 0
|
|
||||||
? `${agent.collectionCount} collection${agent.collectionCount !== 1 ? 's' : ''}`
|
|
||||||
: agent.toolCount}
|
|
||||||
</span>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<span className="text-foreground-secondary text-sm">
|
|
||||||
{formatDate(agent.updatedAt)}
|
|
||||||
</span>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<div className="flex items-center justify-end gap-1">
|
|
||||||
<Link
|
|
||||||
href={`/dashboard/agents/${agent.id}/chat`}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<Button size="sm" variant="default">
|
|
||||||
<Icon icon="message" size="xs" className="mr-1" />
|
|
||||||
Chat
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={(e) => handleDelete(agent.id, e)}
|
|
||||||
disabled={deletingId === agent.id}
|
|
||||||
>
|
|
||||||
<Icon icon="trash" size="xs" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
</DashboardLayout>
|
</DashboardLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,22 +3,16 @@
|
||||||
import { Badge } from '@tpmjs/ui/Badge/Badge';
|
import { Badge } from '@tpmjs/ui/Badge/Badge';
|
||||||
import { Button } from '@tpmjs/ui/Button/Button';
|
import { Button } from '@tpmjs/ui/Button/Button';
|
||||||
import { Icon } from '@tpmjs/ui/Icon/Icon';
|
import { Icon } from '@tpmjs/ui/Icon/Icon';
|
||||||
import {
|
import { Input } from '@tpmjs/ui/Input/Input';
|
||||||
Table,
|
import Link from 'next/link';
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableEmpty,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from '@tpmjs/ui/Table/Table';
|
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { CollectionForm } from '~/components/collections/CollectionForm';
|
import { CollectionForm } from '~/components/collections/CollectionForm';
|
||||||
import { DashboardLayout } from '~/components/dashboard/DashboardLayout';
|
import { DashboardLayout } from '~/components/dashboard/DashboardLayout';
|
||||||
|
|
||||||
interface Collection {
|
interface Collection {
|
||||||
id: string;
|
id: string;
|
||||||
|
slug: string | null;
|
||||||
name: string;
|
name: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
toolCount: number;
|
toolCount: number;
|
||||||
|
|
@ -26,13 +20,172 @@ interface Collection {
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(dateString: string): string {
|
function formatRelativeDate(dateString: string): string {
|
||||||
const date = new Date(dateString);
|
const date = new Date(dateString);
|
||||||
return date.toLocaleDateString('en-US', {
|
const now = new Date();
|
||||||
month: 'short',
|
const diffMs = now.getTime() - date.getTime();
|
||||||
day: 'numeric',
|
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||||
year: 'numeric',
|
|
||||||
});
|
if (diffDays === 0) {
|
||||||
|
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
||||||
|
if (diffHours === 0) {
|
||||||
|
const diffMinutes = Math.floor(diffMs / (1000 * 60));
|
||||||
|
return diffMinutes <= 1 ? 'Just now' : `${diffMinutes}m ago`;
|
||||||
|
}
|
||||||
|
return `${diffHours}h ago`;
|
||||||
|
}
|
||||||
|
if (diffDays === 1) return 'Yesterday';
|
||||||
|
if (diffDays < 7) return `${diffDays}d ago`;
|
||||||
|
if (diffDays < 30) return `${Math.floor(diffDays / 7)}w ago`;
|
||||||
|
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function CollectionCard({
|
||||||
|
collection,
|
||||||
|
onDelete,
|
||||||
|
isDeleting,
|
||||||
|
username,
|
||||||
|
}: {
|
||||||
|
collection: Collection;
|
||||||
|
onDelete: (id: string) => void;
|
||||||
|
isDeleting: boolean;
|
||||||
|
username: string | null;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [showCopied, setShowCopied] = useState(false);
|
||||||
|
|
||||||
|
const mcpUrl = collection.slug && username
|
||||||
|
? `${typeof window !== 'undefined' ? window.location.origin : ''}/api/mcp/${username}/${collection.slug}/http`
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const handleCopyMcpUrl = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (mcpUrl) {
|
||||||
|
navigator.clipboard.writeText(mcpUrl);
|
||||||
|
setShowCopied(true);
|
||||||
|
setTimeout(() => setShowCopied(false), 2000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="bg-surface border border-border rounded-xl p-5 hover:border-primary/50 hover:shadow-md transition-all cursor-pointer group"
|
||||||
|
onClick={() => router.push(`/dashboard/collections/${collection.id}`)}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-start justify-between mb-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center group-hover:bg-primary/20 transition-colors">
|
||||||
|
<Icon icon="folder" size="md" className="text-primary" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h3 className="font-semibold text-foreground truncate group-hover:text-primary transition-colors">
|
||||||
|
{collection.name}
|
||||||
|
</h3>
|
||||||
|
<div className="flex items-center gap-2 mt-0.5">
|
||||||
|
<Badge
|
||||||
|
variant={collection.isPublic ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{collection.isPublic ? 'Public' : 'Private'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-foreground-tertiary whitespace-nowrap">
|
||||||
|
{formatRelativeDate(collection.updatedAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
{collection.description ? (
|
||||||
|
<p className="text-sm text-foreground-secondary line-clamp-2 mb-4 min-h-[2.5rem]">
|
||||||
|
{collection.description}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-foreground-tertiary italic mb-4 min-h-[2.5rem]">
|
||||||
|
No description
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="flex items-center gap-4 mb-4 text-sm">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Icon icon="puzzle" size="xs" className="text-foreground-tertiary" />
|
||||||
|
<span className="text-foreground-secondary">
|
||||||
|
{collection.toolCount} tool{collection.toolCount !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{mcpUrl && (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Icon icon="link" size="xs" className="text-foreground-tertiary" />
|
||||||
|
<span className="text-foreground-secondary">MCP Ready</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center gap-2 pt-4 border-t border-border">
|
||||||
|
{mcpUrl && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={handleCopyMcpUrl}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
<Icon icon={showCopied ? 'check' : 'copy'} size="xs" className="mr-1.5" />
|
||||||
|
{showCopied ? 'Copied!' : 'Copy MCP URL'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Link
|
||||||
|
href={`/dashboard/collections/${collection.id}`}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className={mcpUrl ? '' : 'flex-1'}
|
||||||
|
>
|
||||||
|
<Button size="sm" variant={mcpUrl ? 'secondary' : 'default'} className={mcpUrl ? '' : 'w-full'}>
|
||||||
|
<Icon icon="edit" size="xs" className={mcpUrl ? '' : 'mr-1.5'} />
|
||||||
|
{!mcpUrl && 'Manage'}
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDelete(collection.id);
|
||||||
|
}}
|
||||||
|
disabled={isDeleting}
|
||||||
|
className="text-foreground-tertiary hover:text-error"
|
||||||
|
>
|
||||||
|
<Icon icon="trash" size="xs" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CollectionCardSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="bg-surface border border-border rounded-xl p-5">
|
||||||
|
<div className="flex items-start justify-between mb-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-surface-secondary animate-pulse" />
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="h-4 w-32 bg-surface-secondary rounded animate-pulse" />
|
||||||
|
<div className="h-4 w-16 bg-surface-secondary rounded animate-pulse" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="h-3 w-16 bg-surface-secondary rounded animate-pulse" />
|
||||||
|
</div>
|
||||||
|
<div className="h-10 bg-surface-secondary rounded animate-pulse mb-4" />
|
||||||
|
<div className="h-4 w-20 bg-surface-secondary rounded animate-pulse mb-4" />
|
||||||
|
<div className="flex gap-2 pt-4 border-t border-border">
|
||||||
|
<div className="flex-1 h-8 bg-surface-secondary rounded animate-pulse" />
|
||||||
|
<div className="h-8 w-8 bg-surface-secondary rounded animate-pulse" />
|
||||||
|
<div className="h-8 w-8 bg-surface-secondary rounded animate-pulse" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CollectionsPage(): React.ReactElement {
|
export default function CollectionsPage(): React.ReactElement {
|
||||||
|
|
@ -43,6 +196,8 @@ export default function CollectionsPage(): React.ReactElement {
|
||||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||||
const [isCreating, setIsCreating] = useState(false);
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [username, setUsername] = useState<string | null>(null);
|
||||||
|
|
||||||
const fetchCollections = useCallback(async () => {
|
const fetchCollections = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -66,10 +221,32 @@ export default function CollectionsPage(): React.ReactElement {
|
||||||
}
|
}
|
||||||
}, [router]);
|
}, [router]);
|
||||||
|
|
||||||
|
// Fetch user info for MCP URL
|
||||||
|
useEffect(() => {
|
||||||
|
fetch('/api/user/profile')
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success && data.data?.username) {
|
||||||
|
setUsername(data.data.username);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchCollections();
|
fetchCollections();
|
||||||
}, [fetchCollections]);
|
}, [fetchCollections]);
|
||||||
|
|
||||||
|
const filteredCollections = useMemo(() => {
|
||||||
|
if (!searchQuery.trim()) return collections;
|
||||||
|
const query = searchQuery.toLowerCase();
|
||||||
|
return collections.filter(
|
||||||
|
(collection) =>
|
||||||
|
collection.name.toLowerCase().includes(query) ||
|
||||||
|
collection.description?.toLowerCase().includes(query)
|
||||||
|
);
|
||||||
|
}, [collections, searchQuery]);
|
||||||
|
|
||||||
const handleCreate = async (data: { name: string; description?: string; isPublic: boolean }) => {
|
const handleCreate = async (data: { name: string; description?: string; isPublic: boolean }) => {
|
||||||
setIsCreating(true);
|
setIsCreating(true);
|
||||||
|
|
||||||
|
|
@ -96,8 +273,7 @@ export default function CollectionsPage(): React.ReactElement {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (id: string, e: React.MouseEvent) => {
|
const handleDelete = async (id: string) => {
|
||||||
e.stopPropagation();
|
|
||||||
if (
|
if (
|
||||||
!confirm('Are you sure you want to delete this collection? This action cannot be undone.')
|
!confirm('Are you sure you want to delete this collection? This action cannot be undone.')
|
||||||
) {
|
) {
|
||||||
|
|
@ -138,10 +314,14 @@ export default function CollectionsPage(): React.ReactElement {
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="text-center py-16">
|
<div className="text-center py-16">
|
||||||
<Icon icon="alertCircle" size="lg" className="mx-auto text-error mb-4" />
|
<div className="w-16 h-16 rounded-full bg-error/10 flex items-center justify-center mx-auto mb-4">
|
||||||
<h2 className="text-lg font-medium text-foreground mb-2">Error</h2>
|
<Icon icon="alertCircle" size="lg" className="text-error" />
|
||||||
<p className="text-foreground-secondary mb-4">{error}</p>
|
</div>
|
||||||
<Button onClick={fetchCollections}>Try Again</Button>
|
<h2 className="text-lg font-medium text-foreground mb-2">Something went wrong</h2>
|
||||||
|
<p className="text-foreground-secondary mb-6 max-w-md mx-auto">{error}</p>
|
||||||
|
<Button onClick={fetchCollections}>
|
||||||
|
Try Again
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</DashboardLayout>
|
</DashboardLayout>
|
||||||
);
|
);
|
||||||
|
|
@ -150,11 +330,7 @@ export default function CollectionsPage(): React.ReactElement {
|
||||||
return (
|
return (
|
||||||
<DashboardLayout
|
<DashboardLayout
|
||||||
title="Collections"
|
title="Collections"
|
||||||
subtitle={
|
subtitle="Organize tools into shareable MCP servers"
|
||||||
collections.length > 0
|
|
||||||
? `${collections.length} collection${collections.length !== 1 ? 's' : ''}`
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
actions={
|
actions={
|
||||||
!showCreateForm && (
|
!showCreateForm && (
|
||||||
<Button onClick={() => setShowCreateForm(true)}>
|
<Button onClick={() => setShowCreateForm(true)}>
|
||||||
|
|
@ -166,8 +342,18 @@ export default function CollectionsPage(): React.ReactElement {
|
||||||
>
|
>
|
||||||
{/* Create Form */}
|
{/* Create Form */}
|
||||||
{showCreateForm && (
|
{showCreateForm && (
|
||||||
<div className="bg-surface border border-border rounded-lg p-6 mb-6">
|
<div className="bg-surface border border-primary/30 rounded-xl p-6 mb-6 shadow-sm">
|
||||||
<h2 className="text-lg font-medium text-foreground mb-4">Create New Collection</h2>
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||||
|
<Icon icon="folder" size="md" className="text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-foreground">Create New Collection</h2>
|
||||||
|
<p className="text-sm text-foreground-secondary">
|
||||||
|
Group tools together and share them as an MCP server
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<CollectionForm
|
<CollectionForm
|
||||||
onSubmit={handleCreate}
|
onSubmit={handleCreate}
|
||||||
onCancel={() => setShowCreateForm(false)}
|
onCancel={() => setShowCreateForm(false)}
|
||||||
|
|
@ -177,119 +363,119 @@ export default function CollectionsPage(): React.ReactElement {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Collections Table */}
|
{/* Search */}
|
||||||
<div className="bg-surface border border-border rounded-lg overflow-hidden">
|
{(collections.length > 0 || searchQuery) && !showCreateForm && (
|
||||||
<Table>
|
<div className="mb-6">
|
||||||
<TableHeader>
|
<div className="relative">
|
||||||
<TableRow>
|
<Icon
|
||||||
<TableHead className="w-[350px]">Name</TableHead>
|
icon="search"
|
||||||
<TableHead>Tools</TableHead>
|
size="sm"
|
||||||
<TableHead>Visibility</TableHead>
|
className="absolute left-3 top-1/2 -translate-y-1/2 text-foreground-tertiary"
|
||||||
<TableHead>Updated</TableHead>
|
/>
|
||||||
<TableHead className="w-[100px] text-right">Actions</TableHead>
|
<Input
|
||||||
</TableRow>
|
type="text"
|
||||||
</TableHeader>
|
placeholder="Search collections by name or description..."
|
||||||
<TableBody>
|
value={searchQuery}
|
||||||
{isLoading ? (
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
// Loading skeleton
|
className="pl-10"
|
||||||
<>
|
/>
|
||||||
{[0, 1, 2].map((idx) => (
|
</div>
|
||||||
<TableRow key={`collection-skeleton-${idx}`}>
|
</div>
|
||||||
<TableCell>
|
)}
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="w-8 h-8 rounded-lg bg-surface-secondary animate-pulse" />
|
{/* Loading State */}
|
||||||
<div className="space-y-1.5">
|
{isLoading && (
|
||||||
<div className="h-4 w-32 bg-surface-secondary rounded animate-pulse" />
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
<div className="h-3 w-48 bg-surface-secondary rounded animate-pulse" />
|
{[0, 1, 2, 3, 4, 5].map((idx) => (
|
||||||
</div>
|
<CollectionCardSkeleton key={`skeleton-${idx}`} />
|
||||||
</div>
|
))}
|
||||||
</TableCell>
|
</div>
|
||||||
<TableCell>
|
)}
|
||||||
<div className="h-4 w-12 bg-surface-secondary rounded animate-pulse" />
|
|
||||||
</TableCell>
|
{/* Empty State */}
|
||||||
<TableCell>
|
{!isLoading && collections.length === 0 && !showCreateForm && (
|
||||||
<div className="h-5 w-16 bg-surface-secondary rounded animate-pulse" />
|
<div className="text-center py-16 bg-surface border border-border rounded-xl">
|
||||||
</TableCell>
|
<div className="w-20 h-20 rounded-full bg-primary/10 flex items-center justify-center mx-auto mb-6">
|
||||||
<TableCell>
|
<Icon icon="folder" size="lg" className="text-primary" />
|
||||||
<div className="h-4 w-24 bg-surface-secondary rounded animate-pulse" />
|
</div>
|
||||||
</TableCell>
|
<h2 className="text-xl font-semibold text-foreground mb-2">Create your first collection</h2>
|
||||||
<TableCell>
|
<p className="text-foreground-secondary mb-8 max-w-md mx-auto">
|
||||||
<div className="h-8 w-8 bg-surface-secondary rounded animate-pulse ml-auto" />
|
Collections group related tools together and can be shared as MCP servers.
|
||||||
</TableCell>
|
Connect them to Claude, Cursor, or any MCP-compatible client.
|
||||||
</TableRow>
|
</p>
|
||||||
))}
|
<div className="flex flex-col sm:flex-row items-center justify-center gap-3">
|
||||||
</>
|
<Button size="lg" onClick={() => setShowCreateForm(true)}>
|
||||||
) : collections.length === 0 ? (
|
<Icon icon="plus" size="sm" className="mr-2" />
|
||||||
<TableEmpty
|
Create Collection
|
||||||
colSpan={5}
|
</Button>
|
||||||
icon={
|
<Link href="/tool/tool-search">
|
||||||
<div className="w-16 h-16 rounded-full bg-primary/10 flex items-center justify-center">
|
<Button size="lg" variant="outline">
|
||||||
<Icon icon="folder" size="lg" className="text-primary" />
|
<Icon icon="search" size="sm" className="mr-2" />
|
||||||
</div>
|
Browse Tools
|
||||||
}
|
</Button>
|
||||||
title="No collections yet"
|
</Link>
|
||||||
description="Create a collection to organize your tools. Collections make it easy to group related tools together and share them with your agents."
|
</div>
|
||||||
action={
|
</div>
|
||||||
<Button onClick={() => setShowCreateForm(true)}>
|
)}
|
||||||
<Icon icon="plus" size="sm" className="mr-2" />
|
|
||||||
Create Your First Collection
|
{/* No Results State */}
|
||||||
</Button>
|
{!isLoading && collections.length > 0 && filteredCollections.length === 0 && (
|
||||||
}
|
<div className="text-center py-16 bg-surface border border-border rounded-xl">
|
||||||
|
<div className="w-16 h-16 rounded-full bg-foreground/5 flex items-center justify-center mx-auto mb-4">
|
||||||
|
<Icon icon="search" size="lg" className="text-foreground-tertiary" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-lg font-medium text-foreground mb-2">No collections found</h2>
|
||||||
|
<p className="text-foreground-secondary mb-4">
|
||||||
|
No collections match "{searchQuery}"
|
||||||
|
</p>
|
||||||
|
<Button variant="secondary" onClick={() => setSearchQuery('')}>
|
||||||
|
Clear Search
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Collections Grid */}
|
||||||
|
{!isLoading && filteredCollections.length > 0 && (
|
||||||
|
<>
|
||||||
|
{searchQuery && (
|
||||||
|
<p className="text-sm text-foreground-tertiary mb-4">
|
||||||
|
Showing {filteredCollections.length} of {collections.length} collections
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{filteredCollections.map((collection) => (
|
||||||
|
<CollectionCard
|
||||||
|
key={collection.id}
|
||||||
|
collection={collection}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
isDeleting={deletingId === collection.id}
|
||||||
|
username={username}
|
||||||
/>
|
/>
|
||||||
) : (
|
))}
|
||||||
collections.map((collection) => (
|
</div>
|
||||||
<TableRow
|
</>
|
||||||
key={collection.id}
|
)}
|
||||||
interactive
|
|
||||||
onClick={() => router.push(`/dashboard/collections/${collection.id}`)}
|
{/* Help Section */}
|
||||||
className="cursor-pointer"
|
{!isLoading && collections.length > 0 && !showCreateForm && (
|
||||||
>
|
<div className="mt-8 p-6 bg-surface/50 border border-border rounded-xl">
|
||||||
<TableCell>
|
<div className="flex items-start gap-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0">
|
||||||
<div className="w-8 h-8 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0">
|
<Icon icon="link" size="sm" className="text-primary" />
|
||||||
<Icon icon="folder" size="sm" className="text-primary" />
|
</div>
|
||||||
</div>
|
<div>
|
||||||
<div className="min-w-0">
|
<h3 className="font-medium text-foreground mb-1">Connect to MCP clients</h3>
|
||||||
<p className="font-medium text-foreground truncate">{collection.name}</p>
|
<p className="text-sm text-foreground-secondary mb-3">
|
||||||
{collection.description && (
|
Each public collection has an MCP URL you can use with Claude Desktop, Cursor,
|
||||||
<p className="text-sm text-foreground-tertiary truncate max-w-[280px]">
|
Windsurf, or any MCP-compatible client. Copy the URL and add it to your client's configuration.
|
||||||
{collection.description}
|
</p>
|
||||||
</p>
|
<Link href="/docs/tutorials/mcp" className="text-sm text-primary hover:underline">
|
||||||
)}
|
Learn more about MCP integration →
|
||||||
</div>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</div>
|
||||||
<TableCell>
|
</div>
|
||||||
<span className="text-foreground-secondary">{collection.toolCount}</span>
|
)}
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Badge variant={collection.isPublic ? 'default' : 'outline'} size="sm">
|
|
||||||
{collection.isPublic ? 'Public' : 'Private'}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<span className="text-foreground-secondary text-sm">
|
|
||||||
{formatDate(collection.updatedAt)}
|
|
||||||
</span>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<div className="flex items-center justify-end">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={(e) => handleDelete(collection.id, e)}
|
|
||||||
disabled={deletingId === collection.id}
|
|
||||||
>
|
|
||||||
<Icon icon="trash" size="xs" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
</DashboardLayout>
|
</DashboardLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue