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:
Ajax Davis 2026-01-19 18:10:43 +10:00
parent 3c5c218207
commit ac6fdb87e7
4 changed files with 798 additions and 433 deletions

View file

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

View file

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

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