diff --git a/apps/web/src/app/(profile)/[username]/collections/[slug]/CollectionDetailClient.tsx b/apps/web/src/app/(profile)/[username]/collections/[slug]/CollectionDetailClient.tsx index 1805d24..17848a9 100644 --- a/apps/web/src/app/(profile)/[username]/collections/[slug]/CollectionDetailClient.tsx +++ b/apps/web/src/app/(profile)/[username]/collections/[slug]/CollectionDetailClient.tsx @@ -1,32 +1,20 @@ 'use client'; import { Badge } from '@tpmjs/ui/Badge/Badge'; +import { Button } from '@tpmjs/ui/Button/Button'; +import { CodeBlock } from '@tpmjs/ui/CodeBlock/CodeBlock'; import { Icon } from '@tpmjs/ui/Icon/Icon'; import Link from 'next/link'; +import { useCallback, useState } from 'react'; import { AppHeader } from '~/components/AppHeader'; -import { InstallationSection } from '~/components/collections/InstallationSection'; +import { ForkButton } from '~/components/ForkButton'; import { ForkedFromBadge } from '~/components/ForkedFromBadge'; import { LikeButton } from '~/components/LikeButton'; import { ScenariosSection } from '~/components/ScenariosSection'; import { ShareButton } from '~/components/ShareButton'; import { SkillsSection } from '~/components/skills/SkillsSection'; - -export interface CollectionTool { - id: string; - toolId: string; - position: number; - note: string | null; - tool: { - id: string; - name: string; - description: string; - likeCount: number; - package: { - npmPackageName: string; - category: string; - }; - }; -} +import { UseCasesSection } from '~/components/UseCasesSection'; +import { useSession } from '~/lib/auth-client'; /** * Locked state for private collections viewed by non-owners @@ -46,13 +34,46 @@ export function PrivateCollectionLocked({ name }: { name: string }) {

{name}

Private -

This collection is private.

+

+ This collection is private and can only be viewed by its owner. +

); } +export interface CollectionTool { + id: string; + toolId: string; + position: number; + note: string | null; + tool: { + id: string; + name: string; + description: string; + likeCount: number; + package: { + npmPackageName: string; + category: string; + }; + }; +} + +export interface UseCaseToolStep { + toolName: string; + packageName: string; + purpose: string; + order: number; +} + +export interface UseCase { + id: string; + userPrompt: string; + description: string; + toolSequence: UseCaseToolStep[]; +} + export interface PublicCollection { id: string; slug: string; // Already coerced to empty string if null in server component @@ -78,6 +99,184 @@ export interface PublicCollection { username: string; }; } | null; + useCases?: UseCase[] | null; + useCasesGeneratedAt?: string | null; +} + +function McpUrlSection({ + username, + slug, + isOwner, +}: { + username: string; + slug: string; + isOwner: boolean; +}) { + const [copiedUrl, setCopiedUrl] = useState<'http' | 'sse' | null>(null); + const [showConfig, setShowConfig] = useState(false); + const [showApiExample, setShowApiExample] = useState(false); + + const baseUrl = typeof window !== 'undefined' ? window.location.origin : 'https://tpmjs.com'; + const httpUrl = `${baseUrl}/api/mcp/${username}/${slug}/http`; + const sseUrl = `${baseUrl}/api/mcp/${username}/${slug}/sse`; + + const copyToClipboard = async (url: string, type: 'http' | 'sse') => { + await navigator.clipboard.writeText(url); + setCopiedUrl(type); + setTimeout(() => setCopiedUrl(null), 2000); + }; + + const configSnippet = `{ + "mcpServers": { + "tpmjs-${slug}": { + "command": "npx", + "args": [ + "mcp-remote", + "${httpUrl}" + ] + } + } +}`; + + const apiExampleSnippet = `// Call a tool with your own credentials +const response = await fetch("${httpUrl}", { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": "Bearer YOUR_TPMJS_API_KEY" + }, + body: JSON.stringify({ + jsonrpc: "2.0", + method: "tools/call", + params: { + name: "tool-name", + arguments: { /* tool args */ }, + env: { + // Your env vars for the tools + "API_KEY": "your-key-here" + } + }, + id: 1 + }) +});`; + + return ( +
+
+
+ +
+

MCP Server URLs

+
+ +
+ {/* HTTP Transport */} +
+
+ + HTTP Transport + + (recommended) +
+
+
+ {httpUrl} +
+ +
+
+ + {/* SSE Transport */} +
+
+ + SSE Transport + + (streaming) +
+
+
+ {sseUrl} +
+ +
+
+
+ + {/* Note for non-owners */} + {!isOwner && ( +
+

+ + You'll need to provide your own API keys for any tools that require them. Pass + credentials via the{' '} + env parameter in your + API calls. +

+
+ )} + + {/* Config snippet toggle */} +
+ + + {showConfig && ( +
+ +
+ )} + + {!isOwner && ( + <> + + + {showApiExample && ( +
+ +
+ )} + + )} +
+ +

+ Use these URLs with{' '} + + Claude Desktop, Cursor, or any MCP client + +

+
+ ); } interface CollectionDetailClientProps { @@ -85,7 +284,28 @@ interface CollectionDetailClientProps { username: string; } -export function CollectionDetailClient({ collection, username }: CollectionDetailClientProps) { +export function CollectionDetailClient({ + collection: initialCollection, + username, +}: CollectionDetailClientProps) { + const { data: session } = useSession(); + const [collection, setCollection] = useState(initialCollection); + + // Check if current user is the owner + const isOwner = session?.user?.id && collection.createdBy?.id === session.user.id; + + // Handler for when use cases are generated + const handleUseCasesGenerated = useCallback( + (useCases: UseCase[], generatedAt: string) => { + setCollection({ + ...collection, + useCases, + useCasesGeneratedAt: generatedAt, + }); + }, + [collection] + ); + // Generate tweet text const tweetText = collection.description ? `${collection.name} - ${collection.description.slice(0, 100)}${collection.description.length > 100 ? '...' : ''}` @@ -129,6 +349,7 @@ export function CollectionDetailClient({ collection, username }: CollectionDetai entityId={collection.id} initialCount={collection.likeCount} /> + @@ -150,19 +371,8 @@ export function CollectionDetailClient({ collection, username }: CollectionDetai )} - {/* Installation Section */} - + {/* MCP Server URLs - Available to everyone (non-owners must provide their own credentials) */} + {/* Tools */} {collection.tools.length > 0 ? ( @@ -211,6 +421,16 @@ export function CollectionDetailClient({ collection, username }: CollectionDetai )} + {/* Use Cases Section */} + {collection.tools.length > 0 && ( + + )} + {/* Scenarios Section */} {collection.tools.length > 0 && ( ; +interface CollectionTool { + id: string; + toolId: string; + position: number; + note: string | null; + addedAt: string; + tool: { + id: string; + name: string; + description: string; + likeCount: number; + package: { + id: string; + npmPackageName: string; + category: string; + }; + }; } -/** - * DEPRECATED: This route is deprecated in favor of /@username/collections/[slug] - * All requests are 301 redirected to the new canonical URL. - */ -export default async function CollectionRedirectPage({ params }: CollectionRedirectPageProps) { - const { id } = await params; - - // Look up the collection by ID - const collection = await prisma.collection.findUnique({ - where: { id }, - select: { - slug: true, - isPublic: true, - user: { - select: { username: true }, - }, - }, - }); - - // If collection doesn't exist, return 404 - if (!collection) { - notFound(); - } - - // If collection is private, return 404 (don't reveal existence) - if (!collection.isPublic) { - notFound(); - } - - // If user has no username or collection has no slug, can't redirect to pretty URL - if (!collection.user.username || !collection.slug) { - notFound(); - } - - // 301 permanent redirect to the canonical URL - redirect(`/@${collection.user.username}/collections/${collection.slug}`); +interface PublicCollection { + id: string; + slug: string | null; + name: string; + description: string | null; + likeCount: number; + toolCount: number; + createdAt: string; + updatedAt: string; + createdBy: { + id: string; + username: string | null; + name: string; + image: string | null; + }; + tools: CollectionTool[]; +} + +function McpUrlSection({ username, slug }: { username: string; slug: string }) { + const [copiedUrl, setCopiedUrl] = useState<'http' | 'sse' | null>(null); + const [showConfig, setShowConfig] = useState(false); + + const baseUrl = typeof window !== 'undefined' ? window.location.origin : 'https://tpmjs.com'; + const httpUrl = `${baseUrl}/api/mcp/${username}/${slug}/http`; + const sseUrl = `${baseUrl}/api/mcp/${username}/${slug}/sse`; + + const copyToClipboard = async (url: string, type: 'http' | 'sse') => { + await navigator.clipboard.writeText(url); + setCopiedUrl(type); + setTimeout(() => setCopiedUrl(null), 2000); + }; + + const configSnippet = `{ + "mcpServers": { + "tpmjs-collection": { + "command": "npx", + "args": [ + "mcp-remote", + "${httpUrl}", + "--header", + "Authorization: Bearer YOUR_TPMJS_API_KEY" + ] + } + } +}`; + + return ( +
+
+
+ +
+

MCP Server URLs

+
+ +
+ {/* HTTP Transport */} +
+
+ + HTTP Transport + + (recommended) +
+
+
+ {httpUrl} +
+ +
+
+ + {/* SSE Transport */} +
+
+ + SSE Transport + + (streaming) +
+
+
+ {sseUrl} +
+ +
+
+
+ + {/* Config snippet toggle */} +
+ + + {showConfig && ( +
+
+              {configSnippet}
+            
+ +
+ )} +
+ +

+ Use these URLs with{' '} + + Claude Desktop, Cursor, or any MCP client + +

+
+ ); +} + +export default function PublicCollectionDetailPage(): React.ReactElement { + const params = useParams(); + const router = useRouter(); + const collectionId = params.id as string; + + const [collection, setCollection] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchCollection = useCallback(async () => { + try { + const response = await fetch(`/api/public/collections/${collectionId}`); + const data = await response.json(); + + if (data.success) { + // Redirect to pretty URL if username and slug are available + if (data.data.createdBy?.username && data.data.slug) { + router.replace(`/${data.data.createdBy.username}/collections/${data.data.slug}`); + return; + } + setCollection(data.data); + } else { + if (data.error?.code === 'NOT_FOUND' || data.error?.code === 'FORBIDDEN') { + setError('This collection is not available or is private'); + } else { + setError(data.error?.message || 'Failed to fetch collection'); + } + } + } catch (err) { + console.error('Failed to fetch collection:', err); + setError('Failed to fetch collection'); + } finally { + setIsLoading(false); + } + }, [collectionId, router]); + + useEffect(() => { + fetchCollection(); + }, [fetchCollection]); + + if (isLoading) { + return ( +
+ +
+
+
+
+
+
+ {[1, 2, 3].map((i) => ( +
+ ))} +
+
+
+
+ ); + } + + if (error || !collection) { + return ( +
+ +
+
+ +

+ {error || 'Collection not found'} +

+

+ This collection may be private or no longer available. +

+ + + +
+
+
+ ); + } + + return ( +
+ + +
+ {/* Back link */} + + + Back to Collections + + + {/* Header */} +
+
+

{collection.name}

+ {collection.description && ( +

{collection.description}

+ )} +
+ +
+ + {/* Meta info */} +
+
+ {collection.createdBy.image ? ( + {collection.createdBy.name} + ) : ( +
+ +
+ )} + Created by {collection.createdBy.name} +
+ + + {collection.toolCount} tool{collection.toolCount !== 1 ? 's' : ''} + +
+ + {/* MCP URLs */} + {collection.createdBy?.username && collection.slug && ( + + )} + + {/* Tools */} +
+

Tools in this Collection

+ + {collection.tools.length === 0 ? ( +
+ +

No tools in this collection yet

+
+ ) : ( +
+ {collection.tools.map((ct) => ( +
+
+
+ + {ct.tool.name} + + + from {ct.tool.package.npmPackageName} + +
+ +
+

+ {ct.tool.description} +

+ + {ct.tool.package.category} + + {ct.note && ( +

Note: {ct.note}

+ )} +
+ ))} +
+ )} +
+
+
+ ); } diff --git a/apps/web/src/app/dashboard/collections/[id]/page.tsx b/apps/web/src/app/dashboard/collections/[id]/page.tsx index 6fe7916..f4abfc7 100644 --- a/apps/web/src/app/dashboard/collections/[id]/page.tsx +++ b/apps/web/src/app/dashboard/collections/[id]/page.tsx @@ -19,11 +19,41 @@ import { useParams, useRouter, useSearchParams } from 'next/navigation'; import { useCallback, useEffect, useState } from 'react'; import { AddToolSearch } from '~/components/collections/AddToolSearch'; import { CollectionForm } from '~/components/collections/CollectionForm'; -import { InstallationSection } from '~/components/collections/InstallationSection'; import { DashboardLayout } from '~/components/dashboard/DashboardLayout'; import { EnvVarsEditor } from '~/components/EnvVarsEditor'; import { ExecutorConfigPanel } from '~/components/ExecutorConfigPanel'; +// MCP URL display component +function McpUrlDisplay({ url, label, sublabel }: { url: string; label: string; sublabel: string }) { + const [copied, setCopied] = useState(false); + + const copyToClipboard = async () => { + await navigator.clipboard.writeText(url); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + return ( +
+
+ + {label} + + ({sublabel}) +
+
+
+ {url} +
+ +
+
+ ); +} + interface CollectionTool { id: string; toolId: string; @@ -61,9 +91,9 @@ interface Collection { tools: CollectionTool[]; } -type TabId = 'tools' | 'installation' | 'env-vars' | 'settings'; +type TabId = 'tools' | 'connect' | 'env-vars' | 'settings'; -const VALID_TABS: TabId[] = ['tools', 'installation', 'env-vars', 'settings']; +const VALID_TABS: TabId[] = ['tools', 'connect', 'env-vars', 'settings']; export default function CollectionDetailPage(): React.ReactElement { const params = useParams(); @@ -84,6 +114,7 @@ export default function CollectionDetailPage(): React.ReactElement { const [isDeleting, setIsDeleting] = useState(false); const [executorConfig, setExecutorConfig] = useState(null); const [envVars, setEnvVars] = useState | null>(null); + const [showClaudeConfig, setShowClaudeConfig] = useState(false); // Update URL when tab changes const handleTabChange = (tabId: string) => { @@ -334,11 +365,29 @@ export default function CollectionDetailPage(): React.ReactElement { } const existingToolIds = collection.tools.map((t) => t.toolId); + const baseUrl = typeof window !== 'undefined' ? window.location.origin : 'https://tpmjs.com'; + const httpUrl = `${baseUrl}/api/mcp/${collection.user.username}/${collection.slug}/http`; + const sseUrl = `${baseUrl}/api/mcp/${collection.user.username}/${collection.slug}/sse`; + + const configSnippet = `{ + "mcpServers": { + "${collection.slug}": { + "command": "npx", + "args": [ + "mcp-remote", + "${httpUrl}", + "--header", + "Authorization: Bearer YOUR_TPMJS_API_KEY" + ] + } + } +}`; + const envVarsCount = envVars ? Object.keys(envVars).length : 0; const tabs = [ { id: 'tools' as const, label: 'Tools', count: collection.toolCount }, - { id: 'installation' as const, label: 'Installation' }, + { id: 'connect' as const, label: 'Connect' }, { id: 'env-vars' as const, label: 'Env Vars', @@ -469,11 +518,11 @@ export default function CollectionDetailPage(): React.ReactElement { )} - {/* Installation Tab */} - {activeTab === 'installation' && ( + {/* Connect Tab */} + {activeTab === 'connect' && (
{/* Username warning */} - {!collection.user.username && ( + {collection.isPublic && !collection.user.username && (
@@ -509,20 +558,63 @@ export default function CollectionDetailPage(): React.ReactElement {
)} - {/* Installation Section */} - {collection.user.username && ( - + {/* MCP URLs */} + {collection.isPublic && collection.user.username && ( +
+
+
+ +
+

MCP Server URLs

+
+ +
+ + +
+ +
+ + + {showClaudeConfig && ( +
+
+                      {configSnippet}
+                    
+ +
+ )} +
+ +

+ Use these URLs with{' '} + + Claude Desktop, Cursor, or any MCP client + + . Requires your{' '} + + TPMJS API key + {' '} + for authentication. +

+
)}
)} diff --git a/apps/web/src/components/AppHeader.tsx b/apps/web/src/components/AppHeader.tsx index 9f24911..fc89189 100644 --- a/apps/web/src/components/AppHeader.tsx +++ b/apps/web/src/components/AppHeader.tsx @@ -213,6 +213,11 @@ export function AppHeader(): React.ReactElement { Tools + + +