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:
Ajax Davis 2026-01-14 07:08:36 +10:00
parent e73b7955a3
commit a7b4c31881
2 changed files with 601 additions and 312 deletions

View file

@ -1,21 +1,12 @@
'use client';
import type { AIProvider } from '@tpmjs/types/agent';
import { Badge } from '@tpmjs/ui/Badge/Badge';
import { Button } from '@tpmjs/ui/Button/Button';
import { Icon } from '@tpmjs/ui/Icon/Icon';
import {
Table,
TableBody,
TableCell,
TableEmpty,
TableHead,
TableHeader,
TableRow,
} from '@tpmjs/ui/Table/Table';
import { Input } from '@tpmjs/ui/Input/Input';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { DashboardLayout } from '~/components/dashboard/DashboardLayout';
interface Agent {
@ -31,29 +22,166 @@ interface Agent {
updatedAt: string;
}
const PROVIDER_DISPLAY_NAMES: Record<AIProvider, string> = {
OPENAI: 'OpenAI',
ANTHROPIC: 'Anthropic',
GOOGLE: 'Google',
GROQ: 'Groq',
MISTRAL: 'Mistral',
const PROVIDER_INFO: Record<AIProvider, { name: string; color: string; bgColor: string }> = {
OPENAI: { name: 'OpenAI', color: 'text-emerald-600 dark:text-emerald-400', bgColor: 'bg-emerald-500/10' },
ANTHROPIC: { name: 'Anthropic', color: 'text-orange-600 dark:text-orange-400', bgColor: 'bg-orange-500/10' },
GOOGLE: { name: 'Google', color: 'text-blue-600 dark:text-blue-400', bgColor: 'bg-blue-500/10' },
GROQ: { name: 'Groq', color: 'text-purple-600 dark:text-purple-400', bgColor: 'bg-purple-500/10' },
MISTRAL: { name: 'Mistral', color: 'text-red-600 dark:text-red-400', bgColor: 'bg-red-500/10' },
};
const PROVIDER_COLORS: Record<AIProvider, 'default' | 'secondary' | 'outline'> = {
OPENAI: 'default',
ANTHROPIC: 'secondary',
GOOGLE: 'outline',
GROQ: 'outline',
MISTRAL: 'outline',
};
function formatDate(dateString: string): string {
function formatRelativeDate(dateString: string): string {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
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 {
@ -62,6 +190,7 @@ export default function AgentsPage(): React.ReactElement {
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const fetchAgents = useCallback(async () => {
try {
@ -89,8 +218,19 @@ export default function AgentsPage(): React.ReactElement {
fetchAgents();
}, [fetchAgents]);
const handleDelete = async (id: string, e: React.MouseEvent) => {
e.stopPropagation();
const filteredAgents = useMemo(() => {
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.')) {
return;
}
@ -130,10 +270,14 @@ export default function AgentsPage(): React.ReactElement {
}
>
<div className="text-center py-16">
<Icon icon="alertCircle" size="lg" className="mx-auto text-error mb-4" />
<h2 className="text-lg font-medium text-foreground mb-2">Error</h2>
<p className="text-foreground-secondary mb-4">{error}</p>
<Button onClick={fetchAgents}>Try Again</Button>
<div className="w-16 h-16 rounded-full bg-error/10 flex items-center justify-center mx-auto mb-4">
<Icon icon="alertCircle" size="lg" className="text-error" />
</div>
<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>
</DashboardLayout>
);
@ -142,9 +286,7 @@ export default function AgentsPage(): React.ReactElement {
return (
<DashboardLayout
title="Agents"
subtitle={
agents.length > 0 ? `${agents.length} agent${agents.length !== 1 ? 's' : ''}` : undefined
}
subtitle="Create and manage AI agents with custom tools"
actions={
<Link href="/dashboard/agents/new">
<Button>
@ -154,138 +296,99 @@ export default function AgentsPage(): React.ReactElement {
</Link>
}
>
<div className="bg-surface border border-border rounded-lg overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[300px]">Name</TableHead>
<TableHead>Provider</TableHead>
<TableHead>Tools</TableHead>
<TableHead>Updated</TableHead>
<TableHead className="w-[140px] text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
// Loading skeleton
<>
{[0, 1, 2].map((idx) => (
<TableRow key={`agent-skeleton-${idx}`}>
<TableCell>
<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">
<div className="h-4 w-32 bg-surface-secondary rounded animate-pulse" />
<div className="h-3 w-48 bg-surface-secondary rounded animate-pulse" />
</div>
</div>
</TableCell>
<TableCell>
<div className="h-5 w-20 bg-surface-secondary rounded animate-pulse" />
</TableCell>
<TableCell>
<div className="h-4 w-12 bg-surface-secondary rounded animate-pulse" />
</TableCell>
<TableCell>
<div className="h-4 w-24 bg-surface-secondary rounded animate-pulse" />
</TableCell>
<TableCell>
<div className="h-8 w-24 bg-surface-secondary rounded animate-pulse ml-auto" />
</TableCell>
</TableRow>
))}
</>
) : agents.length === 0 ? (
<TableEmpty
colSpan={5}
icon={
<div className="w-16 h-16 rounded-full bg-primary/10 flex items-center justify-center">
<Icon icon="terminal" size="lg" className="text-primary" />
</div>
}
title="No agents yet"
description="Create your first AI agent to start chatting with tools. Agents can use any tools from your collections or individual tools."
action={
<Link href="/dashboard/agents/new">
<Button>
<Icon icon="plus" size="sm" className="mr-2" />
Create Your First Agent
</Button>
</Link>
}
{/* Search and Filters */}
{(agents.length > 0 || searchQuery) && (
<div className="mb-6">
<div className="relative">
<Icon
icon="search"
size="sm"
className="absolute left-3 top-1/2 -translate-y-1/2 text-foreground-tertiary"
/>
<Input
type="text"
placeholder="Search agents by name, model, or provider..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10"
/>
</div>
</div>
)}
{/* Loading State */}
{isLoading && (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{[0, 1, 2, 3, 4, 5].map((idx) => (
<AgentCardSkeleton key={`skeleton-${idx}`} />
))}
</div>
)}
{/* Empty State */}
{!isLoading && agents.length === 0 && (
<div className="text-center py-16 bg-surface border border-border rounded-xl">
<div className="w-20 h-20 rounded-full bg-primary/10 flex items-center justify-center mx-auto mb-6">
<Icon icon="terminal" size="lg" className="text-primary" />
</div>
<h2 className="text-xl font-semibold text-foreground mb-2">Create your first agent</h2>
<p className="text-foreground-secondary mb-8 max-w-md mx-auto">
AI agents can chat with users and use tools from your collections.
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">
<Link href="/dashboard/agents/new">
<Button size="lg">
<Icon icon="plus" size="sm" className="mr-2" />
Create Agent
</Button>
</Link>
<Link href="/tool/tool-search">
<Button size="lg" variant="outline">
<Icon icon="search" size="sm" className="mr-2" />
Browse Tools
</Button>
</Link>
</div>
</div>
)}
{/* 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) => (
<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>
))}
</div>
</>
)}
</DashboardLayout>
);
}

View file

@ -3,22 +3,16 @@
import { Badge } from '@tpmjs/ui/Badge/Badge';
import { Button } from '@tpmjs/ui/Button/Button';
import { Icon } from '@tpmjs/ui/Icon/Icon';
import {
Table,
TableBody,
TableCell,
TableEmpty,
TableHead,
TableHeader,
TableRow,
} from '@tpmjs/ui/Table/Table';
import { Input } from '@tpmjs/ui/Input/Input';
import Link from 'next/link';
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 { DashboardLayout } from '~/components/dashboard/DashboardLayout';
interface Collection {
id: string;
slug: string | null;
name: string;
description: string | null;
toolCount: number;
@ -26,13 +20,172 @@ interface Collection {
updatedAt: string;
}
function formatDate(dateString: string): string {
function formatRelativeDate(dateString: string): string {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
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 {
@ -43,6 +196,8 @@ export default function CollectionsPage(): React.ReactElement {
const [showCreateForm, setShowCreateForm] = useState(false);
const [isCreating, setIsCreating] = useState(false);
const [deletingId, setDeletingId] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [username, setUsername] = useState<string | null>(null);
const fetchCollections = useCallback(async () => {
try {
@ -66,10 +221,32 @@ export default function CollectionsPage(): React.ReactElement {
}
}, [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(() => {
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 }) => {
setIsCreating(true);
@ -96,8 +273,7 @@ export default function CollectionsPage(): React.ReactElement {
}
};
const handleDelete = async (id: string, e: React.MouseEvent) => {
e.stopPropagation();
const handleDelete = async (id: string) => {
if (
!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">
<Icon icon="alertCircle" size="lg" className="mx-auto text-error mb-4" />
<h2 className="text-lg font-medium text-foreground mb-2">Error</h2>
<p className="text-foreground-secondary mb-4">{error}</p>
<Button onClick={fetchCollections}>Try Again</Button>
<div className="w-16 h-16 rounded-full bg-error/10 flex items-center justify-center mx-auto mb-4">
<Icon icon="alertCircle" size="lg" className="text-error" />
</div>
<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>
</DashboardLayout>
);
@ -150,11 +330,7 @@ export default function CollectionsPage(): React.ReactElement {
return (
<DashboardLayout
title="Collections"
subtitle={
collections.length > 0
? `${collections.length} collection${collections.length !== 1 ? 's' : ''}`
: undefined
}
subtitle="Organize tools into shareable MCP servers"
actions={
!showCreateForm && (
<Button onClick={() => setShowCreateForm(true)}>
@ -166,8 +342,18 @@ export default function CollectionsPage(): React.ReactElement {
>
{/* Create Form */}
{showCreateForm && (
<div className="bg-surface border border-border rounded-lg p-6 mb-6">
<h2 className="text-lg font-medium text-foreground mb-4">Create New Collection</h2>
<div className="bg-surface border border-primary/30 rounded-xl p-6 mb-6 shadow-sm">
<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
onSubmit={handleCreate}
onCancel={() => setShowCreateForm(false)}
@ -177,119 +363,119 @@ export default function CollectionsPage(): React.ReactElement {
</div>
)}
{/* Collections Table */}
<div className="bg-surface border border-border rounded-lg overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[350px]">Name</TableHead>
<TableHead>Tools</TableHead>
<TableHead>Visibility</TableHead>
<TableHead>Updated</TableHead>
<TableHead className="w-[100px] text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
// Loading skeleton
<>
{[0, 1, 2].map((idx) => (
<TableRow key={`collection-skeleton-${idx}`}>
<TableCell>
<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">
<div className="h-4 w-32 bg-surface-secondary rounded animate-pulse" />
<div className="h-3 w-48 bg-surface-secondary rounded animate-pulse" />
</div>
</div>
</TableCell>
<TableCell>
<div className="h-4 w-12 bg-surface-secondary rounded animate-pulse" />
</TableCell>
<TableCell>
<div className="h-5 w-16 bg-surface-secondary rounded animate-pulse" />
</TableCell>
<TableCell>
<div className="h-4 w-24 bg-surface-secondary rounded animate-pulse" />
</TableCell>
<TableCell>
<div className="h-8 w-8 bg-surface-secondary rounded animate-pulse ml-auto" />
</TableCell>
</TableRow>
))}
</>
) : collections.length === 0 ? (
<TableEmpty
colSpan={5}
icon={
<div className="w-16 h-16 rounded-full bg-primary/10 flex items-center justify-center">
<Icon icon="folder" size="lg" className="text-primary" />
</div>
}
title="No collections yet"
description="Create a collection to organize your tools. Collections make it easy to group related tools together and share them with your agents."
action={
<Button onClick={() => setShowCreateForm(true)}>
<Icon icon="plus" size="sm" className="mr-2" />
Create Your First Collection
</Button>
}
{/* Search */}
{(collections.length > 0 || searchQuery) && !showCreateForm && (
<div className="mb-6">
<div className="relative">
<Icon
icon="search"
size="sm"
className="absolute left-3 top-1/2 -translate-y-1/2 text-foreground-tertiary"
/>
<Input
type="text"
placeholder="Search collections by name or description..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10"
/>
</div>
</div>
)}
{/* Loading State */}
{isLoading && (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{[0, 1, 2, 3, 4, 5].map((idx) => (
<CollectionCardSkeleton key={`skeleton-${idx}`} />
))}
</div>
)}
{/* Empty State */}
{!isLoading && collections.length === 0 && !showCreateForm && (
<div className="text-center py-16 bg-surface border border-border rounded-xl">
<div className="w-20 h-20 rounded-full bg-primary/10 flex items-center justify-center mx-auto mb-6">
<Icon icon="folder" size="lg" className="text-primary" />
</div>
<h2 className="text-xl font-semibold text-foreground mb-2">Create your first collection</h2>
<p className="text-foreground-secondary mb-8 max-w-md mx-auto">
Collections group related tools together and can be shared as MCP servers.
Connect them to Claude, Cursor, or any MCP-compatible client.
</p>
<div className="flex flex-col sm:flex-row items-center justify-center gap-3">
<Button size="lg" onClick={() => setShowCreateForm(true)}>
<Icon icon="plus" size="sm" className="mr-2" />
Create Collection
</Button>
<Link href="/tool/tool-search">
<Button size="lg" variant="outline">
<Icon icon="search" size="sm" className="mr-2" />
Browse Tools
</Button>
</Link>
</div>
</div>
)}
{/* No Results State */}
{!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) => (
<TableRow
key={collection.id}
interactive
onClick={() => router.push(`/dashboard/collections/${collection.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="folder" size="sm" className="text-primary" />
</div>
<div className="min-w-0">
<p className="font-medium text-foreground truncate">{collection.name}</p>
{collection.description && (
<p className="text-sm text-foreground-tertiary truncate max-w-[280px]">
{collection.description}
</p>
)}
</div>
</div>
</TableCell>
<TableCell>
<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>
))}
</div>
</>
)}
{/* Help Section */}
{!isLoading && collections.length > 0 && !showCreateForm && (
<div className="mt-8 p-6 bg-surface/50 border border-border rounded-xl">
<div className="flex items-start gap-4">
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0">
<Icon icon="link" size="sm" className="text-primary" />
</div>
<div>
<h3 className="font-medium text-foreground mb-1">Connect to MCP clients</h3>
<p className="text-sm text-foreground-secondary mb-3">
Each public collection has an MCP URL you can use with Claude Desktop, Cursor,
Windsurf, or any MCP-compatible client. Copy the URL and add it to your client's configuration.
</p>
<Link href="/docs/tutorials/mcp" className="text-sm text-primary hover:underline">
Learn more about MCP integration
</Link>
</div>
</div>
</div>
)}
</DashboardLayout>
);
}