feat(web): add tweet button and OG tags to collection page
- Add ShareButton component for Twitter/X sharing - Refactor collection page to server component with generateMetadata - Add proper OpenGraph and Twitter Card meta tags for social sharing - Extract client-side logic to CollectionDetailClient component
This commit is contained in:
parent
3c5c218207
commit
ac6fdb87e7
4 changed files with 798 additions and 433 deletions
|
|
@ -0,0 +1,419 @@
|
|||
'use client';
|
||||
|
||||
import { Badge } from '@tpmjs/ui/Badge/Badge';
|
||||
import { Button } from '@tpmjs/ui/Button/Button';
|
||||
import { CodeBlock } from '@tpmjs/ui/CodeBlock/CodeBlock';
|
||||
import { Icon } from '@tpmjs/ui/Icon/Icon';
|
||||
import Link from 'next/link';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { AppHeader } from '~/components/AppHeader';
|
||||
import { ForkButton } from '~/components/ForkButton';
|
||||
import { ForkedFromBadge } from '~/components/ForkedFromBadge';
|
||||
import { LikeButton } from '~/components/LikeButton';
|
||||
import { ScenariosSection } from '~/components/ScenariosSection';
|
||||
import { ShareButton } from '~/components/ShareButton';
|
||||
import { UseCasesSection } from '~/components/UseCasesSection';
|
||||
import { useSession } from '~/lib/auth-client';
|
||||
|
||||
export interface CollectionTool {
|
||||
id: string;
|
||||
toolId: string;
|
||||
position: number;
|
||||
note: string | null;
|
||||
tool: {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
likeCount: number;
|
||||
package: {
|
||||
npmPackageName: string;
|
||||
category: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface UseCaseToolStep {
|
||||
toolName: string;
|
||||
packageName: string;
|
||||
purpose: string;
|
||||
order: number;
|
||||
}
|
||||
|
||||
export interface UseCase {
|
||||
id: string;
|
||||
userPrompt: string;
|
||||
description: string;
|
||||
toolSequence: UseCaseToolStep[];
|
||||
}
|
||||
|
||||
export interface PublicCollection {
|
||||
id: string;
|
||||
slug: string; // Already coerced to empty string if null in server component
|
||||
name: string;
|
||||
description: string | null;
|
||||
likeCount: number;
|
||||
toolCount: number;
|
||||
forkCount: number;
|
||||
createdAt: string;
|
||||
createdBy: {
|
||||
id: string;
|
||||
username: string;
|
||||
name: string;
|
||||
image: string | null;
|
||||
};
|
||||
tools: CollectionTool[];
|
||||
forkedFromId: string | null;
|
||||
forkedFrom: {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string; // Already coerced to empty string if null in server component
|
||||
user: {
|
||||
username: string;
|
||||
};
|
||||
} | null;
|
||||
useCases: UseCase[] | null;
|
||||
useCasesGeneratedAt: string | null;
|
||||
}
|
||||
|
||||
function McpUrlSection({
|
||||
username,
|
||||
slug,
|
||||
isOwner,
|
||||
}: {
|
||||
username: string;
|
||||
slug: string;
|
||||
isOwner: boolean;
|
||||
}) {
|
||||
const [copiedUrl, setCopiedUrl] = useState<'http' | 'sse' | null>(null);
|
||||
const [showConfig, setShowConfig] = useState(false);
|
||||
const [showApiExample, setShowApiExample] = useState(false);
|
||||
|
||||
const baseUrl = typeof window !== 'undefined' ? window.location.origin : 'https://tpmjs.com';
|
||||
const httpUrl = `${baseUrl}/api/mcp/${username}/${slug}/http`;
|
||||
const sseUrl = `${baseUrl}/api/mcp/${username}/${slug}/sse`;
|
||||
|
||||
const copyToClipboard = async (url: string, type: 'http' | 'sse') => {
|
||||
await navigator.clipboard.writeText(url);
|
||||
setCopiedUrl(type);
|
||||
setTimeout(() => setCopiedUrl(null), 2000);
|
||||
};
|
||||
|
||||
const configSnippet = `{
|
||||
"mcpServers": {
|
||||
"tpmjs-${slug}": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"mcp-remote",
|
||||
"${httpUrl}"
|
||||
]
|
||||
}
|
||||
}
|
||||
}`;
|
||||
|
||||
const apiExampleSnippet = `// Call a tool with your own credentials
|
||||
const response = await fetch("${httpUrl}", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": "Bearer YOUR_TPMJS_API_KEY"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
method: "tools/call",
|
||||
params: {
|
||||
name: "tool-name",
|
||||
arguments: { /* tool args */ },
|
||||
env: {
|
||||
// Your env vars for the tools
|
||||
"API_KEY": "your-key-here"
|
||||
}
|
||||
},
|
||||
id: 1
|
||||
})
|
||||
});`;
|
||||
|
||||
return (
|
||||
<section className="p-4 bg-gradient-to-br from-primary/5 via-transparent to-primary/5 border border-primary/20 rounded-xl">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<div className="p-1.5 bg-primary/10 rounded-lg">
|
||||
<Icon icon="link" className="w-4 h-4 text-primary" />
|
||||
</div>
|
||||
<h3 className="font-semibold text-foreground">MCP Server URLs</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{/* HTTP Transport */}
|
||||
<div className="group">
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<span className="text-xs font-medium text-foreground-secondary uppercase tracking-wide">
|
||||
HTTP Transport
|
||||
</span>
|
||||
<span className="text-xs text-foreground-tertiary">(recommended)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 px-3 py-2 bg-surface border border-border rounded-lg font-mono text-sm text-foreground-secondary overflow-x-auto">
|
||||
{httpUrl}
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => copyToClipboard(httpUrl, 'http')}
|
||||
className="shrink-0"
|
||||
>
|
||||
<Icon icon={copiedUrl === 'http' ? 'check' : 'copy'} className="w-4 h-4 mr-1" />
|
||||
{copiedUrl === 'http' ? 'Copied!' : 'Copy'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SSE Transport */}
|
||||
<div className="group">
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<span className="text-xs font-medium text-foreground-secondary uppercase tracking-wide">
|
||||
SSE Transport
|
||||
</span>
|
||||
<span className="text-xs text-foreground-tertiary">(streaming)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 px-3 py-2 bg-surface border border-border rounded-lg font-mono text-sm text-foreground-secondary overflow-x-auto">
|
||||
{sseUrl}
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => copyToClipboard(sseUrl, 'sse')}
|
||||
className="shrink-0"
|
||||
>
|
||||
<Icon icon={copiedUrl === 'sse' ? 'check' : 'copy'} className="w-4 h-4 mr-1" />
|
||||
{copiedUrl === 'sse' ? 'Copied!' : 'Copy'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Note for non-owners */}
|
||||
{!isOwner && (
|
||||
<div className="mt-4 p-3 bg-warning/10 border border-warning/20 rounded-lg">
|
||||
<p className="text-sm text-warning-foreground">
|
||||
<Icon icon="info" className="w-4 h-4 inline mr-1" />
|
||||
You'll need to provide your own API keys for any tools that require them. Pass
|
||||
credentials via the{' '}
|
||||
<code className="font-mono text-xs bg-surface px-1 rounded">env</code> parameter in your
|
||||
API calls.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Config snippet toggle */}
|
||||
<div className="mt-4 pt-4 border-t border-border/50 space-y-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowConfig(!showConfig)}
|
||||
className="flex items-center gap-2 text-sm text-primary hover:text-primary/80 transition-colors"
|
||||
>
|
||||
<Icon icon={showConfig ? 'chevronDown' : 'chevronRight'} className="w-4 h-4" />
|
||||
<span>Show Claude Desktop config</span>
|
||||
</button>
|
||||
|
||||
{showConfig && (
|
||||
<div className="mt-3">
|
||||
<CodeBlock language="json" code={configSnippet} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isOwner && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowApiExample(!showApiExample)}
|
||||
className="flex items-center gap-2 text-sm text-primary hover:text-primary/80 transition-colors"
|
||||
>
|
||||
<Icon icon={showApiExample ? 'chevronDown' : 'chevronRight'} className="w-4 h-4" />
|
||||
<span>Show API usage example</span>
|
||||
</button>
|
||||
|
||||
{showApiExample && (
|
||||
<div className="mt-3">
|
||||
<CodeBlock language="typescript" code={apiExampleSnippet} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="mt-3 text-xs text-foreground-tertiary">
|
||||
Use these URLs with{' '}
|
||||
<Link href="/docs/sharing" className="text-primary hover:underline">
|
||||
Claude Desktop, Cursor, or any MCP client
|
||||
</Link>
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
interface CollectionDetailClientProps {
|
||||
collection: PublicCollection;
|
||||
username: string;
|
||||
}
|
||||
|
||||
export function CollectionDetailClient({
|
||||
collection: initialCollection,
|
||||
username,
|
||||
}: CollectionDetailClientProps) {
|
||||
const { data: session } = useSession();
|
||||
const [collection, setCollection] = useState(initialCollection);
|
||||
|
||||
// Check if current user is the owner
|
||||
const isOwner = session?.user?.id && collection.createdBy?.id === session.user.id;
|
||||
|
||||
// Handler for when use cases are generated
|
||||
const handleUseCasesGenerated = useCallback(
|
||||
(useCases: UseCase[], generatedAt: string) => {
|
||||
setCollection({
|
||||
...collection,
|
||||
useCases,
|
||||
useCasesGeneratedAt: generatedAt,
|
||||
});
|
||||
},
|
||||
[collection]
|
||||
);
|
||||
|
||||
// Generate tweet text
|
||||
const tweetText = collection.description
|
||||
? `${collection.name} - ${collection.description.slice(0, 100)}${collection.description.length > 100 ? '...' : ''}`
|
||||
: `Check out "${collection.name}" - a collection of ${collection.toolCount} AI tools`;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<AppHeader />
|
||||
|
||||
<main className="max-w-5xl mx-auto px-4 py-8">
|
||||
<div className="space-y-8">
|
||||
{/* Collection Header */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">{collection.name}</h1>
|
||||
{collection.description && (
|
||||
<p className="text-foreground-secondary mt-2">{collection.description}</p>
|
||||
)}
|
||||
<div className="flex items-center gap-3 mt-2">
|
||||
<Link
|
||||
href={`/${username}`}
|
||||
className="text-sm text-foreground-tertiary hover:text-foreground-secondary inline-flex items-center gap-1"
|
||||
>
|
||||
by @{collection.createdBy.username}
|
||||
</Link>
|
||||
{collection.forkedFrom && (
|
||||
<ForkedFromBadge type="collection" forkedFrom={collection.forkedFrom} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<ShareButton
|
||||
title={collection.name}
|
||||
text={tweetText}
|
||||
hashtags={['TPMJS', 'AI', 'MCP']}
|
||||
variant="twitter"
|
||||
size="sm"
|
||||
/>
|
||||
<LikeButton
|
||||
entityType="collection"
|
||||
entityId={collection.id}
|
||||
initialCount={collection.likeCount}
|
||||
/>
|
||||
<ForkButton type="collection" sourceId={collection.id} sourceName={collection.name} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="flex items-center gap-6 text-sm text-foreground-secondary">
|
||||
<span className="flex items-center gap-1">
|
||||
<Icon icon="puzzle" className="w-4 h-4" />
|
||||
{collection.toolCount} tools
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Icon icon="heart" className="w-4 h-4" />
|
||||
{collection.likeCount} likes
|
||||
</span>
|
||||
{collection.forkCount > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Icon icon="gitFork" className="w-4 h-4" />
|
||||
{collection.forkCount} forks
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* MCP Server URLs - Available to everyone (non-owners must provide their own credentials) */}
|
||||
<McpUrlSection username={username} slug={collection.slug} isOwner={!!isOwner} />
|
||||
|
||||
{/* Tools */}
|
||||
{collection.tools.length > 0 ? (
|
||||
<section>
|
||||
<h2 className="text-lg font-semibold text-foreground mb-4">Tools in Collection</h2>
|
||||
<div className="grid gap-3">
|
||||
{collection.tools.map((ct) => (
|
||||
<Link
|
||||
key={ct.id}
|
||||
href={`/tool/${ct.tool.package.npmPackageName}/${ct.tool.name}`}
|
||||
className="block p-4 bg-surface border border-border rounded-lg hover:border-foreground-secondary transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h3 className="font-medium text-foreground">{ct.tool.name}</h3>
|
||||
<p className="text-sm text-foreground-secondary mt-1 line-clamp-2">
|
||||
{ct.tool.description}
|
||||
</p>
|
||||
{ct.note && (
|
||||
<p className="text-xs text-foreground-tertiary italic mt-2">
|
||||
Note: {ct.note}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{ct.tool.package.category}
|
||||
</Badge>
|
||||
<span className="text-xs text-foreground-tertiary">
|
||||
{ct.tool.package.npmPackageName}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-xs text-foreground-tertiary">
|
||||
<Icon icon="heart" className="w-3.5 h-3.5" />
|
||||
{ct.tool.likeCount}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
<Icon icon="box" className="w-12 h-12 mx-auto text-foreground-secondary mb-4" />
|
||||
<p className="text-foreground-secondary">This collection is empty.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Scenarios Section */}
|
||||
{collection.tools.length > 0 && (
|
||||
<ScenariosSection
|
||||
collectionId={collection.id}
|
||||
collectionOwnerId={collection.createdBy.id}
|
||||
username={username}
|
||||
slug={collection.slug}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Use Cases Section - at the bottom */}
|
||||
{collection.tools.length > 0 && (
|
||||
<UseCasesSection
|
||||
collectionId={collection.id}
|
||||
useCases={collection.useCases}
|
||||
generatedAt={collection.useCasesGeneratedAt}
|
||||
onUseCasesGenerated={handleUseCasesGenerated}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,451 +1,201 @@
|
|||
'use client';
|
||||
import { prisma } from '@tpmjs/db';
|
||||
import type { Metadata } from 'next';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { CollectionDetailClient, type PublicCollection } from './CollectionDetailClient';
|
||||
|
||||
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 { notFound, useParams } from 'next/navigation';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { AppHeader } from '~/components/AppHeader';
|
||||
import { ForkButton } from '~/components/ForkButton';
|
||||
import { ForkedFromBadge } from '~/components/ForkedFromBadge';
|
||||
import { LikeButton } from '~/components/LikeButton';
|
||||
import { ScenariosSection } from '~/components/ScenariosSection';
|
||||
import { UseCasesSection } from '~/components/UseCasesSection';
|
||||
import { useSession } from '~/lib/auth-client';
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
interface CollectionTool {
|
||||
id: string;
|
||||
toolId: string;
|
||||
position: number;
|
||||
note: string | null;
|
||||
tool: {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
likeCount: number;
|
||||
package: {
|
||||
npmPackageName: string;
|
||||
category: string;
|
||||
};
|
||||
};
|
||||
interface CollectionPageProps {
|
||||
params: Promise<{ username: string; slug: string }>;
|
||||
}
|
||||
|
||||
interface UseCaseToolStep {
|
||||
toolName: string;
|
||||
packageName: string;
|
||||
purpose: string;
|
||||
order: number;
|
||||
}
|
||||
/**
|
||||
* Fetch collection data from database
|
||||
*/
|
||||
async function getCollection(username: string, slug: string): Promise<PublicCollection | null> {
|
||||
// Remove @ prefix if present
|
||||
const cleanUsername = username.startsWith('@') ? username.slice(1) : username;
|
||||
|
||||
interface UseCase {
|
||||
id: string;
|
||||
userPrompt: string;
|
||||
description: string;
|
||||
toolSequence: UseCaseToolStep[];
|
||||
}
|
||||
const collection = await prisma.collection.findFirst({
|
||||
where: {
|
||||
slug,
|
||||
user: { username: cleanUsername },
|
||||
isPublic: true,
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
name: true,
|
||||
image: true,
|
||||
},
|
||||
},
|
||||
tools: {
|
||||
include: {
|
||||
tool: {
|
||||
include: {
|
||||
package: {
|
||||
select: {
|
||||
npmPackageName: true,
|
||||
category: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { position: 'asc' },
|
||||
},
|
||||
forkedFrom: {
|
||||
include: {
|
||||
user: {
|
||||
select: { username: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
interface PublicCollection {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
likeCount: number;
|
||||
toolCount: number;
|
||||
forkCount: number;
|
||||
createdAt: string;
|
||||
createdBy: {
|
||||
id: string;
|
||||
username: string;
|
||||
name: string;
|
||||
image: string | null;
|
||||
};
|
||||
tools: CollectionTool[];
|
||||
forkedFromId: string | null;
|
||||
forkedFrom: {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
user: {
|
||||
username: string;
|
||||
};
|
||||
} | null;
|
||||
useCases: UseCase[] | null;
|
||||
useCasesGeneratedAt: string | null;
|
||||
}
|
||||
|
||||
function McpUrlSection({
|
||||
username,
|
||||
slug,
|
||||
isOwner,
|
||||
}: {
|
||||
username: string;
|
||||
slug: string;
|
||||
isOwner: boolean;
|
||||
}) {
|
||||
const [copiedUrl, setCopiedUrl] = useState<'http' | 'sse' | null>(null);
|
||||
const [showConfig, setShowConfig] = useState(false);
|
||||
const [showApiExample, setShowApiExample] = useState(false);
|
||||
|
||||
const baseUrl = typeof window !== 'undefined' ? window.location.origin : 'https://tpmjs.com';
|
||||
const httpUrl = `${baseUrl}/api/mcp/${username}/${slug}/http`;
|
||||
const sseUrl = `${baseUrl}/api/mcp/${username}/${slug}/sse`;
|
||||
|
||||
const copyToClipboard = async (url: string, type: 'http' | 'sse') => {
|
||||
await navigator.clipboard.writeText(url);
|
||||
setCopiedUrl(type);
|
||||
setTimeout(() => setCopiedUrl(null), 2000);
|
||||
};
|
||||
|
||||
const configSnippet = `{
|
||||
"mcpServers": {
|
||||
"tpmjs-${slug}": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"mcp-remote",
|
||||
"${httpUrl}"
|
||||
]
|
||||
}
|
||||
if (!collection) {
|
||||
return null;
|
||||
}
|
||||
}`;
|
||||
|
||||
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"
|
||||
}
|
||||
// Parse useCases from Json field
|
||||
const useCases = collection.useCases as
|
||||
| {
|
||||
id: string;
|
||||
userPrompt: string;
|
||||
description: string;
|
||||
toolSequence: {
|
||||
toolName: string;
|
||||
packageName: string;
|
||||
purpose: string;
|
||||
order: number;
|
||||
}[];
|
||||
}[]
|
||||
| null;
|
||||
|
||||
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,
|
||||
},
|
||||
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>
|
||||
);
|
||||
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,
|
||||
},
|
||||
},
|
||||
})),
|
||||
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,
|
||||
useCases: useCases ?? null,
|
||||
useCasesGeneratedAt: collection.useCasesGeneratedAt?.toISOString() ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export default function PrettyCollectionDetailPage(): React.ReactElement {
|
||||
const params = useParams();
|
||||
const rawUsername = params.username as string;
|
||||
const username = rawUsername.startsWith('@') ? rawUsername.slice(1) : rawUsername;
|
||||
const slug = params.slug as string;
|
||||
const { data: session } = useSession();
|
||||
/**
|
||||
* Generate metadata for OG tags and SEO
|
||||
*/
|
||||
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 [collection, setCollection] = useState<PublicCollection | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
if (!collection) {
|
||||
return {
|
||||
title: 'Collection Not Found | TPMJS',
|
||||
description: 'The requested collection could not be found.',
|
||||
};
|
||||
}
|
||||
|
||||
// Check if current user is the owner
|
||||
const isOwner = session?.user?.id && collection?.createdBy?.id === session.user.id;
|
||||
const title = `${collection.name} | TPMJS`;
|
||||
const description =
|
||||
collection.description ||
|
||||
`${collection.name} - A collection of ${collection.toolCount} AI tools curated by @${cleanUsername}`;
|
||||
|
||||
// Handler for when use cases are generated
|
||||
const handleUseCasesGenerated = useCallback(
|
||||
(useCases: UseCase[], generatedAt: string) => {
|
||||
if (collection) {
|
||||
setCollection({
|
||||
...collection,
|
||||
useCases,
|
||||
useCasesGeneratedAt: generatedAt,
|
||||
});
|
||||
}
|
||||
// Generate a list of tool names for keywords
|
||||
const toolNames = collection.tools.slice(0, 5).map((t) => t.tool.name);
|
||||
const keywords = ['TPMJS', 'AI', 'MCP', 'tools', 'collection', ...toolNames];
|
||||
|
||||
// OG image URL - for now use default, can add custom collection OG later
|
||||
const ogImageUrl = `/api/og/collection/${encodeURIComponent(cleanUsername)}/${encodeURIComponent(slug)}`;
|
||||
|
||||
const canonicalUrl = `https://tpmjs.com/${cleanUsername}/collections/${slug}`;
|
||||
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
keywords,
|
||||
authors: [{ name: `@${cleanUsername}` }],
|
||||
openGraph: {
|
||||
title: collection.name,
|
||||
description,
|
||||
type: 'website',
|
||||
url: canonicalUrl,
|
||||
siteName: 'TPMJS',
|
||||
images: [
|
||||
{
|
||||
url: ogImageUrl,
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: `${collection.name} - TPMJS Collection`,
|
||||
},
|
||||
],
|
||||
},
|
||||
[collection]
|
||||
);
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: collection.name,
|
||||
description,
|
||||
site: '@tpmjs_registry',
|
||||
creator: `@${cleanUsername}`,
|
||||
images: [ogImageUrl],
|
||||
},
|
||||
alternates: {
|
||||
canonical: canonicalUrl,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const fetchCollection = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/public/users/${username}/collections/${slug}`);
|
||||
if (response.status === 404) {
|
||||
setError('not_found');
|
||||
return;
|
||||
}
|
||||
const data = await response.json();
|
||||
/**
|
||||
* Collection detail page - server component
|
||||
*/
|
||||
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);
|
||||
|
||||
if (data.success) {
|
||||
setCollection(data.data);
|
||||
} else {
|
||||
setError(data.error?.message || 'Failed to load collection');
|
||||
}
|
||||
} catch {
|
||||
setError('Failed to load collection');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [username, slug]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCollection();
|
||||
}, [fetchCollection]);
|
||||
|
||||
if (error === 'not_found') {
|
||||
if (!collection) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<AppHeader />
|
||||
|
||||
<main className="max-w-5xl mx-auto px-4 py-8">
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<Icon icon="loader" className="w-8 h-8 animate-spin text-foreground-secondary" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-error">{error}</p>
|
||||
</div>
|
||||
) : collection ? (
|
||||
<div className="space-y-8">
|
||||
{/* Collection Header */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">{collection.name}</h1>
|
||||
{collection.description && (
|
||||
<p className="text-foreground-secondary mt-2">{collection.description}</p>
|
||||
)}
|
||||
<div className="flex items-center gap-3 mt-2">
|
||||
<Link
|
||||
href={`/${username}`}
|
||||
className="text-sm text-foreground-tertiary hover:text-foreground-secondary inline-flex items-center gap-1"
|
||||
>
|
||||
by @{collection.createdBy.username}
|
||||
</Link>
|
||||
{collection.forkedFrom && (
|
||||
<ForkedFromBadge type="collection" forkedFrom={collection.forkedFrom} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<LikeButton
|
||||
entityType="collection"
|
||||
entityId={collection.id}
|
||||
initialCount={collection.likeCount}
|
||||
/>
|
||||
<ForkButton
|
||||
type="collection"
|
||||
sourceId={collection.id}
|
||||
sourceName={collection.name}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="flex items-center gap-6 text-sm text-foreground-secondary">
|
||||
<span className="flex items-center gap-1">
|
||||
<Icon icon="puzzle" className="w-4 h-4" />
|
||||
{collection.toolCount} tools
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Icon icon="heart" className="w-4 h-4" />
|
||||
{collection.likeCount} likes
|
||||
</span>
|
||||
{collection.forkCount > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Icon icon="gitFork" className="w-4 h-4" />
|
||||
{collection.forkCount} forks
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* MCP Server URLs - Available to everyone (non-owners must provide their own credentials) */}
|
||||
<McpUrlSection username={username} slug={collection.slug} isOwner={!!isOwner} />
|
||||
|
||||
{/* Tools */}
|
||||
{collection.tools.length > 0 ? (
|
||||
<section>
|
||||
<h2 className="text-lg font-semibold text-foreground mb-4">Tools in Collection</h2>
|
||||
<div className="grid gap-3">
|
||||
{collection.tools.map((ct) => (
|
||||
<Link
|
||||
key={ct.id}
|
||||
href={`/tool/${ct.tool.package.npmPackageName}/${ct.tool.name}`}
|
||||
className="block p-4 bg-surface border border-border rounded-lg hover:border-foreground-secondary transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h3 className="font-medium text-foreground">{ct.tool.name}</h3>
|
||||
<p className="text-sm text-foreground-secondary mt-1 line-clamp-2">
|
||||
{ct.tool.description}
|
||||
</p>
|
||||
{ct.note && (
|
||||
<p className="text-xs text-foreground-tertiary italic mt-2">
|
||||
Note: {ct.note}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{ct.tool.package.category}
|
||||
</Badge>
|
||||
<span className="text-xs text-foreground-tertiary">
|
||||
{ct.tool.package.npmPackageName}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-xs text-foreground-tertiary">
|
||||
<Icon icon="heart" className="w-3.5 h-3.5" />
|
||||
{ct.tool.likeCount}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
<Icon icon="box" className="w-12 h-12 mx-auto text-foreground-secondary mb-4" />
|
||||
<p className="text-foreground-secondary">This collection is empty.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Scenarios Section */}
|
||||
{collection.tools.length > 0 && (
|
||||
<ScenariosSection
|
||||
collectionId={collection.id}
|
||||
collectionOwnerId={collection.createdBy.id}
|
||||
username={username}
|
||||
slug={collection.slug}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Use Cases Section - at the bottom */}
|
||||
{collection.tools.length > 0 && (
|
||||
<UseCasesSection
|
||||
collectionId={collection.id}
|
||||
useCases={collection.useCases}
|
||||
generatedAt={collection.useCasesGeneratedAt}
|
||||
onUseCasesGenerated={handleUseCasesGenerated}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
return <CollectionDetailClient collection={collection} username={cleanUsername} />;
|
||||
}
|
||||
|
|
|
|||
112
apps/web/src/components/ShareButton.tsx
Normal file
112
apps/web/src/components/ShareButton.tsx
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
'use client';
|
||||
|
||||
import { Button } from '@tpmjs/ui/Button/Button';
|
||||
import { Icon } from '@tpmjs/ui/Icon/Icon';
|
||||
import { useState } from 'react';
|
||||
|
||||
interface ShareButtonProps {
|
||||
title: string;
|
||||
text?: string;
|
||||
url?: string;
|
||||
hashtags?: string[];
|
||||
via?: string;
|
||||
variant?: 'default' | 'twitter' | 'copy';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ShareButton({
|
||||
title,
|
||||
text,
|
||||
url,
|
||||
hashtags = [],
|
||||
via = 'tpmjs_registry',
|
||||
variant = 'twitter',
|
||||
size = 'sm',
|
||||
className,
|
||||
}: ShareButtonProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const getShareUrl = () => {
|
||||
if (typeof window === 'undefined') return '';
|
||||
return url || window.location.href;
|
||||
};
|
||||
|
||||
const handleTwitterShare = () => {
|
||||
const shareUrl = getShareUrl();
|
||||
const tweetText = text || title;
|
||||
const hashtagsParam = hashtags.length > 0 ? `&hashtags=${hashtags.join(',')}` : '';
|
||||
const viaParam = via ? `&via=${via}` : '';
|
||||
|
||||
const twitterUrl = `https://twitter.com/intent/tweet?text=${encodeURIComponent(tweetText)}&url=${encodeURIComponent(shareUrl)}${hashtagsParam}${viaParam}`;
|
||||
|
||||
window.open(twitterUrl, '_blank', 'width=550,height=420');
|
||||
};
|
||||
|
||||
const handleCopyLink = async () => {
|
||||
const shareUrl = getShareUrl();
|
||||
await navigator.clipboard.writeText(shareUrl);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
if (variant === 'copy') {
|
||||
return (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size={size}
|
||||
onClick={handleCopyLink}
|
||||
className={className}
|
||||
title="Copy link"
|
||||
>
|
||||
<Icon icon={copied ? 'check' : 'link'} className="w-4 h-4 mr-1.5" />
|
||||
{copied ? 'Copied!' : 'Copy Link'}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === 'twitter') {
|
||||
return (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size={size}
|
||||
onClick={handleTwitterShare}
|
||||
className={className}
|
||||
title="Share on Twitter/X"
|
||||
>
|
||||
<svg className="w-4 h-4 mr-1.5" viewBox="0 0 24 24" fill="currentColor" role="img">
|
||||
<title>X/Twitter</title>
|
||||
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
|
||||
</svg>
|
||||
Tweet
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
// Default: show both options
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size={size}
|
||||
onClick={handleTwitterShare}
|
||||
className={className}
|
||||
title="Share on Twitter/X"
|
||||
>
|
||||
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor" role="img">
|
||||
<title>X/Twitter</title>
|
||||
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
|
||||
</svg>
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size={size}
|
||||
onClick={handleCopyLink}
|
||||
className={className}
|
||||
title="Copy link"
|
||||
>
|
||||
<Icon icon={copied ? 'check' : 'link'} className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
90
pnpm-lock.yaml
generated
90
pnpm-lock.yaml
generated
|
|
@ -454,9 +454,6 @@ importers:
|
|||
'@oclif/plugin-plugins':
|
||||
specifier: ^5.4.36
|
||||
version: 5.4.54
|
||||
'@tpmjs/types':
|
||||
specifier: workspace:*
|
||||
version: link:../types
|
||||
cli-table3:
|
||||
specifier: ^0.6.5
|
||||
version: 0.6.5
|
||||
|
|
@ -1516,6 +1513,25 @@ importers:
|
|||
specifier: ^5.9.3
|
||||
version: 5.9.3
|
||||
|
||||
packages/tools/official/e2b:
|
||||
dependencies:
|
||||
'@e2b/code-interpreter':
|
||||
specifier: ^1.0.4
|
||||
version: 1.5.1
|
||||
ai:
|
||||
specifier: 6.0.23
|
||||
version: 6.0.23(zod@4.3.5)
|
||||
devDependencies:
|
||||
'@tpmjs/tsconfig':
|
||||
specifier: workspace:*
|
||||
version: link:../../../config/tsconfig
|
||||
tsup:
|
||||
specifier: ^8.5.1
|
||||
version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2)
|
||||
typescript:
|
||||
specifier: ^5.9.3
|
||||
version: 5.9.3
|
||||
|
||||
packages/tools/official/effect-size-suite:
|
||||
dependencies:
|
||||
ai:
|
||||
|
|
@ -4474,6 +4490,9 @@ packages:
|
|||
'@braintree/sanitize-url@7.1.1':
|
||||
resolution: {integrity: sha512-i1L7noDNxtFyL5DmZafWy1wRVhGehQmzZaz1HiN5e7iylJMSZR7ekOV7NsIqa5qBldlLrsKv4HbgFUVlQrz8Mw==}
|
||||
|
||||
'@bufbuild/protobuf@2.10.2':
|
||||
resolution: {integrity: sha512-uFsRXwIGyu+r6AMdz+XijIIZJYpoWeYzILt5yZ2d3mCjQrWUTVpVD9WL/jZAbvp+Ed04rOhrsk7FiTcEDseB5A==}
|
||||
|
||||
'@changesets/apply-release-plan@7.0.14':
|
||||
resolution: {integrity: sha512-ddBvf9PHdy2YY0OUiEl3TV78mH9sckndJR14QAt87KLEbIov81XO0q0QAmvooBxXlqRRP8I9B7XOzZwQG7JkWA==}
|
||||
|
||||
|
|
@ -4566,6 +4585,17 @@ packages:
|
|||
resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==}
|
||||
engines: {node: '>=0.1.90'}
|
||||
|
||||
'@connectrpc/connect-web@2.0.0-rc.3':
|
||||
resolution: {integrity: sha512-w88P8Lsn5CCsA7MFRl2e6oLY4J/5toiNtJns/YJrlyQaWOy3RO8pDgkz+iIkG98RPMhj2thuBvsd3Cn4DKKCkw==}
|
||||
peerDependencies:
|
||||
'@bufbuild/protobuf': ^2.2.0
|
||||
'@connectrpc/connect': 2.0.0-rc.3
|
||||
|
||||
'@connectrpc/connect@2.0.0-rc.3':
|
||||
resolution: {integrity: sha512-ARBt64yEyKbanyRETTjcjJuHr2YXorzQo0etyS5+P6oSeW8xEuzajA9g+zDnMcj1hlX2dQE93foIWQGfpru7gQ==}
|
||||
peerDependencies:
|
||||
'@bufbuild/protobuf': ^2.2.0
|
||||
|
||||
'@csstools/color-helpers@5.1.0':
|
||||
resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==}
|
||||
engines: {node: '>=18'}
|
||||
|
|
@ -4604,6 +4634,10 @@ packages:
|
|||
'@drizzle-team/brocli@0.10.2':
|
||||
resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==}
|
||||
|
||||
'@e2b/code-interpreter@1.5.1':
|
||||
resolution: {integrity: sha512-mkyKjAW2KN5Yt0R1I+1lbH3lo+W/g/1+C2lnwlitXk5wqi/g94SEO41XKdmDf5WWpKG3mnxWDR5d6S/lyjmMEw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@electric-sql/pglite-socket@0.0.6':
|
||||
resolution: {integrity: sha512-6RjmgzphIHIBA4NrMGJsjNWK4pu+bCWJlEWlwcxFTVY3WT86dFpKwbZaGWZV6C5Rd7sCk1Z0CI76QEfukLAUXw==}
|
||||
hasBin: true
|
||||
|
|
@ -7747,6 +7781,9 @@ packages:
|
|||
resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==}
|
||||
engines: {node: '>= 12'}
|
||||
|
||||
compare-versions@6.1.1:
|
||||
resolution: {integrity: sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg==}
|
||||
|
||||
concat-map@0.0.1:
|
||||
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
|
||||
|
||||
|
|
@ -8319,6 +8356,10 @@ packages:
|
|||
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
e2b@1.13.2:
|
||||
resolution: {integrity: sha512-m8acE/MzMAJo1A57DakR2X1Sl5Mt1tcQO2aJfygNaQHLXby/4xsjF0UeJUB70jF7xntiR41pAMbZEHnkzrT9tw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
eastasianwidth@0.2.0:
|
||||
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
|
||||
|
||||
|
|
@ -10518,6 +10559,12 @@ packages:
|
|||
zod:
|
||||
optional: true
|
||||
|
||||
openapi-fetch@0.9.8:
|
||||
resolution: {integrity: sha512-zM6elH0EZStD/gSiNlcPrzXcVQ/pZo3BDvC6CDwRDUt1dDzxlshpmQnpD6cZaJ39THaSmwVCxxRrPKNM1hHrDg==}
|
||||
|
||||
openapi-typescript-helpers@0.0.8:
|
||||
resolution: {integrity: sha512-1eNjQtbfNi5Z/kFhagDIaIRj6qqDzhjNJKz8cmMW0CVdGwT6e1GLbAfgI0d28VTJa1A8jz82jm/4dG8qNoNS8g==}
|
||||
|
||||
opener@1.5.2:
|
||||
resolution: {integrity: sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==}
|
||||
hasBin: true
|
||||
|
|
@ -10754,6 +10801,9 @@ packages:
|
|||
pkg-types@2.3.0:
|
||||
resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==}
|
||||
|
||||
platform@1.3.6:
|
||||
resolution: {integrity: sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==}
|
||||
|
||||
points-on-curve@0.2.0:
|
||||
resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==}
|
||||
|
||||
|
|
@ -13399,6 +13449,8 @@ snapshots:
|
|||
|
||||
'@braintree/sanitize-url@7.1.1': {}
|
||||
|
||||
'@bufbuild/protobuf@2.10.2': {}
|
||||
|
||||
'@changesets/apply-release-plan@7.0.14':
|
||||
dependencies:
|
||||
'@changesets/config': 3.1.2
|
||||
|
|
@ -13593,6 +13645,15 @@ snapshots:
|
|||
'@colors/colors@1.5.0':
|
||||
optional: true
|
||||
|
||||
'@connectrpc/connect-web@2.0.0-rc.3(@bufbuild/protobuf@2.10.2)(@connectrpc/connect@2.0.0-rc.3(@bufbuild/protobuf@2.10.2))':
|
||||
dependencies:
|
||||
'@bufbuild/protobuf': 2.10.2
|
||||
'@connectrpc/connect': 2.0.0-rc.3(@bufbuild/protobuf@2.10.2)
|
||||
|
||||
'@connectrpc/connect@2.0.0-rc.3(@bufbuild/protobuf@2.10.2)':
|
||||
dependencies:
|
||||
'@bufbuild/protobuf': 2.10.2
|
||||
|
||||
'@csstools/color-helpers@5.1.0': {}
|
||||
|
||||
'@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)':
|
||||
|
|
@ -13619,6 +13680,10 @@ snapshots:
|
|||
|
||||
'@drizzle-team/brocli@0.10.2': {}
|
||||
|
||||
'@e2b/code-interpreter@1.5.1':
|
||||
dependencies:
|
||||
e2b: 1.13.2
|
||||
|
||||
'@electric-sql/pglite-socket@0.0.6(@electric-sql/pglite@0.3.2)':
|
||||
dependencies:
|
||||
'@electric-sql/pglite': 0.3.2
|
||||
|
|
@ -16822,6 +16887,8 @@ snapshots:
|
|||
|
||||
commander@8.3.0: {}
|
||||
|
||||
compare-versions@6.1.1: {}
|
||||
|
||||
concat-map@0.0.1: {}
|
||||
|
||||
conf@13.1.0:
|
||||
|
|
@ -17353,6 +17420,15 @@ snapshots:
|
|||
es-errors: 1.3.0
|
||||
gopd: 1.2.0
|
||||
|
||||
e2b@1.13.2:
|
||||
dependencies:
|
||||
'@bufbuild/protobuf': 2.10.2
|
||||
'@connectrpc/connect': 2.0.0-rc.3(@bufbuild/protobuf@2.10.2)
|
||||
'@connectrpc/connect-web': 2.0.0-rc.3(@bufbuild/protobuf@2.10.2)(@connectrpc/connect@2.0.0-rc.3(@bufbuild/protobuf@2.10.2))
|
||||
compare-versions: 6.1.1
|
||||
openapi-fetch: 0.9.8
|
||||
platform: 1.3.6
|
||||
|
||||
eastasianwidth@0.2.0: {}
|
||||
|
||||
ecc-jsbn@0.1.2:
|
||||
|
|
@ -20093,6 +20169,12 @@ snapshots:
|
|||
ws: 8.19.0
|
||||
zod: 4.3.5
|
||||
|
||||
openapi-fetch@0.9.8:
|
||||
dependencies:
|
||||
openapi-typescript-helpers: 0.0.8
|
||||
|
||||
openapi-typescript-helpers@0.0.8: {}
|
||||
|
||||
opener@1.5.2: {}
|
||||
|
||||
optimist@0.6.1:
|
||||
|
|
@ -20356,6 +20438,8 @@ snapshots:
|
|||
exsolve: 1.0.8
|
||||
pathe: 2.0.3
|
||||
|
||||
platform@1.3.6: {}
|
||||
|
||||
points-on-curve@0.2.0: {}
|
||||
|
||||
points-on-path@0.2.1:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue