fix(web): restore all collections features deleted in a52d32c
Restores Collections nav link in AppHeader, full collections/[id] detail page with MCP URLs, dashboard collections Connect tab with McpUrlDisplay, and CollectionDetailClient with ForkButton, CodeBlock, UseCases, and Scenarios sections.
This commit is contained in:
parent
bd232a3407
commit
99017322ba
4 changed files with 736 additions and 95 deletions
|
|
@ -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 }) {
|
|||
<h1 className="text-2xl font-bold text-foreground">{name}</h1>
|
||||
<Badge variant="secondary">Private</Badge>
|
||||
</div>
|
||||
<p className="text-foreground-secondary">This collection is private.</p>
|
||||
<p className="text-foreground-secondary max-w-md">
|
||||
This collection is private and can only be viewed by its owner.
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<section className="p-4 bg-gradient-to-br from-primary/5 via-transparent to-primary/5 border border-primary/20 rounded-xl">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<div className="p-1.5 bg-primary/10 rounded-lg">
|
||||
<Icon icon="link" className="w-4 h-4 text-primary" />
|
||||
</div>
|
||||
<h3 className="font-semibold text-foreground">MCP Server URLs</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{/* HTTP Transport */}
|
||||
<div className="group">
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<span className="text-xs font-medium text-foreground-secondary uppercase tracking-wide">
|
||||
HTTP Transport
|
||||
</span>
|
||||
<span className="text-xs text-foreground-tertiary">(recommended)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 px-3 py-2 bg-surface border border-border rounded-lg font-mono text-sm text-foreground-secondary overflow-x-auto">
|
||||
{httpUrl}
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => copyToClipboard(httpUrl, 'http')}
|
||||
className="shrink-0"
|
||||
>
|
||||
<Icon icon={copiedUrl === 'http' ? 'check' : 'copy'} className="w-4 h-4 mr-1" />
|
||||
{copiedUrl === 'http' ? 'Copied!' : 'Copy'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SSE Transport */}
|
||||
<div className="group">
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<span className="text-xs font-medium text-foreground-secondary uppercase tracking-wide">
|
||||
SSE Transport
|
||||
</span>
|
||||
<span className="text-xs text-foreground-tertiary">(streaming)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 px-3 py-2 bg-surface border border-border rounded-lg font-mono text-sm text-foreground-secondary overflow-x-auto">
|
||||
{sseUrl}
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => copyToClipboard(sseUrl, 'sse')}
|
||||
className="shrink-0"
|
||||
>
|
||||
<Icon icon={copiedUrl === 'sse' ? 'check' : 'copy'} className="w-4 h-4 mr-1" />
|
||||
{copiedUrl === 'sse' ? 'Copied!' : 'Copy'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Note for non-owners */}
|
||||
{!isOwner && (
|
||||
<div className="mt-4 p-3 bg-warning/10 border border-warning/20 rounded-lg">
|
||||
<p className="text-sm text-warning-foreground">
|
||||
<Icon icon="info" className="w-4 h-4 inline mr-1" />
|
||||
You'll need to provide your own API keys for any tools that require them. Pass
|
||||
credentials via the{' '}
|
||||
<code className="font-mono text-xs bg-surface px-1 rounded">env</code> parameter in your
|
||||
API calls.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Config snippet toggle */}
|
||||
<div className="mt-4 pt-4 border-t border-border/50 space-y-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowConfig(!showConfig)}
|
||||
className="flex items-center gap-2 text-sm text-primary hover:text-primary/80 transition-colors"
|
||||
>
|
||||
<Icon icon={showConfig ? 'chevronDown' : 'chevronRight'} className="w-4 h-4" />
|
||||
<span>Show Claude Desktop config</span>
|
||||
</button>
|
||||
|
||||
{showConfig && (
|
||||
<div className="mt-3">
|
||||
<CodeBlock language="json" code={configSnippet} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isOwner && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowApiExample(!showApiExample)}
|
||||
className="flex items-center gap-2 text-sm text-primary hover:text-primary/80 transition-colors"
|
||||
>
|
||||
<Icon icon={showApiExample ? 'chevronDown' : 'chevronRight'} className="w-4 h-4" />
|
||||
<span>Show API usage example</span>
|
||||
</button>
|
||||
|
||||
{showApiExample && (
|
||||
<div className="mt-3">
|
||||
<CodeBlock language="typescript" code={apiExampleSnippet} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="mt-3 text-xs text-foreground-tertiary">
|
||||
Use these URLs with{' '}
|
||||
<Link href="/docs/sharing" className="text-primary hover:underline">
|
||||
Claude Desktop, Cursor, or any MCP client
|
||||
</Link>
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
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}
|
||||
/>
|
||||
<ForkButton type="collection" sourceId={collection.id} sourceName={collection.name} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -150,19 +371,8 @@ export function CollectionDetailClient({ collection, username }: CollectionDetai
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* Installation Section */}
|
||||
<InstallationSection
|
||||
collection={{
|
||||
id: collection.id,
|
||||
slug: collection.slug,
|
||||
name: collection.name,
|
||||
toolCount: collection.toolCount,
|
||||
envVars: null, // Public collections don't expose env vars
|
||||
}}
|
||||
username={username}
|
||||
isPrivate={false}
|
||||
showForkButton={true}
|
||||
/>
|
||||
{/* MCP Server URLs - Available to everyone (non-owners must provide their own credentials) */}
|
||||
<McpUrlSection username={username} slug={collection.slug} isOwner={!!isOwner} />
|
||||
|
||||
{/* Tools */}
|
||||
{collection.tools.length > 0 ? (
|
||||
|
|
@ -211,6 +421,16 @@ export function CollectionDetailClient({ collection, username }: CollectionDetai
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Use Cases Section */}
|
||||
{collection.tools.length > 0 && (
|
||||
<UseCasesSection
|
||||
collectionId={collection.id}
|
||||
useCases={collection.useCases ?? null}
|
||||
generatedAt={collection.useCasesGeneratedAt ?? null}
|
||||
onUseCasesGenerated={handleUseCasesGenerated}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Scenarios Section */}
|
||||
{collection.tools.length > 0 && (
|
||||
<ScenariosSection
|
||||
|
|
|
|||
|
|
@ -1,46 +1,370 @@
|
|||
import { prisma } from '@tpmjs/db';
|
||||
import { notFound, redirect } from 'next/navigation';
|
||||
'use client';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
import { Badge } from '@tpmjs/ui/Badge/Badge';
|
||||
import { Button } from '@tpmjs/ui/Button/Button';
|
||||
import { Icon } from '@tpmjs/ui/Icon/Icon';
|
||||
import Link from 'next/link';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { AppHeader } from '~/components/AppHeader';
|
||||
import { LikeButton } from '~/components/LikeButton';
|
||||
|
||||
interface CollectionRedirectPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
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 (
|
||||
<div className="mb-8 p-4 bg-gradient-to-br from-primary/5 via-transparent to-primary/5 border border-primary/20 rounded-xl">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<div className="p-1.5 bg-primary/10 rounded-lg">
|
||||
<Icon icon="link" size="sm" className="text-primary" />
|
||||
</div>
|
||||
<h3 className="font-semibold text-foreground">MCP Server URLs</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{/* HTTP Transport */}
|
||||
<div className="group">
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<span className="text-xs font-medium text-foreground-secondary uppercase tracking-wide">
|
||||
HTTP Transport
|
||||
</span>
|
||||
<span className="text-xs text-foreground-tertiary">(recommended)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 px-3 py-2 bg-surface border border-border rounded-lg font-mono text-sm text-foreground-secondary overflow-x-auto">
|
||||
{httpUrl}
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => copyToClipboard(httpUrl, 'http')}
|
||||
className="shrink-0"
|
||||
>
|
||||
<Icon icon={copiedUrl === 'http' ? 'check' : 'copy'} size="xs" className="mr-1" />
|
||||
{copiedUrl === 'http' ? 'Copied!' : 'Copy'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SSE Transport */}
|
||||
<div className="group">
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<span className="text-xs font-medium text-foreground-secondary uppercase tracking-wide">
|
||||
SSE Transport
|
||||
</span>
|
||||
<span className="text-xs text-foreground-tertiary">(streaming)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 px-3 py-2 bg-surface border border-border rounded-lg font-mono text-sm text-foreground-secondary overflow-x-auto">
|
||||
{sseUrl}
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => copyToClipboard(sseUrl, 'sse')}
|
||||
className="shrink-0"
|
||||
>
|
||||
<Icon icon={copiedUrl === 'sse' ? 'check' : 'copy'} size="xs" className="mr-1" />
|
||||
{copiedUrl === 'sse' ? 'Copied!' : 'Copy'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Config snippet toggle */}
|
||||
<div className="mt-4 pt-4 border-t border-border/50">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowConfig(!showConfig)}
|
||||
className="flex items-center gap-2 text-sm text-primary hover:text-primary/80 transition-colors"
|
||||
>
|
||||
<Icon icon={showConfig ? 'chevronDown' : 'chevronRight'} size="xs" />
|
||||
<span>Show Claude Desktop config</span>
|
||||
</button>
|
||||
|
||||
{showConfig && (
|
||||
<div className="mt-3 relative">
|
||||
<pre className="p-4 bg-surface border border-border rounded-lg text-xs font-mono text-foreground-secondary overflow-x-auto">
|
||||
{configSnippet}
|
||||
</pre>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(configSnippet);
|
||||
setCopiedUrl('http');
|
||||
setTimeout(() => setCopiedUrl(null), 2000);
|
||||
}}
|
||||
className="absolute top-2 right-2"
|
||||
>
|
||||
<Icon icon="copy" size="xs" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="mt-3 text-xs text-foreground-tertiary">
|
||||
Use these URLs with{' '}
|
||||
<Link href="/docs/tutorials/mcp" className="text-primary hover:underline">
|
||||
Claude Desktop, Cursor, or any MCP client
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PublicCollectionDetailPage(): React.ReactElement {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const collectionId = params.id as string;
|
||||
|
||||
const [collection, setCollection] = useState<PublicCollection | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div className="min-h-screen bg-background">
|
||||
<AppHeader />
|
||||
<main className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="animate-pulse">
|
||||
<div className="h-8 bg-surface-secondary rounded w-1/2 mb-4" />
|
||||
<div className="h-4 bg-surface-secondary rounded w-full mb-8" />
|
||||
<div className="h-32 bg-surface-secondary rounded mb-8" />
|
||||
<div className="space-y-4">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="h-24 bg-surface-secondary rounded" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !collection) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<AppHeader />
|
||||
<main className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-16">
|
||||
<div className="text-center">
|
||||
<Icon icon="alertCircle" size="lg" className="mx-auto text-error mb-4" />
|
||||
<h2 className="text-lg font-medium text-foreground mb-2">
|
||||
{error || 'Collection not found'}
|
||||
</h2>
|
||||
<p className="text-foreground-secondary mb-4">
|
||||
This collection may be private or no longer available.
|
||||
</p>
|
||||
<Link href="/collections">
|
||||
<Button>Browse Collections</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<AppHeader />
|
||||
|
||||
<main className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* Back link */}
|
||||
<Link
|
||||
href="/collections"
|
||||
className="inline-flex items-center gap-1 text-sm text-foreground-secondary hover:text-foreground mb-6"
|
||||
>
|
||||
<Icon icon="arrowLeft" size="xs" />
|
||||
Back to Collections
|
||||
</Link>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground mb-2">{collection.name}</h1>
|
||||
{collection.description && (
|
||||
<p className="text-foreground-secondary">{collection.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<LikeButton
|
||||
entityType="collection"
|
||||
entityId={collection.id}
|
||||
initialCount={collection.likeCount}
|
||||
showCount={true}
|
||||
variant="outline"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Meta info */}
|
||||
<div className="flex items-center gap-4 mb-8 text-sm text-foreground-tertiary">
|
||||
<div className="flex items-center gap-2">
|
||||
{collection.createdBy.image ? (
|
||||
<img
|
||||
src={collection.createdBy.image}
|
||||
alt={collection.createdBy.name}
|
||||
className="w-6 h-6 rounded-full"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-6 h-6 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<Icon icon="user" size="xs" className="text-primary" />
|
||||
</div>
|
||||
)}
|
||||
<span>Created by {collection.createdBy.name}</span>
|
||||
</div>
|
||||
<span>•</span>
|
||||
<span>
|
||||
{collection.toolCount} tool{collection.toolCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* MCP URLs */}
|
||||
{collection.createdBy?.username && collection.slug && (
|
||||
<McpUrlSection username={collection.createdBy.username} slug={collection.slug} />
|
||||
)}
|
||||
|
||||
{/* Tools */}
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-foreground mb-4">Tools in this Collection</h2>
|
||||
|
||||
{collection.tools.length === 0 ? (
|
||||
<div className="text-center py-12 bg-surface border border-border rounded-lg">
|
||||
<Icon icon="puzzle" size="lg" className="mx-auto text-foreground-tertiary mb-2" />
|
||||
<p className="text-foreground-secondary">No tools in this collection yet</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{collection.tools.map((ct) => (
|
||||
<div
|
||||
key={ct.id}
|
||||
className="bg-surface border border-border rounded-lg p-4 hover:border-foreground/20 hover:shadow-sm transition-all"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div>
|
||||
<Link
|
||||
href={`/tool/${ct.tool.package.npmPackageName}/${ct.tool.name}`}
|
||||
className="font-medium text-foreground hover:text-primary transition-colors"
|
||||
>
|
||||
{ct.tool.name}
|
||||
</Link>
|
||||
<span className="text-sm text-foreground-tertiary ml-2">
|
||||
from {ct.tool.package.npmPackageName}
|
||||
</span>
|
||||
</div>
|
||||
<LikeButton
|
||||
entityType="tool"
|
||||
entityId={ct.tool.id}
|
||||
initialCount={ct.tool.likeCount}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm text-foreground-secondary line-clamp-2 mb-2">
|
||||
{ct.tool.description}
|
||||
</p>
|
||||
<Badge variant="secondary" size="sm">
|
||||
{ct.tool.package.category}
|
||||
</Badge>
|
||||
{ct.note && (
|
||||
<p className="mt-2 text-xs text-foreground-tertiary italic">Note: {ct.note}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<span className="text-xs font-medium text-foreground-secondary uppercase tracking-wide">
|
||||
{label}
|
||||
</span>
|
||||
<span className="text-xs text-foreground-tertiary">({sublabel})</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 px-3 py-2 bg-surface border border-border rounded-lg font-mono text-sm text-foreground-secondary overflow-x-auto">
|
||||
{url}
|
||||
</div>
|
||||
<Button variant="secondary" size="sm" onClick={copyToClipboard} className="shrink-0">
|
||||
<Icon icon={copied ? 'check' : 'copy'} size="xs" className="mr-1" />
|
||||
{copied ? 'Copied!' : 'Copy'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<ExecutorConfig | null>(null);
|
||||
const [envVars, setEnvVars] = useState<Record<string, string> | 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 {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Installation Tab */}
|
||||
{activeTab === 'installation' && (
|
||||
{/* Connect Tab */}
|
||||
{activeTab === 'connect' && (
|
||||
<div className="space-y-6">
|
||||
{/* Username warning */}
|
||||
{!collection.user.username && (
|
||||
{collection.isPublic && !collection.user.username && (
|
||||
<div className="p-4 bg-warning/10 border border-warning/30 rounded-lg">
|
||||
<div className="flex items-start gap-3">
|
||||
<Icon icon="alertCircle" size="sm" className="text-warning mt-0.5" />
|
||||
|
|
@ -509,20 +558,63 @@ export default function CollectionDetailPage(): React.ReactElement {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Installation Section */}
|
||||
{collection.user.username && (
|
||||
<InstallationSection
|
||||
collection={{
|
||||
id: collection.id,
|
||||
slug: collection.slug,
|
||||
name: collection.name,
|
||||
toolCount: collection.toolCount,
|
||||
envVars: envVars,
|
||||
}}
|
||||
username={collection.user.username}
|
||||
isPrivate={!collection.isPublic}
|
||||
showForkButton={false}
|
||||
/>
|
||||
{/* MCP URLs */}
|
||||
{collection.isPublic && collection.user.username && (
|
||||
<div className="bg-surface border border-border rounded-lg p-6">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<div className="p-1.5 bg-primary/10 rounded-lg">
|
||||
<Icon icon="link" size="sm" className="text-primary" />
|
||||
</div>
|
||||
<h3 className="font-semibold text-foreground">MCP Server URLs</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<McpUrlDisplay url={httpUrl} label="HTTP Transport" sublabel="recommended" />
|
||||
<McpUrlDisplay url={sseUrl} label="SSE Transport" sublabel="streaming" />
|
||||
</div>
|
||||
|
||||
<div className="mt-6 pt-4 border-t border-border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowClaudeConfig(!showClaudeConfig)}
|
||||
className="flex items-center gap-2 text-sm text-primary hover:text-primary/80 transition-colors"
|
||||
>
|
||||
<Icon icon={showClaudeConfig ? 'chevronDown' : 'chevronRight'} size="xs" />
|
||||
<span>Show Claude Desktop config</span>
|
||||
</button>
|
||||
|
||||
{showClaudeConfig && (
|
||||
<div className="mt-3 relative">
|
||||
<pre className="p-4 bg-surface-secondary border border-border rounded-lg text-xs font-mono text-foreground-secondary overflow-x-auto">
|
||||
{configSnippet}
|
||||
</pre>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => navigator.clipboard.writeText(configSnippet)}
|
||||
className="absolute top-2 right-2"
|
||||
>
|
||||
<Icon icon="copy" size="xs" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="mt-4 text-xs text-foreground-tertiary">
|
||||
Use these URLs with{' '}
|
||||
<Link href="/docs/tutorials/mcp" className="text-primary hover:underline">
|
||||
Claude Desktop, Cursor, or any MCP client
|
||||
</Link>
|
||||
. Requires your{' '}
|
||||
<Link
|
||||
href="/dashboard/settings/tpmjs-api-keys"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
TPMJS API key
|
||||
</Link>{' '}
|
||||
for authentication.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -213,6 +213,11 @@ export function AppHeader(): React.ReactElement {
|
|||
Tools
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/collections">
|
||||
<Button variant="ghost" size="sm" className="text-foreground hover:text-foreground">
|
||||
Collections
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/agents">
|
||||
<Button variant="ghost" size="sm" className="text-foreground hover:text-foreground">
|
||||
Agents
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue