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'; '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>
); );
} }

View file

@ -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>
); );
} }