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:
Ajax Davis 2026-02-07 00:49:40 +10:00
parent f3a46045ba
commit a52d32c367
40 changed files with 3310 additions and 1694 deletions

View file

@ -12,9 +12,7 @@ const nextConfig: NextConfig = {
'@tpmjs/registry-execute',
],
reactStrictMode: true,
serverExternalPackages: [
'@tpmjs/package-executor',
],
serverExternalPackages: ['@tpmjs/package-executor'],
async redirects() {
return [
{

View file

@ -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&apos;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 ? (

View file

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

View file

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

View file

@ -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" />

View file

@ -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">

View file

@ -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`,

View file

@ -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,

View file

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

View file

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

View file

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

View file

@ -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>

View file

@ -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

View 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&apos;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>
);
}

View file

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

View file

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

View file

@ -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;
/**

View file

@ -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',
};

View file

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