feat: add resend tools, MCP collection endpoint, and UI refinements
- Add @tpmjs/tools-resend with email API tools and blocks.yml entries - Add MCP route for collection skill discovery - Add InstallationSection component for collections - Refactor collection pages to use shared components and simplify layouts - Update skills questions API, rate limiting, and API key handling - Add tpmjs-tool-creator skill for Claude - Update video feature scenes and fix lint issues - Update .gitignore with IDE and temp file exclusions
This commit is contained in:
parent
f3a46045ba
commit
a52d32c367
40 changed files with 3310 additions and 1694 deletions
|
|
@ -12,9 +12,7 @@ const nextConfig: NextConfig = {
|
|||
'@tpmjs/registry-execute',
|
||||
],
|
||||
reactStrictMode: true,
|
||||
serverExternalPackages: [
|
||||
'@tpmjs/package-executor',
|
||||
],
|
||||
serverExternalPackages: ['@tpmjs/package-executor'],
|
||||
async redirects() {
|
||||
return [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,19 +1,15 @@
|
|||
'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 { useState } from 'react';
|
||||
import { AppHeader } from '~/components/AppHeader';
|
||||
import { ForkButton } from '~/components/ForkButton';
|
||||
import { InstallationSection } from '~/components/collections/InstallationSection';
|
||||
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';
|
||||
import { useSession } from '~/lib/auth-client';
|
||||
|
||||
export interface CollectionTool {
|
||||
id: string;
|
||||
|
|
@ -32,6 +28,31 @@ export interface CollectionTool {
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Locked state for private collections viewed by non-owners
|
||||
* Shows minimal information: just name and "Private" badge
|
||||
*/
|
||||
export function PrivateCollectionLocked({ name }: { name: string }) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<AppHeader />
|
||||
|
||||
<main className="max-w-5xl mx-auto px-4 py-8">
|
||||
<div className="flex flex-col items-center justify-center py-24 text-center">
|
||||
<div className="w-16 h-16 rounded-full bg-foreground-tertiary/10 flex items-center justify-center mb-6">
|
||||
<Icon icon="key" className="w-8 h-8 text-foreground-tertiary" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<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>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface PublicCollection {
|
||||
id: string;
|
||||
slug: string; // Already coerced to empty string if null in server component
|
||||
|
|
@ -59,193 +80,12 @@ export interface PublicCollection {
|
|||
} | 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 {
|
||||
collection: PublicCollection;
|
||||
username: string;
|
||||
}
|
||||
|
||||
export function CollectionDetailClient({ collection, username }: CollectionDetailClientProps) {
|
||||
const { data: session } = useSession();
|
||||
|
||||
// Check if current user is the owner
|
||||
const isOwner = session?.user?.id && collection.createdBy?.id === session.user.id;
|
||||
|
||||
// Generate tweet text
|
||||
const tweetText = collection.description
|
||||
? `${collection.name} - ${collection.description.slice(0, 100)}${collection.description.length > 100 ? '...' : ''}`
|
||||
|
|
@ -289,7 +129,6 @@ export function CollectionDetailClient({ collection, username }: CollectionDetai
|
|||
entityId={collection.id}
|
||||
initialCount={collection.likeCount}
|
||||
/>
|
||||
<ForkButton type="collection" sourceId={collection.id} sourceName={collection.name} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -311,8 +150,19 @@ export function CollectionDetailClient({ collection, username }: CollectionDetai
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* MCP Server URLs - Available to everyone (non-owners must provide their own credentials) */}
|
||||
<McpUrlSection username={username} slug={collection.slug} isOwner={!!isOwner} />
|
||||
{/* 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}
|
||||
/>
|
||||
|
||||
{/* Tools */}
|
||||
{collection.tools.length > 0 ? (
|
||||
|
|
|
|||
|
|
@ -0,0 +1,359 @@
|
|||
import { prisma } from '@tpmjs/db';
|
||||
import { type NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { API_KEY_SCOPES } from '~/lib/api-keys';
|
||||
import { authenticateRequest, getClientMetadata, hasScope } from '~/lib/api-keys/middleware';
|
||||
import {
|
||||
checkApiKeyRateLimit,
|
||||
createRateLimitResponse,
|
||||
getRateLimitHeaders,
|
||||
} from '~/lib/api-keys/rate-limit';
|
||||
import { trackUsage } from '~/lib/api-keys/usage';
|
||||
import { handleInitialize, handleToolsCall, handleToolsList } from '~/lib/mcp/handlers';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const maxDuration = 60;
|
||||
|
||||
const DB_TIMEOUT_MS = 10000; // 10 second timeout for database queries
|
||||
|
||||
interface RouteContext {
|
||||
params: Promise<{ username: string; slug: string }>;
|
||||
}
|
||||
|
||||
interface JsonRpcRequest {
|
||||
jsonrpc: string;
|
||||
method: string;
|
||||
params?: unknown;
|
||||
id?: string | number;
|
||||
}
|
||||
|
||||
interface JsonRpcResponse {
|
||||
jsonrpc: '2.0';
|
||||
id: string | number | null;
|
||||
result?: unknown;
|
||||
error?: { code: number; message: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a promise with a timeout
|
||||
*/
|
||||
function withTimeout<T>(promise: Promise<T>, ms: number, errorMessage: string): Promise<T> {
|
||||
return Promise.race([
|
||||
promise,
|
||||
new Promise<T>((_, reject) => setTimeout(() => reject(new Error(errorMessage)), ms)),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a user by username (strips @ prefix if present)
|
||||
*/
|
||||
async function getUserByUsername(username: string) {
|
||||
// Strip @ prefix if present (from pretty URLs like /@username)
|
||||
const cleanUsername = username.startsWith('@') ? username.slice(1) : username;
|
||||
|
||||
return withTimeout(
|
||||
prisma.user.findUnique({
|
||||
where: { username: cleanUsername },
|
||||
select: { id: true, username: true },
|
||||
}),
|
||||
DB_TIMEOUT_MS,
|
||||
`Database query timed out after ${DB_TIMEOUT_MS}ms`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a collection by user ID and slug
|
||||
*/
|
||||
async function getCollectionByUserIdAndSlug(userId: string, slug: string) {
|
||||
return withTimeout(
|
||||
prisma.collection.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
slug,
|
||||
},
|
||||
select: { id: true, name: true, description: true, userId: true, isPublic: true },
|
||||
}),
|
||||
DB_TIMEOUT_MS,
|
||||
`Database query timed out after ${DB_TIMEOUT_MS}ms`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a JSON-RPC request and return the response
|
||||
*/
|
||||
async function processJsonRpcRequest(
|
||||
collectionId: string,
|
||||
collectionName: string,
|
||||
body: JsonRpcRequest,
|
||||
isOwner: boolean
|
||||
): Promise<JsonRpcResponse> {
|
||||
const requestId = body.id ?? null;
|
||||
|
||||
switch (body.method) {
|
||||
case 'initialize':
|
||||
return handleInitialize(collectionName, requestId);
|
||||
|
||||
case 'tools/list':
|
||||
return await handleToolsList(collectionId, requestId);
|
||||
|
||||
case 'tools/call': {
|
||||
const params = body.params as {
|
||||
name: string;
|
||||
arguments?: Record<string, unknown>;
|
||||
env?: Record<string, string>;
|
||||
};
|
||||
|
||||
// For non-owners, use caller-provided env vars (or empty if not provided)
|
||||
// For owners, callerEnvVars is undefined so handleToolsCall uses stored env vars
|
||||
const callerEnvVars = isOwner ? undefined : params.env || {};
|
||||
|
||||
return await handleToolsCall(collectionId, params, requestId, callerEnvVars);
|
||||
}
|
||||
|
||||
case 'notifications/initialized':
|
||||
case 'ping':
|
||||
return { jsonrpc: '2.0', id: requestId, result: {} };
|
||||
|
||||
default:
|
||||
return {
|
||||
jsonrpc: '2.0',
|
||||
id: requestId,
|
||||
error: { code: -32601, message: `Method not found: ${body.method}` },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /@username/collections/[slug]/mcp
|
||||
* MCP JSON-RPC endpoint (HTTP transport only)
|
||||
*
|
||||
* Authentication:
|
||||
* - Public collections: No auth required
|
||||
* - Private collections: Requires Authorization: Bearer header with valid API key
|
||||
*/
|
||||
export async function POST(request: NextRequest, context: RouteContext): Promise<Response> {
|
||||
const startTime = Date.now();
|
||||
let authResult: Awaited<ReturnType<typeof authenticateRequest>> | null = null;
|
||||
|
||||
try {
|
||||
const { username, slug } = await context.params;
|
||||
|
||||
// First, find the user by username
|
||||
const user = await getUserByUsername(username);
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
jsonrpc: '2.0',
|
||||
error: {
|
||||
code: -32001,
|
||||
message: `User '${username}' not found. Check the username in your MCP endpoint URL.`,
|
||||
},
|
||||
id: null,
|
||||
},
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Then find the collection by user ID and slug
|
||||
const collection = await getCollectionByUserIdAndSlug(user.id, slug);
|
||||
|
||||
if (!collection) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
jsonrpc: '2.0',
|
||||
error: {
|
||||
code: -32001,
|
||||
message: `Collection '${slug}' not found for user '${user.username}'.`,
|
||||
},
|
||||
id: null,
|
||||
},
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Authenticate the request
|
||||
authResult = await authenticateRequest();
|
||||
|
||||
// Determine if the authenticated user is the owner
|
||||
const isOwner = authResult.authenticated && authResult.userId === collection.userId;
|
||||
|
||||
// Authorization check:
|
||||
// - Owners can always access their own collections (public or private)
|
||||
// - Non-owners can access PUBLIC collections without auth
|
||||
// - Private collections require auth as the owner
|
||||
if (!isOwner && !collection.isPublic) {
|
||||
// Private collection, not the owner - require authentication
|
||||
if (!authResult.authenticated) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
jsonrpc: '2.0',
|
||||
error: {
|
||||
code: -32000,
|
||||
message: 'Authentication required. Add header: Authorization: Bearer YOUR_API_KEY',
|
||||
},
|
||||
id: null,
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
// Authenticated but not the owner of a private collection - don't reveal existence
|
||||
return NextResponse.json(
|
||||
{ jsonrpc: '2.0', error: { code: -32001, message: 'Collection not found' }, id: null },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check scope if authenticated
|
||||
if (authResult.authenticated && !hasScope(authResult, API_KEY_SCOPES.MCP_EXECUTE)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
jsonrpc: '2.0',
|
||||
error: { code: -32000, message: 'Missing required scope: mcp:execute' },
|
||||
id: null,
|
||||
},
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
// Rate limit if authenticated via API key
|
||||
if (authResult.authenticated && authResult.apiKeyId) {
|
||||
const rateLimitResult = await checkApiKeyRateLimit(
|
||||
authResult.apiKeyId,
|
||||
authResult.tier || 'FREE'
|
||||
);
|
||||
|
||||
if (!rateLimitResult.allowed) {
|
||||
return createRateLimitResponse(rateLimitResult);
|
||||
}
|
||||
}
|
||||
|
||||
// Parse JSON-RPC request body
|
||||
let body: JsonRpcRequest;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ jsonrpc: '2.0', error: { code: -32700, message: 'Parse error' }, id: null },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Process the request
|
||||
const response = await processJsonRpcRequest(collection.id, collection.name, body, isOwner);
|
||||
const jsonResponse = NextResponse.json(response);
|
||||
|
||||
// Track usage for authenticated requests
|
||||
if (authResult.authenticated && authResult.userId) {
|
||||
const clientMeta = await getClientMetadata();
|
||||
trackUsage({
|
||||
apiKeyId: authResult.apiKeyId,
|
||||
userId: authResult.userId,
|
||||
endpoint: `/@${user.username}/collections/${slug}/mcp`,
|
||||
method: 'POST',
|
||||
statusCode: jsonResponse.status,
|
||||
latencyMs: Date.now() - startTime,
|
||||
resourceType: 'mcp',
|
||||
resourceId: collection.id,
|
||||
userAgent: clientMeta.userAgent,
|
||||
ipAddress: clientMeta.ipAddress,
|
||||
});
|
||||
}
|
||||
|
||||
// Add rate limit headers for authenticated requests
|
||||
if (authResult.authenticated && authResult.apiKeyId) {
|
||||
const rateLimitResult = await checkApiKeyRateLimit(
|
||||
authResult.apiKeyId,
|
||||
authResult.tier || 'FREE'
|
||||
);
|
||||
const headers = getRateLimitHeaders(rateLimitResult);
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
jsonResponse.headers.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
return jsonResponse;
|
||||
} catch (error) {
|
||||
console.error('[MCP POST] Error:', error);
|
||||
const message = error instanceof Error ? error.message : 'Internal server error';
|
||||
|
||||
// Track error for authenticated requests
|
||||
if (authResult?.authenticated && authResult.userId) {
|
||||
const { username, slug } = await context.params;
|
||||
const clientMeta = await getClientMetadata();
|
||||
trackUsage({
|
||||
apiKeyId: authResult.apiKeyId,
|
||||
userId: authResult.userId,
|
||||
endpoint: `/@${username}/collections/${slug}/mcp`,
|
||||
method: 'POST',
|
||||
statusCode: 500,
|
||||
latencyMs: Date.now() - startTime,
|
||||
resourceType: 'mcp',
|
||||
errorCode: 'INTERNAL_ERROR',
|
||||
errorMessage: message,
|
||||
userAgent: clientMeta.userAgent,
|
||||
ipAddress: clientMeta.ipAddress,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ jsonrpc: '2.0', error: { code: -32603, message }, id: null },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /@username/collections/[slug]/mcp
|
||||
* Returns server info for the MCP endpoint
|
||||
*/
|
||||
export async function GET(_request: NextRequest, context: RouteContext): Promise<Response> {
|
||||
try {
|
||||
const { username, slug } = await context.params;
|
||||
|
||||
// First, find the user by username
|
||||
const user = await getUserByUsername(username);
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json(
|
||||
{ error: `User '${username}' not found. Check the username in your MCP endpoint URL.` },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Then find the collection
|
||||
const collection = await getCollectionByUserIdAndSlug(user.id, slug);
|
||||
|
||||
if (!collection) {
|
||||
return NextResponse.json(
|
||||
{ error: `Collection '${slug}' not found for user '${user.username}'.` },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// For GET requests, check if user can access this collection:
|
||||
// - Public collections are accessible to anyone
|
||||
// - Private collections are only accessible to the owner (when authenticated)
|
||||
if (!collection.isPublic) {
|
||||
const authResult = await authenticateRequest();
|
||||
if (!authResult.authenticated || authResult.userId !== collection.userId) {
|
||||
// Don't reveal existence of private collections
|
||||
return NextResponse.json({ error: 'Collection not found' }, { status: 404 });
|
||||
}
|
||||
}
|
||||
|
||||
// Return server info
|
||||
return NextResponse.json({
|
||||
name: `TPMJS: ${collection.name}`,
|
||||
description: collection.description,
|
||||
protocol: 'mcp',
|
||||
transport: 'http',
|
||||
endpoint: `/@${user.username}/collections/${slug}/mcp`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[MCP GET] Error:', error);
|
||||
const message = error instanceof Error ? error.message : 'Internal server error';
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,11 @@
|
|||
import { prisma } from '@tpmjs/db';
|
||||
import type { Metadata } from 'next';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { CollectionDetailClient, type PublicCollection } from './CollectionDetailClient';
|
||||
import {
|
||||
CollectionDetailClient,
|
||||
PrivateCollectionLocked,
|
||||
type PublicCollection,
|
||||
} from './CollectionDetailClient';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
|
|
@ -9,18 +13,25 @@ interface CollectionPageProps {
|
|||
params: Promise<{ username: string; slug: string }>;
|
||||
}
|
||||
|
||||
interface CollectionResult {
|
||||
collection: PublicCollection | null;
|
||||
isPrivate: boolean;
|
||||
privateName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch collection data from database
|
||||
* Returns both public collections fully, and private collections with minimal info (locked state)
|
||||
*/
|
||||
async function getCollection(username: string, slug: string): Promise<PublicCollection | null> {
|
||||
async function getCollection(username: string, slug: string): Promise<CollectionResult> {
|
||||
// Remove @ prefix if present
|
||||
const cleanUsername = username.startsWith('@') ? username.slice(1) : username;
|
||||
|
||||
// First, check if the collection exists at all (public or private)
|
||||
const collection = await prisma.collection.findFirst({
|
||||
where: {
|
||||
slug,
|
||||
user: { username: cleanUsername },
|
||||
isPublic: true,
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
|
|
@ -57,51 +68,64 @@ async function getCollection(username: string, slug: string): Promise<PublicColl
|
|||
});
|
||||
|
||||
if (!collection) {
|
||||
return null;
|
||||
return { collection: null, isPrivate: false };
|
||||
}
|
||||
|
||||
// If private, return minimal info for locked state
|
||||
if (!collection.isPublic) {
|
||||
return {
|
||||
collection: null,
|
||||
isPrivate: true,
|
||||
privateName: collection.name,
|
||||
};
|
||||
}
|
||||
|
||||
// Public collection - return full data
|
||||
return {
|
||||
id: collection.id,
|
||||
slug: collection.slug || '',
|
||||
name: collection.name,
|
||||
description: collection.description,
|
||||
likeCount: collection.likeCount,
|
||||
toolCount: collection.tools.length,
|
||||
forkCount: collection.forkCount,
|
||||
createdAt: collection.createdAt.toISOString(),
|
||||
createdBy: {
|
||||
id: collection.user.id,
|
||||
username: collection.user.username || '',
|
||||
name: collection.user.name || '',
|
||||
image: collection.user.image,
|
||||
},
|
||||
tools: collection.tools.map((ct) => ({
|
||||
id: ct.id,
|
||||
toolId: ct.toolId,
|
||||
position: ct.position,
|
||||
note: ct.note,
|
||||
tool: {
|
||||
id: ct.tool.id,
|
||||
name: ct.tool.name,
|
||||
description: ct.tool.description,
|
||||
likeCount: ct.tool.likeCount,
|
||||
package: {
|
||||
npmPackageName: ct.tool.package.npmPackageName,
|
||||
category: ct.tool.package.category,
|
||||
},
|
||||
collection: {
|
||||
id: collection.id,
|
||||
slug: collection.slug || '',
|
||||
name: collection.name,
|
||||
description: collection.description,
|
||||
likeCount: collection.likeCount,
|
||||
toolCount: collection.tools.length,
|
||||
forkCount: collection.forkCount,
|
||||
createdAt: collection.createdAt.toISOString(),
|
||||
createdBy: {
|
||||
id: collection.user.id,
|
||||
username: collection.user.username || '',
|
||||
name: collection.user.name || '',
|
||||
image: collection.user.image,
|
||||
},
|
||||
})),
|
||||
forkedFromId: collection.forkedFromId,
|
||||
forkedFrom: collection.forkedFrom
|
||||
? {
|
||||
id: collection.forkedFrom.id,
|
||||
name: collection.forkedFrom.name,
|
||||
slug: collection.forkedFrom.slug || '',
|
||||
user: {
|
||||
username: collection.forkedFrom.user.username || '',
|
||||
tools: collection.tools.map((ct) => ({
|
||||
id: ct.id,
|
||||
toolId: ct.toolId,
|
||||
position: ct.position,
|
||||
note: ct.note,
|
||||
tool: {
|
||||
id: ct.tool.id,
|
||||
name: ct.tool.name,
|
||||
description: ct.tool.description,
|
||||
likeCount: ct.tool.likeCount,
|
||||
package: {
|
||||
npmPackageName: ct.tool.package.npmPackageName,
|
||||
category: ct.tool.package.category,
|
||||
},
|
||||
}
|
||||
: null,
|
||||
},
|
||||
})),
|
||||
forkedFromId: collection.forkedFromId,
|
||||
forkedFrom: collection.forkedFrom
|
||||
? {
|
||||
id: collection.forkedFrom.id,
|
||||
name: collection.forkedFrom.name,
|
||||
slug: collection.forkedFrom.slug || '',
|
||||
user: {
|
||||
username: collection.forkedFrom.user.username || '',
|
||||
},
|
||||
}
|
||||
: null,
|
||||
},
|
||||
isPrivate: false,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -111,15 +135,25 @@ async function getCollection(username: string, slug: string): Promise<PublicColl
|
|||
export async function generateMetadata({ params }: CollectionPageProps): Promise<Metadata> {
|
||||
const { username, slug } = await params;
|
||||
const cleanUsername = username.startsWith('@') ? username.slice(1) : username;
|
||||
const collection = await getCollection(username, slug);
|
||||
const result = await getCollection(username, slug);
|
||||
|
||||
if (!collection) {
|
||||
// Private collection - minimal metadata
|
||||
if (result.isPrivate) {
|
||||
return {
|
||||
title: `${result.privateName} (Private) | TPMJS`,
|
||||
description: 'This collection is private.',
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
}
|
||||
|
||||
if (!result.collection) {
|
||||
return {
|
||||
title: 'Collection Not Found | TPMJS',
|
||||
description: 'The requested collection could not be found.',
|
||||
};
|
||||
}
|
||||
|
||||
const collection = result.collection;
|
||||
const title = `${collection.name} | TPMJS`;
|
||||
const description =
|
||||
collection.description ||
|
||||
|
|
@ -174,11 +208,17 @@ export async function generateMetadata({ params }: CollectionPageProps): Promise
|
|||
export default async function CollectionDetailPage({ params }: CollectionPageProps) {
|
||||
const { username, slug } = await params;
|
||||
const cleanUsername = username.startsWith('@') ? username.slice(1) : username;
|
||||
const collection = await getCollection(username, slug);
|
||||
const result = await getCollection(username, slug);
|
||||
|
||||
if (!collection) {
|
||||
// Private collection - show locked state
|
||||
if (result.isPrivate && result.privateName) {
|
||||
return <PrivateCollectionLocked name={result.privateName} />;
|
||||
}
|
||||
|
||||
// Collection not found
|
||||
if (!result.collection) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return <CollectionDetailClient collection={collection} username={cleanUsername} />;
|
||||
return <CollectionDetailClient collection={result.collection} username={cleanUsername} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -131,7 +131,11 @@ export function QuestionsListClient({
|
|||
|
||||
const clearSkillFilter = () => {
|
||||
setSkillFilter(undefined);
|
||||
window.history.replaceState(null, '', `/${collection.username}/collections/${collection.slug}/skills/questions`);
|
||||
window.history.replaceState(
|
||||
null,
|
||||
'',
|
||||
`/${collection.username}/collections/${collection.slug}/skills/questions`
|
||||
);
|
||||
};
|
||||
|
||||
const basePath = `/${collection.username}/collections/${collection.slug}`;
|
||||
|
|
@ -224,7 +228,7 @@ export function QuestionsListClient({
|
|||
description={
|
||||
skillFilter
|
||||
? `No questions found for skill "${skillFilter}"`
|
||||
: 'Be the first to ask a question about this collection\'s tools.'
|
||||
: "Be the first to ask a question about this collection's tools."
|
||||
}
|
||||
size="md"
|
||||
/>
|
||||
|
|
@ -243,11 +247,7 @@ export function QuestionsListClient({
|
|||
{!loading && !error && questions.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
{questions.map((q) => (
|
||||
<Link
|
||||
key={q.id}
|
||||
href={`${basePath}/skills/questions/${q.id}`}
|
||||
className="block"
|
||||
>
|
||||
<Link key={q.id} href={`${basePath}/skills/questions/${q.id}`} className="block">
|
||||
<Card
|
||||
variant="default"
|
||||
className="hover:border-primary/30 hover:bg-muted/30 transition-all cursor-pointer"
|
||||
|
|
@ -311,11 +311,7 @@ export function QuestionsListClient({
|
|||
{/* Load More */}
|
||||
{hasMore && (
|
||||
<div className="text-center pt-4">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleLoadMore}
|
||||
disabled={loadingMore}
|
||||
>
|
||||
<Button variant="secondary" onClick={handleLoadMore} disabled={loadingMore}>
|
||||
{loadingMore ? (
|
||||
<>
|
||||
<Icon icon="loader" className="w-4 h-4 mr-2 animate-spin" />
|
||||
|
|
|
|||
|
|
@ -90,9 +90,10 @@ export function QuestionDetailClient({
|
|||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const basePath = `/${collection.username}/collections/${collection.slug}`;
|
||||
const questionUrl = typeof window !== 'undefined'
|
||||
? window.location.href
|
||||
: `https://tpmjs.com${basePath}/skills/questions/${question.id}`;
|
||||
const questionUrl =
|
||||
typeof window !== 'undefined'
|
||||
? window.location.href
|
||||
: `https://tpmjs.com${basePath}/skills/questions/${question.id}`;
|
||||
|
||||
const copyLink = async () => {
|
||||
await navigator.clipboard.writeText(questionUrl);
|
||||
|
|
@ -140,10 +141,7 @@ export function QuestionDetailClient({
|
|||
<Icon icon="clock" className="w-4 h-4" />
|
||||
{formatDate(question.createdAt)}
|
||||
</span>
|
||||
<Badge
|
||||
variant={question.confidence >= 0.7 ? 'success' : 'secondary'}
|
||||
size="md"
|
||||
>
|
||||
<Badge variant={question.confidence >= 0.7 ? 'success' : 'secondary'} size="md">
|
||||
{Math.round(question.confidence * 100)}% confidence
|
||||
</Badge>
|
||||
{question.similarCount > 0 && (
|
||||
|
|
@ -233,7 +231,9 @@ export function QuestionDetailClient({
|
|||
className="block p-2 bg-muted/50 border border-border rounded hover:border-primary/30 transition-colors"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium text-sm text-foreground">{sn.skill.name}</span>
|
||||
<span className="font-medium text-sm text-foreground">
|
||||
{sn.skill.name}
|
||||
</span>
|
||||
<Badge variant="outline" size="sm">
|
||||
{sn.skill.questionCount} Q
|
||||
</Badge>
|
||||
|
|
@ -288,10 +288,7 @@ export function QuestionDetailClient({
|
|||
>
|
||||
<p className="text-sm text-foreground line-clamp-2">{sq.question}</p>
|
||||
<div className="flex items-center justify-between mt-1.5">
|
||||
<Badge
|
||||
variant={sq.confidence >= 0.7 ? 'success' : 'secondary'}
|
||||
size="sm"
|
||||
>
|
||||
<Badge variant={sq.confidence >= 0.7 ? 'success' : 'secondary'} size="sm">
|
||||
{Math.round(sq.confidence * 100)}%
|
||||
</Badge>
|
||||
<span className="text-xs text-foreground-tertiary">
|
||||
|
|
|
|||
|
|
@ -110,9 +110,7 @@ export async function generateMetadata({ params }: QuestionPageProps): Promise<M
|
|||
}
|
||||
|
||||
const truncatedQuestion =
|
||||
question.question.length > 60
|
||||
? question.question.slice(0, 60) + '...'
|
||||
: question.question;
|
||||
question.question.length > 60 ? `${question.question.slice(0, 60)}...` : question.question;
|
||||
|
||||
return {
|
||||
title: `${truncatedQuestion} | TPMJS Skills`,
|
||||
|
|
|
|||
|
|
@ -87,32 +87,36 @@ export async function GET(_request: NextRequest, context: RouteContext) {
|
|||
|
||||
// Check if collection is public
|
||||
if (!question.collection.isPublic) {
|
||||
return NextResponse.json({ error: 'Question belongs to a private collection' }, { status: 403 });
|
||||
return NextResponse.json(
|
||||
{ error: 'Question belongs to a private collection' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
// Fetch similar questions (based on same skills)
|
||||
const skillIds = question.skillNodes.map((sn) => sn.skill.id);
|
||||
const similarQuestions = skillIds.length > 0
|
||||
? await prisma.skillQuestion.findMany({
|
||||
where: {
|
||||
id: { not: id },
|
||||
collectionId: question.collection.id,
|
||||
skillNodes: {
|
||||
some: {
|
||||
skillId: { in: skillIds },
|
||||
const similarQuestions =
|
||||
skillIds.length > 0
|
||||
? await prisma.skillQuestion.findMany({
|
||||
where: {
|
||||
id: { not: id },
|
||||
collectionId: question.collection.id,
|
||||
skillNodes: {
|
||||
some: {
|
||||
skillId: { in: skillIds },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
take: 5,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: {
|
||||
id: true,
|
||||
question: true,
|
||||
confidence: true,
|
||||
createdAt: true,
|
||||
},
|
||||
})
|
||||
: [];
|
||||
take: 5,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: {
|
||||
id: true,
|
||||
question: true,
|
||||
confidence: true,
|
||||
createdAt: true,
|
||||
},
|
||||
})
|
||||
: [];
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
|
|
|
|||
|
|
@ -1,370 +1,46 @@
|
|||
'use client';
|
||||
import { prisma } from '@tpmjs/db';
|
||||
import { notFound, redirect } from 'next/navigation';
|
||||
|
||||
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';
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
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;
|
||||
};
|
||||
};
|
||||
interface CollectionRedirectPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
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[];
|
||||
}
|
||||
/**
|
||||
* 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;
|
||||
|
||||
function McpUrlSection({ username, slug }: { username: string; slug: string }) {
|
||||
const [copiedUrl, setCopiedUrl] = useState<'http' | 'sse' | null>(null);
|
||||
const [showConfig, setShowConfig] = useState(false);
|
||||
// Look up the collection by ID
|
||||
const collection = await prisma.collection.findUnique({
|
||||
where: { id },
|
||||
select: {
|
||||
slug: true,
|
||||
isPublic: true,
|
||||
user: {
|
||||
select: { username: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
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 collection doesn't exist, return 404
|
||||
if (!collection) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
// If collection is private, return 404 (don't reveal existence)
|
||||
if (!collection.isPublic) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<AppHeader />
|
||||
// If user has no username or collection has no slug, can't redirect to pretty URL
|
||||
if (!collection.user.username || !collection.slug) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
<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>
|
||||
);
|
||||
// 301 permanent redirect to the canonical URL
|
||||
redirect(`/@${collection.user.username}/collections/${collection.slug}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,294 +1,10 @@
|
|||
'use client';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { Badge } from '@tpmjs/ui/Badge/Badge';
|
||||
import { EmptyState } from '@tpmjs/ui/EmptyState/EmptyState';
|
||||
import { ErrorState } from '@tpmjs/ui/ErrorState/ErrorState';
|
||||
import { Icon } from '@tpmjs/ui/Icon/Icon';
|
||||
import { Input } from '@tpmjs/ui/Input/Input';
|
||||
import { LoadingState } from '@tpmjs/ui/LoadingState/LoadingState';
|
||||
import { PageHeader } from '@tpmjs/ui/PageHeader/PageHeader';
|
||||
import { Select } from '@tpmjs/ui/Select/Select';
|
||||
import Link from 'next/link';
|
||||
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 {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
likeCount: number;
|
||||
toolCount: number;
|
||||
createdAt: string;
|
||||
createdBy: {
|
||||
id: string;
|
||||
name: string;
|
||||
image: string | null;
|
||||
username: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
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<PublicCollection[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const [sort, setSort] = useState<SortOption>('likes');
|
||||
const loadingMore = useRef(false);
|
||||
|
||||
const fetchCollections = useCallback(
|
||||
async (offset: number, resetList = false) => {
|
||||
try {
|
||||
if (loadingMore.current && !resetList) return;
|
||||
loadingMore.current = true;
|
||||
|
||||
const params = new URLSearchParams({
|
||||
limit: '100',
|
||||
offset: String(offset),
|
||||
sort,
|
||||
});
|
||||
|
||||
const response = await fetch(`/api/public/collections?${params}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
if (resetList || offset === 0) {
|
||||
setCollections(data.data);
|
||||
} else {
|
||||
setCollections((prev) => [...prev, ...data.data]);
|
||||
}
|
||||
setHasMore(data.pagination.hasMore);
|
||||
} else {
|
||||
setError(data.error?.message || 'Failed to fetch collections');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch collections:', err);
|
||||
setError('Failed to fetch collections');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
loadingMore.current = false;
|
||||
}
|
||||
},
|
||||
[sort]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoading(true);
|
||||
fetchCollections(0, true);
|
||||
}, [fetchCollections]);
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
if (!hasMore || loadingMore.current) return;
|
||||
fetchCollections(collections.length);
|
||||
}, [hasMore, collections.length, fetchCollections]);
|
||||
|
||||
// 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(
|
||||
() => (
|
||||
<tr className="bg-surface-secondary text-left text-xs font-semibold uppercase tracking-wider text-foreground-secondary border-b border-border">
|
||||
<th className="px-4 py-3 w-[250px]">Name</th>
|
||||
<th className="px-4 py-3 w-[300px]">Description</th>
|
||||
<th className="px-4 py-3 w-[80px] text-center">Tools</th>
|
||||
<th className="px-4 py-3 w-[80px] text-center">Likes</th>
|
||||
<th className="px-4 py-3 w-[150px]">Creator</th>
|
||||
<th className="px-4 py-3 w-[100px] text-right">Copy</th>
|
||||
</tr>
|
||||
),
|
||||
[]
|
||||
);
|
||||
|
||||
const TableRow = useCallback((_index: number, collection: PublicCollection) => {
|
||||
return (
|
||||
<>
|
||||
<td className="px-4 py-3">
|
||||
<Link
|
||||
href={collection.createdBy.username ? `/${collection.createdBy.username}/collections/${collection.slug}` : `/collections/${collection.id}`}
|
||||
className="font-semibold text-foreground hover:text-primary group-hover:text-primary transition-colors"
|
||||
>
|
||||
{collection.name}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-foreground-secondary">
|
||||
{collection.description ? truncateText(collection.description, 60) : '—'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<Badge variant="secondary" size="sm">
|
||||
{collection.toolCount}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<LikeButton
|
||||
entityType="collection"
|
||||
entityId={collection.id}
|
||||
initialCount={collection.likeCount}
|
||||
size="sm"
|
||||
showCount={true}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{collection.createdBy.image ? (
|
||||
<img
|
||||
src={collection.createdBy.image}
|
||||
alt={collection.createdBy.name}
|
||||
className="w-5 h-5 rounded-full"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-5 h-5 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<Icon icon="user" size="xs" className="text-primary" />
|
||||
</div>
|
||||
)}
|
||||
<span className="text-sm text-foreground-secondary truncate max-w-[100px]">
|
||||
{collection.createdBy.name}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
{collection.createdBy.username && (
|
||||
<CopyDropdown
|
||||
options={getCollectionCopyOptions(
|
||||
collection.createdBy.username,
|
||||
collection.slug,
|
||||
collection.name
|
||||
)}
|
||||
buttonLabel="Copy"
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
</>
|
||||
);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<AppHeader />
|
||||
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<PageHeader
|
||||
title="Public Collections"
|
||||
description="Discover curated tool collections shared by the community"
|
||||
/>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-col sm:flex-row gap-4 mb-6">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search collections..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-foreground-secondary">Sort:</span>
|
||||
<Select
|
||||
value={sort}
|
||||
onChange={(e) => setSort(e.target.value as SortOption)}
|
||||
options={[
|
||||
{ value: 'likes', label: 'Most Liked' },
|
||||
{ value: 'recent', label: 'Most Recent' },
|
||||
{ value: 'tools', label: 'Most Tools' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{error ? (
|
||||
<ErrorState message={error} onRetry={() => fetchCollections(0, true)} />
|
||||
) : isLoading ? (
|
||||
<LoadingState message="Loading collections..." size="lg" />
|
||||
) : filteredCollections.length === 0 ? (
|
||||
<EmptyState
|
||||
icon="folder"
|
||||
title="No collections found"
|
||||
description={
|
||||
search
|
||||
? 'Try adjusting your search terms'
|
||||
: 'Be the first to share a public collection!'
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="border border-border rounded-lg overflow-hidden">
|
||||
<TableVirtuoso
|
||||
style={{ height: 'calc(100vh - 350px)', minHeight: '400px' }}
|
||||
data={filteredCollections}
|
||||
overscan={30}
|
||||
endReached={loadMore}
|
||||
fixedHeaderContent={TableHeader}
|
||||
itemContent={TableRow}
|
||||
components={{
|
||||
Table: (props) => (
|
||||
<table
|
||||
{...props}
|
||||
className="w-full border-collapse text-sm"
|
||||
style={{ tableLayout: 'fixed' }}
|
||||
/>
|
||||
),
|
||||
TableHead: (props) => (
|
||||
<thead {...props} className="bg-surface-secondary sticky top-0 z-10" />
|
||||
),
|
||||
TableBody: (props) => <tbody {...props} />,
|
||||
TableRow: (props) => (
|
||||
<tr
|
||||
{...props}
|
||||
className="border-b border-border bg-surface hover:bg-surface-secondary transition-all duration-150 group"
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 text-sm text-foreground-tertiary">
|
||||
Showing {filteredCollections.length} collection
|
||||
{filteredCollections.length !== 1 ? 's' : ''}
|
||||
{search && ` matching "${search}"`}
|
||||
{hasMore && ' (scroll for more)'}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
/**
|
||||
* DEPRECATED: The /collections list page is deprecated.
|
||||
* Users should browse collections through user profiles.
|
||||
* All requests are 301 redirected to the homepage.
|
||||
*/
|
||||
export default function CollectionsListRedirectPage() {
|
||||
redirect('/');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,41 +19,11 @@ 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;
|
||||
|
|
@ -91,9 +61,9 @@ interface Collection {
|
|||
tools: CollectionTool[];
|
||||
}
|
||||
|
||||
type TabId = 'tools' | 'connect' | 'env-vars' | 'settings';
|
||||
type TabId = 'tools' | 'installation' | 'env-vars' | 'settings';
|
||||
|
||||
const VALID_TABS: TabId[] = ['tools', 'connect', 'env-vars', 'settings'];
|
||||
const VALID_TABS: TabId[] = ['tools', 'installation', 'env-vars', 'settings'];
|
||||
|
||||
export default function CollectionDetailPage(): React.ReactElement {
|
||||
const params = useParams();
|
||||
|
|
@ -114,7 +84,6 @@ 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) => {
|
||||
|
|
@ -365,29 +334,11 @@ 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: 'connect' as const, label: 'Connect' },
|
||||
{ id: 'installation' as const, label: 'Installation' },
|
||||
{
|
||||
id: 'env-vars' as const,
|
||||
label: 'Env Vars',
|
||||
|
|
@ -518,11 +469,11 @@ export default function CollectionDetailPage(): React.ReactElement {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Connect Tab */}
|
||||
{activeTab === 'connect' && (
|
||||
{/* Installation Tab */}
|
||||
{activeTab === 'installation' && (
|
||||
<div className="space-y-6">
|
||||
{/* Username warning */}
|
||||
{collection.isPublic && !collection.user.username && (
|
||||
{!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" />
|
||||
|
|
@ -558,63 +509,20 @@ export default function CollectionDetailPage(): React.ReactElement {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
{/* 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}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -113,7 +113,7 @@ export default function LikedCollectionsPage(): React.ReactElement {
|
|||
<p className="text-foreground-secondary mb-4">
|
||||
Browse public collections and click the heart icon to save your favorites
|
||||
</p>
|
||||
<Link href="/collections">
|
||||
<Link href="/">
|
||||
<Button>Browse Collections</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -213,11 +213,6 @@ 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
|
||||
|
|
|
|||
301
apps/web/src/components/collections/InstallationSection.tsx
Normal file
301
apps/web/src/components/collections/InstallationSection.tsx
Normal file
|
|
@ -0,0 +1,301 @@
|
|||
'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 { useState } from 'react';
|
||||
import { ForkButton } from '~/components/ForkButton';
|
||||
|
||||
interface EnvVar {
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface InstallationSectionProps {
|
||||
collection: {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
toolCount: number;
|
||||
envVars?: Record<string, string> | null;
|
||||
};
|
||||
username: string;
|
||||
isPrivate: boolean;
|
||||
/** Whether to show the fork button (typically false for dashboard/owner view) */
|
||||
showForkButton?: boolean;
|
||||
}
|
||||
|
||||
export function InstallationSection({
|
||||
collection,
|
||||
username,
|
||||
isPrivate,
|
||||
showForkButton = true,
|
||||
}: InstallationSectionProps) {
|
||||
const [copiedCommand, setCopiedCommand] = useState(false);
|
||||
const [showClaudeDesktop, setShowClaudeDesktop] = useState(false);
|
||||
const [showTroubleshooting, setShowTroubleshooting] = useState(false);
|
||||
|
||||
const baseUrl = typeof window !== 'undefined' ? window.location.origin : 'https://tpmjs.com';
|
||||
const mcpUrl = `${baseUrl}/@${username}/collections/${collection.slug}/mcp`;
|
||||
|
||||
// Build the claude mcp add command
|
||||
const commandParts = ['claude mcp add', collection.slug, '--transport http', mcpUrl];
|
||||
|
||||
if (isPrivate) {
|
||||
commandParts.push('--header "Authorization: Bearer YOUR_API_KEY"');
|
||||
}
|
||||
|
||||
const installCommand = commandParts.join(' \\\n ');
|
||||
|
||||
// Build Claude Desktop config JSON
|
||||
const claudeDesktopConfig = isPrivate
|
||||
? JSON.stringify(
|
||||
{
|
||||
mcpServers: {
|
||||
[collection.slug]: {
|
||||
type: 'http',
|
||||
url: mcpUrl,
|
||||
headers: {
|
||||
Authorization: 'Bearer YOUR_API_KEY',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
: JSON.stringify(
|
||||
{
|
||||
mcpServers: {
|
||||
[collection.slug]: {
|
||||
type: 'http',
|
||||
url: mcpUrl,
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
);
|
||||
|
||||
// Extract env var names from collection
|
||||
const envVarsList: EnvVar[] = collection.envVars
|
||||
? Object.keys(collection.envVars).map((name) => ({ name }))
|
||||
: [];
|
||||
|
||||
const copyCommand = async () => {
|
||||
// Copy the flat command (without line breaks for easy pasting)
|
||||
const flatCommand = isPrivate
|
||||
? `claude mcp add ${collection.slug} --transport http ${mcpUrl} --header "Authorization: Bearer YOUR_API_KEY"`
|
||||
: `claude mcp add ${collection.slug} --transport http ${mcpUrl}`;
|
||||
|
||||
await navigator.clipboard.writeText(flatCommand);
|
||||
setCopiedCommand(true);
|
||||
setTimeout(() => setCopiedCommand(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="bg-surface border border-border rounded-lg p-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="text-lg font-semibold text-foreground">Installation</h2>
|
||||
<Badge variant="secondary" size="sm">
|
||||
{collection.toolCount} {collection.toolCount === 1 ? 'tool' : 'tools'}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Env Vars Warning */}
|
||||
{envVarsList.length > 0 && (
|
||||
<div className="mb-6 p-4 bg-warning/10 border border-warning/30 rounded-lg">
|
||||
<div className="flex items-start gap-3">
|
||||
<Icon icon="alertTriangle" size="sm" className="text-warning mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<h3 className="font-medium text-foreground">Required Environment Variables</h3>
|
||||
<p className="text-sm text-foreground-secondary mt-1">
|
||||
Set these before tools will work:
|
||||
</p>
|
||||
<ul className="mt-2 space-y-1">
|
||||
{envVarsList.map((envVar) => (
|
||||
<li key={envVar.name} className="text-sm">
|
||||
<code className="font-mono text-xs bg-surface px-1.5 py-0.5 rounded border border-border">
|
||||
{envVar.name}
|
||||
</code>
|
||||
{envVar.description && (
|
||||
<span className="text-foreground-tertiary ml-2">— {envVar.description}</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 1: Add to Claude */}
|
||||
<div className="mb-6">
|
||||
<h3 className="text-sm font-medium text-foreground mb-2">Step 1: Add to Claude</h3>
|
||||
<div className="relative">
|
||||
<pre className="p-4 bg-surface-secondary border border-border rounded-lg font-mono text-sm text-foreground-secondary overflow-x-auto whitespace-pre-wrap break-all">
|
||||
{installCommand}
|
||||
</pre>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={copyCommand}
|
||||
className="absolute top-2 right-2"
|
||||
>
|
||||
<Icon icon={copiedCommand ? 'check' : 'copy'} size="xs" />
|
||||
</Button>
|
||||
</div>
|
||||
{isPrivate && (
|
||||
<p className="mt-2 text-xs text-foreground-tertiary">
|
||||
Get your API key from{' '}
|
||||
<Link
|
||||
href="/dashboard/settings/tpmjs-api-keys"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
Settings
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Step 2: Verify */}
|
||||
<div className="mb-6">
|
||||
<h3 className="text-sm font-medium text-foreground mb-2">Step 2: Verify</h3>
|
||||
<p className="text-sm text-foreground-secondary">
|
||||
Run{' '}
|
||||
<code className="font-mono text-xs bg-surface-secondary px-1.5 py-0.5 rounded border border-border">
|
||||
/mcp
|
||||
</code>{' '}
|
||||
in Claude Code to confirm the server is connected.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Fork Button */}
|
||||
{showForkButton && (
|
||||
<div className="mb-6">
|
||||
<ForkButton
|
||||
type="collection"
|
||||
sourceId={collection.id}
|
||||
sourceName={collection.name}
|
||||
variant="full"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Collapsible Sections */}
|
||||
<div className="pt-4 border-t border-border space-y-2">
|
||||
{/* Claude Desktop */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowClaudeDesktop(!showClaudeDesktop)}
|
||||
className="flex items-center gap-2 text-sm text-primary hover:text-primary/80 transition-colors"
|
||||
>
|
||||
<Icon icon={showClaudeDesktop ? 'chevronDown' : 'chevronRight'} size="xs" />
|
||||
<span>Using Claude Desktop instead?</span>
|
||||
</button>
|
||||
|
||||
{showClaudeDesktop && (
|
||||
<div className="mt-3 ml-6">
|
||||
<p className="text-sm text-foreground-secondary mb-2">
|
||||
Add this to your{' '}
|
||||
<code className="font-mono text-xs bg-surface-secondary px-1.5 py-0.5 rounded border border-border">
|
||||
claude_desktop_config.json
|
||||
</code>{' '}
|
||||
file:
|
||||
</p>
|
||||
<CodeBlock language="json" code={claudeDesktopConfig} />
|
||||
{isPrivate && (
|
||||
<p className="mt-2 text-xs text-foreground-tertiary">
|
||||
Replace <code className="font-mono">YOUR_API_KEY</code> with your{' '}
|
||||
<Link
|
||||
href="/dashboard/settings/tpmjs-api-keys"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
TPMJS API key
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Troubleshooting */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowTroubleshooting(!showTroubleshooting)}
|
||||
className="flex items-center gap-2 text-sm text-primary hover:text-primary/80 transition-colors"
|
||||
>
|
||||
<Icon icon={showTroubleshooting ? 'chevronDown' : 'chevronRight'} size="xs" />
|
||||
<span>Troubleshooting</span>
|
||||
</button>
|
||||
|
||||
{showTroubleshooting && (
|
||||
<div className="mt-3 ml-6 space-y-4 text-sm">
|
||||
{/* Connection timeout */}
|
||||
<div>
|
||||
<h4 className="font-medium text-foreground">Connection timeout</h4>
|
||||
<ul className="mt-1 text-foreground-secondary space-y-1">
|
||||
<li>
|
||||
• Try increasing the timeout:{' '}
|
||||
<code className="font-mono text-xs bg-surface-secondary px-1 rounded">
|
||||
MCP_TIMEOUT=10000 claude
|
||||
</code>
|
||||
</li>
|
||||
<li>• Check firewall/VPN settings</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Auth failures */}
|
||||
<div>
|
||||
<h4 className="font-medium text-foreground">Authentication failures</h4>
|
||||
<ul className="mt-1 text-foreground-secondary space-y-1">
|
||||
<li>• Verify your API key is correct</li>
|
||||
<li>
|
||||
• Ensure the header format is{' '}
|
||||
<code className="font-mono text-xs bg-surface-secondary px-1 rounded">
|
||||
Authorization: Bearer YOUR_KEY
|
||||
</code>
|
||||
</li>
|
||||
<li>• Check that your API key hasn't expired</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Server not appearing */}
|
||||
<div>
|
||||
<h4 className="font-medium text-foreground">Server not appearing</h4>
|
||||
<ul className="mt-1 text-foreground-secondary space-y-1">
|
||||
<li>
|
||||
• Run{' '}
|
||||
<code className="font-mono text-xs bg-surface-secondary px-1 rounded">
|
||||
claude mcp list
|
||||
</code>{' '}
|
||||
to see configured servers
|
||||
</li>
|
||||
<li>
|
||||
• Try removing and re-adding:{' '}
|
||||
<code className="font-mono text-xs bg-surface-secondary px-1 rounded">
|
||||
claude mcp remove {collection.slug}
|
||||
</code>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Tools not loading */}
|
||||
<div>
|
||||
<h4 className="font-medium text-foreground">Tools not loading</h4>
|
||||
<ul className="mt-1 text-foreground-secondary space-y-1">
|
||||
<li>• The server may take a moment to initialize on first connection</li>
|
||||
<li>• Check that required environment variables are set</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -124,7 +124,10 @@ export function SkillsActivityFeed({
|
|||
<div className="space-y-3">
|
||||
{questions.map((q) => (
|
||||
<Link key={q.id} href={`${basePath}/skills/questions/${q.id}`} className="block">
|
||||
<Card variant="default" className="hover:border-primary/20 hover:bg-muted/30 transition-all cursor-pointer">
|
||||
<Card
|
||||
variant="default"
|
||||
className="hover:border-primary/20 hover:bg-muted/30 transition-all cursor-pointer"
|
||||
>
|
||||
<CardHeader padding="sm" className="pb-2">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<CardTitle as="h4" className="text-sm font-medium line-clamp-2">
|
||||
|
|
@ -146,8 +149,8 @@ export function SkillsActivityFeed({
|
|||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex gap-1.5 flex-wrap">
|
||||
{q.skillNodes.slice(0, 2).map((sn, i) => (
|
||||
<Badge key={i} variant="outline" size="sm">
|
||||
{q.skillNodes.slice(0, 2).map((sn) => (
|
||||
<Badge key={sn.skill.name} variant="outline" size="sm">
|
||||
{sn.skill.name}
|
||||
</Badge>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -120,13 +120,15 @@ export function SkillsStats({ collectionId }: SkillsStatsProps): React.ReactElem
|
|||
</CardHeader>
|
||||
<CardContent padding="sm" className="pt-0">
|
||||
<div className="space-y-3">
|
||||
{stats.topSkills.slice(0, 5).map((skill, i) => (
|
||||
<div key={i} className="space-y-1">
|
||||
{stats.topSkills.slice(0, 5).map((skill) => (
|
||||
<div key={skill.name} className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<Badge variant="outline" size="sm" className="truncate max-w-[140px]">
|
||||
{skill.name}
|
||||
</Badge>
|
||||
<span className="text-xs text-foreground-secondary">{skill.questionCount} Q</span>
|
||||
<span className="text-xs text-foreground-secondary">
|
||||
{skill.questionCount} Q
|
||||
</span>
|
||||
</div>
|
||||
<ProgressBar
|
||||
value={skill.confidence * 100}
|
||||
|
|
|
|||
|
|
@ -108,9 +108,9 @@ export const DEFAULT_API_KEY_SCOPES: ApiKeyScope[] = [
|
|||
* Rate limits by user tier (requests per hour)
|
||||
*/
|
||||
export const RATE_LIMITS_BY_TIER = {
|
||||
FREE: 100,
|
||||
PRO: 1000,
|
||||
ENTERPRISE: 10000,
|
||||
FREE: 1000,
|
||||
PRO: 10000,
|
||||
ENTERPRISE: 100000,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -93,21 +93,21 @@ export interface RateLimitConfig {
|
|||
prefix?: string;
|
||||
}
|
||||
|
||||
/** Default rate limit: 100 requests per minute */
|
||||
/** Default rate limit: 1000 requests per minute */
|
||||
export const DEFAULT_RATE_LIMIT: RateLimitConfig = {
|
||||
limit: 100,
|
||||
limit: 1000,
|
||||
windowSeconds: 60,
|
||||
};
|
||||
|
||||
/** Strict rate limit for expensive operations: 20 requests per minute */
|
||||
/** Strict rate limit for expensive operations: 200 requests per minute */
|
||||
export const STRICT_RATE_LIMIT: RateLimitConfig = {
|
||||
limit: 20,
|
||||
limit: 200,
|
||||
windowSeconds: 60,
|
||||
};
|
||||
|
||||
/** AI generation rate limit: 5 requests per hour (expensive AI operations) */
|
||||
/** AI generation rate limit: 50 requests per hour (expensive AI operations) */
|
||||
export const AI_GENERATION_RATE_LIMIT: RateLimitConfig = {
|
||||
limit: 5,
|
||||
limit: 50,
|
||||
windowSeconds: 3600, // 1 hour
|
||||
prefix: 'ai-gen',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
import { prisma } from '@tpmjs/db';
|
||||
|
||||
const RATE_LIMIT_WINDOW_MS = 3600000; // 1 hour
|
||||
const RATE_LIMIT_MAX_REQUESTS = 10; // 10 executions per hour
|
||||
const RATE_LIMIT_MAX_REQUESTS = 100; // 100 executions per hour
|
||||
|
||||
export interface RateLimitResult {
|
||||
allowed: boolean;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue