diff --git a/apps/web/package.json b/apps/web/package.json index 36ee060..1a7a930 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -51,6 +51,7 @@ "rehype-sanitize": "^6.0.0", "remark-gfm": "^4.0.1", "resend": "^6.6.0", + "sonner": "^2.0.7", "zod": "^4.0.0" }, "devDependencies": { diff --git a/apps/web/src/app/agents/page.tsx b/apps/web/src/app/agents/page.tsx index 48bb312..4369429 100644 --- a/apps/web/src/app/agents/page.tsx +++ b/apps/web/src/app/agents/page.tsx @@ -3,9 +3,14 @@ import { Badge } from '@tpmjs/ui/Badge/Badge'; import { Button } from '@tpmjs/ui/Button/Button'; import { Icon } from '@tpmjs/ui/Icon/Icon'; +import { Input } from '@tpmjs/ui/Input/Input'; +import { Select } from '@tpmjs/ui/Select/Select'; +import { Spinner } from '@tpmjs/ui/Spinner/Spinner'; import Link from 'next/link'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { TableVirtuoso } from 'react-virtuoso'; import { AppHeader } from '~/components/AppHeader'; +import { CopyDropdown, getAgentCopyOptions } from '~/components/CopyDropdown'; import { LikeButton } from '~/components/LikeButton'; interface PublicAgent { @@ -28,31 +33,52 @@ interface PublicAgent { type SortOption = 'likes' | 'recent' | 'tools'; +function sortAgents(agents: PublicAgent[], sortBy: SortOption): PublicAgent[] { + return [...agents].sort((a, b) => { + switch (sortBy) { + case 'likes': + return b.likeCount - a.likeCount; + case 'recent': + return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); + case 'tools': + return b.toolCount - a.toolCount; + default: + return 0; + } + }); +} + +function truncateText(text: string, maxLength: number): string { + if (text.length <= maxLength) return text; + return `${text.slice(0, maxLength).trim()}...`; +} + export default function PublicAgentsPage(): React.ReactElement { const [agents, setAgents] = useState([]); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); const [hasMore, setHasMore] = useState(false); - const [offset, setOffset] = useState(0); const [search, setSearch] = useState(''); const [sort, setSort] = useState('likes'); - const limit = 20; + const loadingMore = useRef(false); const fetchAgents = useCallback( - async (currentOffset: number, resetList = false) => { + async (offset: number, resetList = false) => { try { + if (loadingMore.current && !resetList) return; + loadingMore.current = true; + const params = new URLSearchParams({ - limit: String(limit), - offset: String(currentOffset), + limit: '100', + offset: String(offset), sort, - ...(search && { search }), }); const response = await fetch(`/api/public/agents?${params}`); const data = await response.json(); if (data.success) { - if (resetList || currentOffset === 0) { + if (resetList || offset === 0) { setAgents(data.data); } else { setAgents((prev) => [...prev, ...data.data]); @@ -66,29 +92,111 @@ export default function PublicAgentsPage(): React.ReactElement { setError('Failed to fetch agents'); } finally { setIsLoading(false); + loadingMore.current = false; } }, - [sort, search] + [sort] ); useEffect(() => { - setOffset(0); setIsLoading(true); fetchAgents(0, true); }, [fetchAgents]); - const loadMore = () => { - const newOffset = offset + limit; - setOffset(newOffset); - fetchAgents(newOffset); - }; + const loadMore = useCallback(() => { + if (!hasMore || loadingMore.current) return; + fetchAgents(agents.length); + }, [hasMore, agents.length, fetchAgents]); - const handleSearch = (e: React.FormEvent) => { - e.preventDefault(); - setOffset(0); - setIsLoading(true); - fetchAgents(0, true); - }; + // Filter and sort agents + const filteredAgents = useMemo(() => { + let result = agents; + + if (search) { + const query = search.toLowerCase(); + result = result.filter( + (a) => + a.name.toLowerCase().includes(query) || + a.description?.toLowerCase().includes(query) || + a.provider.toLowerCase().includes(query) + ); + } + + return sortAgents(result, sort); + }, [agents, search, sort]); + + const TableHeader = useCallback( + () => ( + + Name + Description + Provider + Tools + Likes + Creator + Copy + + ), + [] + ); + + const TableRow = useCallback((_index: number, agent: PublicAgent) => { + return ( + <> + + + {agent.name} + + + + {agent.description ? truncateText(agent.description, 50) : '—'} + + + + {agent.provider} + + + + + {agent.toolCount} + + + + + + +
+ {agent.createdBy.image ? ( + {agent.createdBy.name} + ) : ( +
+ +
+ )} + + {agent.createdBy.name} + +
+ + + + + + ); + }, []); return (
@@ -105,34 +213,26 @@ export default function PublicAgentsPage(): React.ReactElement { {/* Filters */}
-
-
- - setSearch(e.target.value)} - placeholder="Search agents..." - className="w-full pl-10 pr-4 py-2 bg-surface border border-border rounded-lg text-foreground placeholder:text-foreground-tertiary focus:outline-none focus:ring-2 focus:ring-primary/50" - /> -
-
+
+ setSearch(e.target.value)} + placeholder="Search agents..." + /> +
Sort: - + options={[ + { value: 'likes', label: 'Most Liked' }, + { value: 'recent', label: 'Most Recent' }, + { value: 'tools', label: 'Most Tools' }, + ]} + />
@@ -145,20 +245,11 @@ export default function PublicAgentsPage(): React.ReactElement {
) : isLoading ? ( -
- {[1, 2, 3, 4, 5, 6].map((i) => ( -
-
-
-
-
-
- ))} +
+ + Loading agents...
- ) : agents.length === 0 ? ( + ) : filteredAgents.length === 0 ? (
@@ -170,75 +261,42 @@ export default function PublicAgentsPage(): React.ReactElement {
) : ( <> -
- {agents.map((agent) => ( -
-
- - {agent.name} - - + ( + - - - {agent.description && ( -

- {agent.description} -

- )} - -
- - {agent.provider} - - {agent.modelId} -
- -
-
- - - {agent.toolCount} tool{agent.toolCount !== 1 ? 's' : ''} - -
-
- {agent.createdBy.image ? ( - {agent.createdBy.name} - ) : ( -
- -
- )} - - {agent.createdBy.name} - -
-
- - ))} + ), + TableHead: (props) => ( + + ), + TableBody: (props) => , + TableRow: (props) => ( + + ), + }} + /> - {hasMore && ( -
- -
- )} +
+ Showing {filteredAgents.length} agent + {filteredAgents.length !== 1 ? 's' : ''} + {search && ` matching "${search}"`} + {hasMore && ' (scroll for more)'} +
)} diff --git a/apps/web/src/app/collections/page.tsx b/apps/web/src/app/collections/page.tsx index 5c7c3b7..5676b92 100644 --- a/apps/web/src/app/collections/page.tsx +++ b/apps/web/src/app/collections/page.tsx @@ -1,10 +1,16 @@ 'use client'; +import { Badge } from '@tpmjs/ui/Badge/Badge'; import { Button } from '@tpmjs/ui/Button/Button'; import { Icon } from '@tpmjs/ui/Icon/Icon'; +import { Input } from '@tpmjs/ui/Input/Input'; +import { Select } from '@tpmjs/ui/Select/Select'; +import { Spinner } from '@tpmjs/ui/Spinner/Spinner'; import Link from 'next/link'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { TableVirtuoso } from 'react-virtuoso'; import { AppHeader } from '~/components/AppHeader'; +import { CopyDropdown, getCollectionCopyOptions } from '~/components/CopyDropdown'; import { LikeButton } from '~/components/LikeButton'; interface PublicCollection { @@ -23,31 +29,52 @@ interface PublicCollection { type SortOption = 'likes' | 'recent' | 'tools'; +function sortCollections(collections: PublicCollection[], sortBy: SortOption): PublicCollection[] { + return [...collections].sort((a, b) => { + switch (sortBy) { + case 'likes': + return b.likeCount - a.likeCount; + case 'recent': + return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); + case 'tools': + return b.toolCount - a.toolCount; + default: + return 0; + } + }); +} + +function truncateText(text: string, maxLength: number): string { + if (text.length <= maxLength) return text; + return `${text.slice(0, maxLength).trim()}...`; +} + export default function PublicCollectionsPage(): React.ReactElement { const [collections, setCollections] = useState([]); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); const [hasMore, setHasMore] = useState(false); - const [offset, setOffset] = useState(0); const [search, setSearch] = useState(''); const [sort, setSort] = useState('likes'); - const limit = 20; + const loadingMore = useRef(false); const fetchCollections = useCallback( - async (currentOffset: number, resetList = false) => { + async (offset: number, resetList = false) => { try { + if (loadingMore.current && !resetList) return; + loadingMore.current = true; + const params = new URLSearchParams({ - limit: String(limit), - offset: String(currentOffset), + limit: '100', + offset: String(offset), sort, - ...(search && { search }), }); const response = await fetch(`/api/public/collections?${params}`); const data = await response.json(); if (data.success) { - if (resetList || currentOffset === 0) { + if (resetList || offset === 0) { setCollections(data.data); } else { setCollections((prev) => [...prev, ...data.data]); @@ -61,29 +88,105 @@ export default function PublicCollectionsPage(): React.ReactElement { setError('Failed to fetch collections'); } finally { setIsLoading(false); + loadingMore.current = false; } }, - [sort, search] + [sort] ); useEffect(() => { - setOffset(0); setIsLoading(true); fetchCollections(0, true); }, [fetchCollections]); - const loadMore = () => { - const newOffset = offset + limit; - setOffset(newOffset); - fetchCollections(newOffset); - }; + const loadMore = useCallback(() => { + if (!hasMore || loadingMore.current) return; + fetchCollections(collections.length); + }, [hasMore, collections.length, fetchCollections]); - const handleSearch = (e: React.FormEvent) => { - e.preventDefault(); - setOffset(0); - setIsLoading(true); - fetchCollections(0, true); - }; + // Filter and sort collections + const filteredCollections = useMemo(() => { + let result = collections; + + if (search) { + const query = search.toLowerCase(); + result = result.filter( + (c) => c.name.toLowerCase().includes(query) || c.description?.toLowerCase().includes(query) + ); + } + + return sortCollections(result, sort); + }, [collections, search, sort]); + + const TableHeader = useCallback( + () => ( + + + + + + + + + ), + [] + ); + + const TableRow = useCallback((_index: number, collection: PublicCollection) => { + return ( + <> + + + + + + + + ); + }, []); return (
@@ -100,34 +203,26 @@ export default function PublicCollectionsPage(): React.ReactElement { {/* Filters */}
-
-
- - setSearch(e.target.value)} - placeholder="Search collections..." - className="w-full pl-10 pr-4 py-2 bg-surface border border-border rounded-lg text-foreground placeholder:text-foreground-tertiary focus:outline-none focus:ring-2 focus:ring-primary/50" - /> -
- +
+ setSearch(e.target.value)} + placeholder="Search collections..." + /> +
Sort: - + options={[ + { value: 'likes', label: 'Most Liked' }, + { value: 'recent', label: 'Most Recent' }, + { value: 'tools', label: 'Most Tools' }, + ]} + />
@@ -140,20 +235,13 @@ export default function PublicCollectionsPage(): React.ReactElement {
) : isLoading ? ( -
- {[1, 2, 3, 4, 5, 6].map((i) => ( -
-
-
-
-
-
- ))} +
+ + + Loading collections... +
- ) : collections.length === 0 ? ( + ) : filteredCollections.length === 0 ? (
@@ -167,68 +255,42 @@ export default function PublicCollectionsPage(): React.ReactElement {
) : ( <> -
- {collections.map((collection) => ( -
-
- - {collection.name} - - + ( +
NameDescriptionToolsLikesCreatorCopy
+ + {collection.name} + + + {collection.description ? truncateText(collection.description, 60) : '—'} + + + {collection.toolCount} + + + + +
+ {collection.createdBy.image ? ( + {collection.createdBy.name} + ) : ( +
+ +
+ )} + + {collection.createdBy.name} + +
+
+ +
- - - {collection.description && ( -

- {collection.description} -

- )} - -
-
- - - {collection.toolCount} tool{collection.toolCount !== 1 ? 's' : ''} - -
-
- {collection.createdBy.image ? ( - {collection.createdBy.name} - ) : ( -
- -
- )} - - {collection.createdBy.name} - -
-
- - ))} + ), + TableHead: (props) => ( + + ), + TableBody: (props) => , + TableRow: (props) => ( + + ), + }} + /> - {hasMore && ( -
- -
- )} +
+ Showing {filteredCollections.length} collection + {filteredCollections.length !== 1 ? 's' : ''} + {search && ` matching "${search}"`} + {hasMore && ' (scroll for more)'} +
)} diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index 684c49b..feaf064 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -2,6 +2,7 @@ import { Analytics } from '@vercel/analytics/next'; import type { Metadata } from 'next'; import { Space_Grotesk, Space_Mono } from 'next/font/google'; import Script from 'next/script'; +import { Toaster } from 'sonner'; import { AppFooter } from '../components/AppFooter'; import { ThemeProvider } from '../components/providers/ThemeProvider'; import './globals.css'; @@ -160,6 +161,7 @@ export default function RootLayout({
{children}
+ diff --git a/apps/web/src/app/tool/tool-search/page.tsx b/apps/web/src/app/tool/tool-search/page.tsx index 5d7fb36..c9ddad5 100644 --- a/apps/web/src/app/tool/tool-search/page.tsx +++ b/apps/web/src/app/tool/tool-search/page.tsx @@ -2,24 +2,29 @@ import { Badge } from '@tpmjs/ui/Badge/Badge'; import { Button } from '@tpmjs/ui/Button/Button'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@tpmjs/ui/Card/Card'; -import { CodeBlock } from '@tpmjs/ui/CodeBlock/CodeBlock'; +import { Card, CardContent } from '@tpmjs/ui/Card/Card'; import { Container } from '@tpmjs/ui/Container/Container'; import { Icon } from '@tpmjs/ui/Icon/Icon'; import { Input } from '@tpmjs/ui/Input/Input'; -import { ProgressBar } from '@tpmjs/ui/ProgressBar/ProgressBar'; import { Select } from '@tpmjs/ui/Select/Select'; import { Spinner } from '@tpmjs/ui/Spinner/Spinner'; -import { formatTimeAgo } from '@tpmjs/utils/format'; import Link from 'next/link'; -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { TableVirtuoso } from 'react-virtuoso'; import { AppHeader } from '~/components/AppHeader'; +import { CopyButton } from '~/components/CopyButton'; +import { + PackageManagerSelector, + getInstallCommand, + usePackageManager, +} from '~/components/PackageManagerSelector'; interface Tool { id: string; name: string; description: string; qualityScore: string; + likeCount?: number; importHealth?: 'HEALTHY' | 'BROKEN' | 'UNKNOWN'; executionHealth?: 'HEALTHY' | 'BROKEN' | 'UNKNOWN'; createdAt: string; @@ -34,7 +39,13 @@ interface Tool { }; } -type SortOption = 'downloads' | 'recent'; +type SortOption = 'downloads' | 'likes' | 'recent' | 'name'; + +function formatDownloads(count: number): string { + if (count >= 1000000) return `${(count / 1000000).toFixed(1)}M`; + if (count >= 1000) return `${(count / 1000).toFixed(1)}k`; + return count.toString(); +} /** Sort tools by criterion, pushing broken tools to the bottom */ function sortTools(tools: Tool[], sortBy: SortOption): Tool[] { @@ -44,24 +55,35 @@ function sortTools(tools: Tool[], sortBy: SortOption): Tool[] { // Always push broken tools to bottom if (aIsBroken && !bIsBroken) return 1; if (!aIsBroken && bIsBroken) return -1; + // Within same broken status, sort by selected criterion - if (sortBy === 'downloads') { - const aDownloads = a.package.npmDownloadsLastMonth ?? 0; - const bDownloads = b.package.npmDownloadsLastMonth ?? 0; - return bDownloads - aDownloads; + switch (sortBy) { + case 'downloads': { + const aDownloads = a.package.npmDownloadsLastMonth ?? 0; + const bDownloads = b.package.npmDownloadsLastMonth ?? 0; + return bDownloads - aDownloads; + } + case 'likes': { + const aLikes = a.likeCount ?? 0; + const bLikes = b.likeCount ?? 0; + return bLikes - aLikes; + } + case 'recent': { + const aTime = a.createdAt ? new Date(a.createdAt).getTime() : 0; + const bTime = b.createdAt ? new Date(b.createdAt).getTime() : 0; + return bTime - aTime; + } + case 'name': { + const aName = a.name.toLowerCase(); + const bName = b.name.toLowerCase(); + return aName.localeCompare(bName); + } + default: + return 0; } - // Sort by recent (createdAt descending) - const aTime = a.createdAt ? new Date(a.createdAt).getTime() : 0; - const bTime = b.createdAt ? new Date(b.createdAt).getTime() : 0; - return bTime - aTime; }); } -/** - * Tool Registry Search Page - * - * Fetches tools from the /api/tools endpoint and displays them in a searchable grid. - */ export default function ToolSearchPage(): React.ReactElement { const [searchQuery, setSearchQuery] = useState(''); const [categoryFilter, setCategoryFilter] = useState('all'); @@ -71,19 +93,15 @@ export default function ToolSearchPage(): React.ReactElement { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [availableCategories, setAvailableCategories] = useState([]); + const [packageManager, setPackageManager] = usePackageManager(); // Fetch tools from API useEffect(() => { - // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Tool search page requires complex filtering logic const fetchTools = async () => { try { setLoading(true); const params = new URLSearchParams(); - if (searchQuery) { - params.set('q', searchQuery); - } - if (categoryFilter !== 'all') { params.set('category', categoryFilter); } @@ -102,18 +120,16 @@ export default function ToolSearchPage(): React.ReactElement { if (toolsData.success) { const fetchedTools = toolsData.data; - setTools(sortTools(fetchedTools, sortBy)); + setTools(fetchedTools); setError(null); // Extract unique categories from all tools const categories = new Set(); - for (const tool of fetchedTools) { if (tool.package.category) { categories.add(tool.package.category); } } - setAvailableCategories(Array.from(categories).sort()); } else { setError(toolsData.error || 'Failed to fetch tools'); @@ -126,13 +142,93 @@ export default function ToolSearchPage(): React.ReactElement { }; fetchTools(); - }, [searchQuery, categoryFilter, healthFilter, sortBy]); + }, [categoryFilter, healthFilter]); + + // Filter and sort tools + const filteredTools = useMemo(() => { + let result = tools; + + // Apply search filter + if (searchQuery) { + const query = searchQuery.toLowerCase(); + result = result.filter( + (tool) => + tool.name.toLowerCase().includes(query) || + tool.package.npmPackageName.toLowerCase().includes(query) || + tool.description.toLowerCase().includes(query) + ); + } + + // Sort tools + return sortTools(result, sortBy); + }, [tools, searchQuery, sortBy]); + + const TableHeader = useCallback( + () => ( + + + + + + + + ), + [] + ); + + const TableRow = useCallback( + (_index: number, tool: Tool) => { + const isBroken = tool.importHealth === 'BROKEN' || tool.executionHealth === 'BROKEN'; + const displayName = tool.name !== 'default' ? tool.name : tool.package.npmPackageName; + const installCommand = getInstallCommand(tool.package.npmPackageName, packageManager); + + return ( + <> + + + + + + + ); + }, + [packageManager] + ); return (
- {/* Main content */} {/* Page header */}
@@ -155,9 +251,9 @@ export default function ToolSearchPage(): React.ReactElement { /> {/* Filter row */} -
+
{/* Category filter */} -
+
Category: @@ -165,7 +261,7 @@ export default function ToolSearchPage(): React.ReactElement { value={categoryFilter} onChange={(e) => setCategoryFilter(e.target.value)} size="sm" - className="flex-1 sm:flex-none sm:min-w-[150px]" + className="min-w-[150px]" options={[ { value: 'all', label: 'All Categories' }, ...availableCategories.map((cat) => ({ @@ -177,7 +273,7 @@ export default function ToolSearchPage(): React.ReactElement {
{/* Health filter */} -
+
Health: @@ -185,7 +281,7 @@ export default function ToolSearchPage(): React.ReactElement { value={healthFilter} onChange={(e) => setHealthFilter(e.target.value)} size="sm" - className="flex-1 sm:flex-none sm:min-w-[130px]" + className="min-w-[130px]" options={[ { value: 'all', label: 'All Tools' }, { value: 'healthy', label: 'Healthy Only' }, @@ -195,7 +291,7 @@ export default function ToolSearchPage(): React.ReactElement {
{/* Sort dropdown */} -
+
Sort: @@ -203,10 +299,12 @@ export default function ToolSearchPage(): React.ReactElement { value={sortBy} onChange={(e) => setSortBy(e.target.value as SortOption)} size="sm" - className="flex-1 sm:flex-none sm:min-w-[150px]" + className="min-w-[150px]" options={[ - { value: 'downloads', label: 'Most Downloaded' }, + { value: 'downloads', label: 'Downloads' }, + { value: 'likes', label: 'Likes' }, { value: 'recent', label: 'Recent' }, + { value: 'name', label: 'Name (A-Z)' }, ]} />
@@ -225,6 +323,9 @@ export default function ToolSearchPage(): React.ReactElement { )}
+ + {/* Package manager selector */} +
{/* Loading state */} @@ -240,141 +341,55 @@ export default function ToolSearchPage(): React.ReactElement { {/* Error state */} {error &&
Error: {error}
} - {/* Tool grid */} - {!loading && !error && tools.length > 0 && ( -
- {tools.map((tool) => { - const isBroken = tool.importHealth === 'BROKEN' || tool.executionHealth === 'BROKEN'; - const qualityPercent = Math.round(Number.parseFloat(tool.qualityScore) * 100); + {/* Tool table */} + {!loading && !error && filteredTools.length > 0 && ( +
+ ( +
NameCategoryDownloadsLikesInstall
+ +
+ {displayName} + {isBroken && ( + + Broken + + )} +
+
+ {tool.package.npmPackageName} +
+ +
+ + {tool.package.category} + + + {formatDownloads(tool.package.npmDownloadsLastMonth)} + + + + {tool.likeCount ?? 0} + + + +
+ ), + TableHead: (props) => , + TableBody: (props) => , + TableRow: (props) => ( + + ), + }} + /> + + )} - // Clean up repository URL - let repoUrl = tool.package.npmRepository?.url || ''; - repoUrl = repoUrl.replace(/^git\+/, ''); - repoUrl = repoUrl.replace(/\.git$/, ''); - repoUrl = repoUrl.replace(/^git:\/\//, 'https://'); - repoUrl = repoUrl.replace(/^git@github\.com:/, 'https://github.com/'); - - return ( - - - - {/* Top row: Title + metadata */} -
-
- - {tool.name !== 'default' ? tool.name : tool.package.npmPackageName} - -
- {tool.package.npmPackageName} -
-
- {/* Right side: downloads, version, link */} -
- {tool.package.npmDownloadsLastMonth.toLocaleString()}/mo - v{tool.package.npmVersion} - {repoUrl && ( - - )} -
-
- {/* Description */} - - {tool.description} - -
- - - {/* Category badge */} -
- - {tool.package.category} - -
- - {/* Quality + Broken status row */} -
-
- = 70 - ? 'success' - : qualityPercent >= 50 - ? 'primary' - : 'warning' - } - size="sm" - showLabel={false} - className="flex-1" - /> - - {qualityPercent}% - -
- {isBroken && ( - - Broken - - )} -
- - {/* Bottom section with install command and published date */} -
-
e.stopPropagation()} - onKeyDown={(e) => e.stopPropagation()} - role="presentation" - > - -
- {tool.package.npmPublishedAt && ( -
- Published {formatTimeAgo(tool.package.npmPublishedAt)} -
- )} -
-
-
- - ); - })} + {/* Results count */} + {!loading && !error && filteredTools.length > 0 && ( +
+ Showing {filteredTools.length} tool{filteredTools.length !== 1 ? 's' : ''} + {searchQuery && ` matching "${searchQuery}"`}
)} {/* Empty States */} - {!loading && !error && tools.length === 0 && ( + {!loading && !error && filteredTools.length === 0 && (
- {/* Icon/Visual Element */}
- {/* Search query with no results */} {searchQuery && ( <>
@@ -405,7 +420,6 @@ export default function ToolSearchPage(): React.ReactElement { )} - {/* Filters active but no search query */} {!searchQuery && (categoryFilter !== 'all' || healthFilter !== 'all') && ( <>
@@ -415,32 +429,19 @@ export default function ToolSearchPage(): React.ReactElement {

Try adjusting or clearing your filters to see more tools.

- {categoryFilter !== 'all' && ( -

- Current filter: Category = {categoryFilter} -

- )} - {healthFilter !== 'all' && ( -

- Current filter: Health = {healthFilter} -

- )} -
-
-
+ )} - {/* No tools at all (edge case) */} {!searchQuery && categoryFilter === 'all' && healthFilter === 'all' && ( <>
@@ -449,64 +450,15 @@ export default function ToolSearchPage(): React.ReactElement { Be the first to publish a tool and help AI agents gain new capabilities.

-
- - -
-
-

- Publishing a tool is easy: -

-
-
-
- 1 -
-

- Add{' '} - tpmjs{' '} - keyword to your package.json -

-
-
-
- 2 -
-

- Include a{' '} - tpmjs{' '} - field with tool metadata -

-
-
-
- 3 -
-

- Publish to npm and your tool appears here automatically -

-
-
-
+ )} diff --git a/apps/web/src/components/CopyButton.tsx b/apps/web/src/components/CopyButton.tsx new file mode 100644 index 0000000..a619bc6 --- /dev/null +++ b/apps/web/src/components/CopyButton.tsx @@ -0,0 +1,48 @@ +'use client'; + +import { Icon } from '@tpmjs/ui/Icon/Icon'; +import { useCallback, useState } from 'react'; +import { toast } from 'sonner'; + +interface CopyButtonProps { + text: string; + label?: string; + successMessage?: string; + size?: 'xs' | 'sm' | 'md'; + className?: string; +} + +export function CopyButton({ + text, + label, + successMessage = 'Copied to clipboard', + size = 'sm', + className = '', +}: CopyButtonProps): React.ReactElement { + const [copied, setCopied] = useState(false); + + const handleCopy = useCallback(async () => { + try { + await navigator.clipboard.writeText(text); + setCopied(true); + toast.success(successMessage); + setTimeout(() => setCopied(false), 2000); + } catch { + toast.error('Failed to copy'); + } + }, [text, successMessage]); + + const iconSize = size === 'xs' ? 'xs' : size === 'sm' ? 'sm' : 'md'; + + return ( + + ); +} diff --git a/apps/web/src/components/CopyDropdown.tsx b/apps/web/src/components/CopyDropdown.tsx new file mode 100644 index 0000000..9ae5ab6 --- /dev/null +++ b/apps/web/src/components/CopyDropdown.tsx @@ -0,0 +1,146 @@ +'use client'; + +import { Icon } from '@tpmjs/ui/Icon/Icon'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { toast } from 'sonner'; + +interface CopyOption { + label: string; + value: string; + description?: string; +} + +interface CopyDropdownProps { + options: CopyOption[]; + buttonLabel?: string; + className?: string; +} + +export function CopyDropdown({ + options, + buttonLabel = 'Copy', + className = '', +}: CopyDropdownProps): React.ReactElement { + const [isOpen, setIsOpen] = useState(false); + const dropdownRef = useRef(null); + + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setIsOpen(false); + } + } + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + const handleCopy = useCallback(async (option: CopyOption) => { + try { + await navigator.clipboard.writeText(option.value); + toast.success(`${option.label} copied to clipboard`); + setIsOpen(false); + } catch { + toast.error('Failed to copy'); + } + }, []); + + return ( +
+ + + {isOpen && ( +
+ {options.map((option) => ( + + ))} +
+ )} +
+ ); +} + +// Helper functions to generate copy options for different entity types + +export function getCollectionCopyOptions( + collectionId: string, + collectionName: string +): CopyOption[] { + const baseUrl = 'https://tpmjs.com'; + const mcpUrlHttp = `${baseUrl}/mcp/collections/${collectionId}`; + const mcpUrlSse = `${baseUrl}/mcp/collections/${collectionId}/sse`; + + const claudeConfig = JSON.stringify( + { + mcpServers: { + [collectionName.toLowerCase().replace(/\s+/g, '-')]: { + command: 'npx', + args: ['-y', '@anthropic-ai/mcp-remote', mcpUrlSse], + }, + }, + }, + null, + 2 + ); + + return [ + { label: 'MCP URL (HTTP)', value: mcpUrlHttp, description: mcpUrlHttp }, + { label: 'MCP URL (SSE)', value: mcpUrlSse, description: mcpUrlSse }, + { + label: 'Claude Config', + value: claudeConfig, + description: 'JSON for claude_desktop_config.json', + }, + ]; +} + +export function getAgentCopyOptions(agentUid: string, agentName: string): CopyOption[] { + const baseUrl = 'https://tpmjs.com'; + const mcpUrl = `${baseUrl}/mcp/agents/${agentUid}`; + + const claudeConfig = JSON.stringify( + { + mcpServers: { + [agentName.toLowerCase().replace(/\s+/g, '-')]: { + command: 'npx', + args: ['-y', '@anthropic-ai/mcp-remote', mcpUrl], + }, + }, + }, + null, + 2 + ); + + return [ + { label: 'Agent UID', value: agentUid, description: agentUid }, + { label: 'MCP URL', value: mcpUrl, description: mcpUrl }, + { + label: 'Claude Config', + value: claudeConfig, + description: 'JSON for claude_desktop_config.json', + }, + ]; +} diff --git a/apps/web/src/components/PackageManagerSelector.tsx b/apps/web/src/components/PackageManagerSelector.tsx new file mode 100644 index 0000000..ce83a7b --- /dev/null +++ b/apps/web/src/components/PackageManagerSelector.tsx @@ -0,0 +1,112 @@ +'use client'; + +import { useCallback, useEffect, useState } from 'react'; + +export type PackageManager = 'npm' | 'yarn' | 'pnpm' | 'bun'; + +const STORAGE_KEY = 'tpmjs-package-manager'; + +const packageManagers: { id: PackageManager; label: string }[] = [ + { id: 'npm', label: 'npm' }, + { id: 'yarn', label: 'yarn' }, + { id: 'pnpm', label: 'pnpm' }, + { id: 'bun', label: 'bun' }, +]; + +interface PackageManagerSelectorProps { + value?: PackageManager; + onChange?: (manager: PackageManager) => void; + className?: string; +} + +export function PackageManagerSelector({ + value, + onChange, + className = '', +}: PackageManagerSelectorProps): React.ReactElement { + const [selected, setSelected] = useState('npm'); + + // Load from localStorage on mount + useEffect(() => { + if (typeof window !== 'undefined') { + const stored = localStorage.getItem(STORAGE_KEY) as PackageManager | null; + if (stored && packageManagers.some((pm) => pm.id === stored)) { + setSelected(stored); + } + } + }, []); + + // Sync with controlled value + useEffect(() => { + if (value) { + setSelected(value); + } + }, [value]); + + const handleSelect = useCallback( + (manager: PackageManager) => { + setSelected(manager); + localStorage.setItem(STORAGE_KEY, manager); + onChange?.(manager); + }, + [onChange] + ); + + return ( +
+ Package Manager: +
+ {packageManagers.map((pm) => ( + + ))} +
+
+ ); +} + +export function getInstallCommand(packageName: string, manager: PackageManager): string { + switch (manager) { + case 'npm': + return `npm install ${packageName}`; + case 'yarn': + return `yarn add ${packageName}`; + case 'pnpm': + return `pnpm add ${packageName}`; + case 'bun': + return `bun add ${packageName}`; + default: + return `npm install ${packageName}`; + } +} + +// Hook for getting current package manager +export function usePackageManager(): [PackageManager, (manager: PackageManager) => void] { + const [manager, setManager] = useState('npm'); + + useEffect(() => { + if (typeof window !== 'undefined') { + const stored = localStorage.getItem(STORAGE_KEY) as PackageManager | null; + if (stored && packageManagers.some((pm) => pm.id === stored)) { + setManager(stored); + } + } + }, []); + + const updateManager = useCallback((newManager: PackageManager) => { + setManager(newManager); + localStorage.setItem(STORAGE_KEY, newManager); + }, []); + + return [manager, updateManager]; +} diff --git a/manual-tools.ts b/manual-tools.ts index 8db5d1e..d328190 100644 --- a/manual-tools.ts +++ b/manual-tools.ts @@ -894,14 +894,15 @@ export const manualTools: ManualTool[] = [ type: 'object', description: 'An object containing file paths as keys and file contents as values.', required: true, - } + }, ], returns: { type: 'object', description: 'An object containing the tools available in the created bash environment.', }, aiAgent: { - useCase: 'Use this tool to execute bash commands and manipulate files within a sandboxed environment.', + useCase: + 'Use this tool to execute bash commands and manipulate files within a sandboxed environment.', examples: ['Create a bash environment with specific files and execute commands.'], }, docsUrl: 'https://github.com/vercel/bash-tool', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3aa9e2b..23e6e8a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -331,6 +331,9 @@ importers: resend: specifier: ^6.6.0 version: 6.6.0 + sonner: + specifier: ^2.0.7 + version: 2.0.7(react-dom@19.2.0(react@19.2.0))(react@19.2.0) zod: specifier: ^4.0.0 version: 4.1.13 @@ -10090,6 +10093,12 @@ packages: resolution: {integrity: sha512-QlaZEqcAH3/RtNyet1IPIYPsEWAaYyXXv1Krsi+1L/QHppjX4Ifm8MQsBISz9vE8cHicIq3clogsheili5vhaQ==} engines: {node: '>= 18'} + sonner@2.0.7: + resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -18503,6 +18512,11 @@ snapshots: smol-toml@1.5.2: {} + sonner@2.0.7(react-dom@19.2.0(react@19.2.0))(react@19.2.0): + dependencies: + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + source-map-js@1.2.1: {} source-map-support@0.5.21: