diff --git a/apps/web/src/app/(profile)/[username]/agents/[uid]/page.tsx b/apps/web/src/app/(profile)/[username]/agents/[uid]/page.tsx index e7563dc..f94de37 100644 --- a/apps/web/src/app/(profile)/[username]/agents/[uid]/page.tsx +++ b/apps/web/src/app/(profile)/[username]/agents/[uid]/page.tsx @@ -7,8 +7,10 @@ import Link from 'next/link'; import { notFound, useParams } from 'next/navigation'; import { useCallback, useEffect, useState } from 'react'; import { AppHeader } from '~/components/AppHeader'; -import { CloneButton } from '~/components/CloneButton'; +import { ForkButton } from '~/components/ForkButton'; +import { ForkedFromBadge } from '~/components/ForkedFromBadge'; import { LikeButton } from '~/components/LikeButton'; +import { useSession } from '~/lib/auth-client'; interface AgentTool { id: string; @@ -46,6 +48,7 @@ interface PublicAgent { systemPrompt: string | null; temperature: number; likeCount: number; + forkCount: number; toolCount: number; collectionCount: number; createdAt: string; @@ -57,6 +60,15 @@ interface PublicAgent { }; tools: AgentTool[]; collections: AgentCollection[]; + forkedFromId: string | null; + forkedFrom: { + id: string; + name: string; + uid: string; + user: { + username: string; + }; + } | null; } export default function PrettyAgentDetailPage(): React.ReactElement { @@ -64,11 +76,15 @@ export default function PrettyAgentDetailPage(): React.ReactElement { const rawUsername = params.username as string; const username = rawUsername.startsWith('@') ? rawUsername.slice(1) : rawUsername; const uid = params.uid as string; + const { data: session } = useSession(); const [agent, setAgent] = useState(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); + // Check if current user is the owner + const isOwner = session?.user?.id && agent?.createdBy?.id === session.user.id; + const fetchAgent = useCallback(async () => { try { const response = await fetch(`/api/public/users/${username}/agents/${uid}`); @@ -123,22 +139,29 @@ export default function PrettyAgentDetailPage(): React.ReactElement { {agent.description && (

{agent.description}

)} - - by @{agent.createdBy.username} - +
+ + by @{agent.createdBy.username} + + {agent.forkedFrom && ( + + )} +
- - - - + + {isOwner && ( + + + + )}
@@ -152,10 +175,21 @@ export default function PrettyAgentDetailPage(): React.ReactElement { {agent.collectionCount} collections + {agent.forkCount > 0 && ( + + + {agent.forkCount} forks + + )} Model: {agent.modelId} Temperature: {agent.temperature} + {/* Fork CTA for non-owners */} + {!isOwner && ( + + )} + {/* System Prompt */} {agent.systemPrompt && (
diff --git a/apps/web/src/app/(profile)/[username]/collections/[slug]/page.tsx b/apps/web/src/app/(profile)/[username]/collections/[slug]/page.tsx index 71814aa..aa27029 100644 --- a/apps/web/src/app/(profile)/[username]/collections/[slug]/page.tsx +++ b/apps/web/src/app/(profile)/[username]/collections/[slug]/page.tsx @@ -8,8 +8,10 @@ import Link from 'next/link'; import { notFound, useParams } from 'next/navigation'; import { useCallback, useEffect, useState } from 'react'; import { AppHeader } from '~/components/AppHeader'; -import { CloneButton } from '~/components/CloneButton'; +import { ForkButton } from '~/components/ForkButton'; +import { ForkedFromBadge } from '~/components/ForkedFromBadge'; import { LikeButton } from '~/components/LikeButton'; +import { useSession } from '~/lib/auth-client'; interface CollectionTool { id: string; @@ -35,6 +37,7 @@ interface PublicCollection { description: string | null; likeCount: number; toolCount: number; + forkCount: number; createdAt: string; createdBy: { id: string; @@ -43,6 +46,15 @@ interface PublicCollection { image: string | null; }; tools: CollectionTool[]; + forkedFromId: string | null; + forkedFrom: { + id: string; + name: string; + slug: string; + user: { + username: string; + }; + } | null; } function McpUrlSection({ username, slug }: { username: string; slug: string }) { @@ -163,11 +175,15 @@ export default function PrettyCollectionDetailPage(): React.ReactElement { const rawUsername = params.username as string; const username = rawUsername.startsWith('@') ? rawUsername.slice(1) : rawUsername; const slug = params.slug as string; + const { data: session } = useSession(); const [collection, setCollection] = useState(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); + // Check if current user is the owner + const isOwner = session?.user?.id && collection?.createdBy?.id === session.user.id; + const fetchCollection = useCallback(async () => { try { const response = await fetch(`/api/public/users/${username}/collections/${slug}`); @@ -219,12 +235,17 @@ export default function PrettyCollectionDetailPage(): React.ReactElement { {collection.description && (

{collection.description}

)} - - by @{collection.createdBy.username} - +
+ + by @{collection.createdBy.username} + + {collection.forkedFrom && ( + + )} +
- {collection.likeCount} likes + {collection.forkCount > 0 && ( + + + {collection.forkCount} forks + + )}
- {/* MCP Server URLs */} - + {/* MCP Server URLs - Only shown for owner */} + {isOwner ? ( + + ) : ( + + )} {/* Tools */} {collection.tools.length > 0 ? ( diff --git a/apps/web/src/app/api/agents/[id]/clone/route.ts b/apps/web/src/app/api/agents/[id]/clone/route.ts index 84753bf..a51210d 100644 --- a/apps/web/src/app/api/agents/[id]/clone/route.ts +++ b/apps/web/src/app/api/agents/[id]/clone/route.ts @@ -151,9 +151,10 @@ export async function POST(request: NextRequest, context: RouteContext) { } } - // Create the cloned agent with all its relationships - const clonedAgent = await prisma.$transaction(async (tx) => { - // Create the agent + // Create the forked agent with all its relationships + // NOTE: envVars and executorConfig are NOT copied - user must add their own + const forkedAgent = await prisma.$transaction(async (tx) => { + // Create the agent with fork reference const newAgent = await tx.agent.create({ data: { userId: session.user.id, @@ -166,11 +167,20 @@ export async function POST(request: NextRequest, context: RouteContext) { temperature: sourceAgent.temperature, maxToolCallsPerTurn: sourceAgent.maxToolCallsPerTurn, maxMessagesInContext: sourceAgent.maxMessagesInContext, - isPublic: false, // Cloned agents start as private + isPublic: false, // Forked agents start as private likeCount: 1, // Start with 1 like (from owner) + forkedFromId: sourceAgent.id, // Track fork origin + // NOTE: envVars is intentionally NOT copied - user adds their own API keys + // NOTE: executorConfig is intentionally NOT copied - user configures their own }, }); + // Increment fork count on source agent + await tx.agent.update({ + where: { id: sourceAgent.id }, + data: { forkCount: { increment: 1 } }, + }); + // Auto-like the agent await tx.agentLike.create({ data: { @@ -215,24 +225,28 @@ export async function POST(request: NextRequest, context: RouteContext) { return newAgent; }); - // Log activity + // Log activity as FORK logActivity({ userId: session.user.id, - type: 'AGENT_CLONED', - targetName: clonedAgent.name, + type: 'AGENT_FORKED', + targetName: forkedAgent.name, targetType: 'agent', - agentId: clonedAgent.id, - metadata: { sourceAgentId: sourceAgent.id }, + agentId: forkedAgent.id, + metadata: { + sourceAgentId: sourceAgent.id, + sourceAgentName: sourceAgent.name, + }, }); return apiSuccess( { - id: clonedAgent.id, - uid: clonedAgent.uid, - name: clonedAgent.name, - description: clonedAgent.description, - isPublic: clonedAgent.isPublic, - createdAt: clonedAgent.createdAt, + id: forkedAgent.id, + uid: forkedAgent.uid, + name: forkedAgent.name, + description: forkedAgent.description, + isPublic: forkedAgent.isPublic, + forkedFromId: forkedAgent.forkedFromId, + createdAt: forkedAgent.createdAt, }, { requestId, status: 201 } ); diff --git a/apps/web/src/app/api/agents/[id]/conversation/[conversationId]/route.ts b/apps/web/src/app/api/agents/[id]/conversation/[conversationId]/route.ts index 6b4b448..f40ffba 100644 --- a/apps/web/src/app/api/agents/[id]/conversation/[conversationId]/route.ts +++ b/apps/web/src/app/api/agents/[id]/conversation/[conversationId]/route.ts @@ -122,6 +122,19 @@ export async function POST(request: NextRequest, context: RouteContext): Promise return NextResponse.json({ success: false, error: 'Agent not found' }, { status: 404 }); } + // Owner-only enforcement: Only the agent owner can chat with the agent + if (authResult.userId !== agent.userId) { + return NextResponse.json( + { + success: false, + error: + 'Fork this agent to use it. Only the agent owner can chat with agents. ' + + 'Visit the agent page to fork it to your account.', + }, + { status: 403 } + ); + } + // Map provider to expected key name format const providerKeyNames: Record = { OPENAI: 'OPENAI_API_KEY', diff --git a/apps/web/src/app/api/agents/[id]/fork-status/route.ts b/apps/web/src/app/api/agents/[id]/fork-status/route.ts new file mode 100644 index 0000000..63b1020 --- /dev/null +++ b/apps/web/src/app/api/agents/[id]/fork-status/route.ts @@ -0,0 +1,88 @@ +import { prisma } from '@tpmjs/db'; +import { AGENT_LIMITS } from '@tpmjs/types/agent'; +import { headers } from 'next/headers'; +import type { NextRequest } from 'next/server'; + +import { apiNotFound, apiSuccess, apiUnauthorized } from '~/lib/api-response'; +import { auth } from '~/lib/auth'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +type RouteContext = { + params: Promise<{ id: string }>; +}; + +/** + * GET /api/agents/[id]/fork-status + * Check if the current user has forked this agent + */ +export async function GET(_request: NextRequest, context: RouteContext) { + const requestId = crypto.randomUUID(); + + const session = await auth.api.getSession({ headers: await headers() }); + if (!session?.user?.id) { + return apiUnauthorized('Authentication required', requestId); + } + + const { id } = await context.params; + + // Get the source agent + const agent = await prisma.agent.findUnique({ + where: { id }, + select: { id: true, userId: true, isPublic: true }, + }); + + if (!agent) { + return apiNotFound('Agent', requestId); + } + + const isOwner = agent.userId === session.user.id; + + // If owner, they can't fork their own agent + if (isOwner) { + return apiSuccess( + { + hasFork: false, + fork: null, + isOwner: true, + canFork: false, + }, + { requestId } + ); + } + + // Check if user already has a fork of this agent + const existingFork = await prisma.agent.findFirst({ + where: { + userId: session.user.id, + forkedFromId: id, + }, + select: { id: true, uid: true, name: true }, + }); + + // Check if user can fork (within limits) + let canFork = false; + if (!existingFork && agent.isPublic) { + const existingCount = await prisma.agent.count({ + where: { userId: session.user.id }, + }); + canFork = existingCount < AGENT_LIMITS.MAX_AGENTS_PER_USER; + } + + return apiSuccess( + { + hasFork: !!existingFork, + fork: existingFork + ? { + id: existingFork.id, + uid: existingFork.uid, + name: existingFork.name, + } + : null, + isOwner: false, + canFork, + }, + { requestId } + ); +} diff --git a/apps/web/src/app/api/collections/[id]/clone/route.ts b/apps/web/src/app/api/collections/[id]/clone/route.ts index 3c5480f..f3dc58b 100644 --- a/apps/web/src/app/api/collections/[id]/clone/route.ts +++ b/apps/web/src/app/api/collections/[id]/clone/route.ts @@ -1,5 +1,5 @@ import { prisma } from '@tpmjs/db'; -import { COLLECTION_LIMITS, CloneCollectionSchema } from '@tpmjs/types/collection'; +import { CloneCollectionSchema, COLLECTION_LIMITS } from '@tpmjs/types/collection'; import { headers } from 'next/headers'; import type { NextRequest } from 'next/server'; @@ -135,20 +135,30 @@ export async function POST(request: NextRequest, context: RouteContext) { const name = customName || `${sourceCollection.name} (copy)`; const slug = await generateUniqueSlug(session.user.id, name); - // Create the cloned collection with all its tools - const clonedCollection = await prisma.$transaction(async (tx) => { - // Create the collection + // Create the forked collection with all its tools + // NOTE: envVars and executorConfig are NOT copied - user must add their own + const forkedCollection = await prisma.$transaction(async (tx) => { + // Create the collection with fork reference const newCollection = await tx.collection.create({ data: { userId: session.user.id, name, slug, description: sourceCollection.description, - isPublic: false, // Cloned collections start as private + isPublic: false, // Forked collections start as private likeCount: 1, // Start with 1 like (from owner) + forkedFromId: sourceCollection.id, // Track fork origin + // NOTE: envVars is intentionally NOT copied - user adds their own API keys + // NOTE: executorConfig is intentionally NOT copied - user configures their own }, }); + // Increment fork count on source collection + await tx.collection.update({ + where: { id: sourceCollection.id }, + data: { forkCount: { increment: 1 } }, + }); + // Auto-like the collection await tx.collectionLike.create({ data: { @@ -172,25 +182,29 @@ export async function POST(request: NextRequest, context: RouteContext) { return newCollection; }); - // Log activity + // Log activity as FORK logActivity({ userId: session.user.id, - type: 'COLLECTION_CLONED', - targetName: clonedCollection.name, + type: 'COLLECTION_FORKED', + targetName: forkedCollection.name, targetType: 'collection', - collectionId: clonedCollection.id, - metadata: { sourceCollectionId: sourceCollection.id }, + collectionId: forkedCollection.id, + metadata: { + sourceCollectionId: sourceCollection.id, + sourceCollectionName: sourceCollection.name, + }, }); return apiSuccess( { - id: clonedCollection.id, - name: clonedCollection.name, - slug: clonedCollection.slug, - description: clonedCollection.description, - isPublic: clonedCollection.isPublic, + id: forkedCollection.id, + name: forkedCollection.name, + slug: forkedCollection.slug, + description: forkedCollection.description, + isPublic: forkedCollection.isPublic, + forkedFromId: forkedCollection.forkedFromId, toolCount: sourceCollection.tools.length, - createdAt: clonedCollection.createdAt, + createdAt: forkedCollection.createdAt, }, { requestId, status: 201 } ); diff --git a/apps/web/src/app/api/collections/[id]/fork-status/route.ts b/apps/web/src/app/api/collections/[id]/fork-status/route.ts new file mode 100644 index 0000000..b3fe730 --- /dev/null +++ b/apps/web/src/app/api/collections/[id]/fork-status/route.ts @@ -0,0 +1,88 @@ +import { prisma } from '@tpmjs/db'; +import { COLLECTION_LIMITS } from '@tpmjs/types/collection'; +import { headers } from 'next/headers'; +import type { NextRequest } from 'next/server'; + +import { apiNotFound, apiSuccess, apiUnauthorized } from '~/lib/api-response'; +import { auth } from '~/lib/auth'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +type RouteContext = { + params: Promise<{ id: string }>; +}; + +/** + * GET /api/collections/[id]/fork-status + * Check if the current user has forked this collection + */ +export async function GET(_request: NextRequest, context: RouteContext) { + const requestId = crypto.randomUUID(); + + const session = await auth.api.getSession({ headers: await headers() }); + if (!session?.user?.id) { + return apiUnauthorized('Authentication required', requestId); + } + + const { id } = await context.params; + + // Get the source collection + const collection = await prisma.collection.findUnique({ + where: { id }, + select: { id: true, userId: true, isPublic: true }, + }); + + if (!collection) { + return apiNotFound('Collection', requestId); + } + + const isOwner = collection.userId === session.user.id; + + // If owner, they can't fork their own collection + if (isOwner) { + return apiSuccess( + { + hasFork: false, + fork: null, + isOwner: true, + canFork: false, + }, + { requestId } + ); + } + + // Check if user already has a fork of this collection + const existingFork = await prisma.collection.findFirst({ + where: { + userId: session.user.id, + forkedFromId: id, + }, + select: { id: true, slug: true, name: true }, + }); + + // Check if user can fork (within limits) + let canFork = false; + if (!existingFork && collection.isPublic) { + const existingCount = await prisma.collection.count({ + where: { userId: session.user.id }, + }); + canFork = existingCount < COLLECTION_LIMITS.MAX_COLLECTIONS_PER_USER; + } + + return apiSuccess( + { + hasFork: !!existingFork, + fork: existingFork + ? { + id: existingFork.id, + slug: existingFork.slug, + name: existingFork.name, + } + : null, + isOwner: false, + canFork, + }, + { requestId } + ); +} diff --git a/apps/web/src/app/api/mcp/[username]/[slug]/[transport]/route.ts b/apps/web/src/app/api/mcp/[username]/[slug]/[transport]/route.ts index 05bbeb8..36457cf 100644 --- a/apps/web/src/app/api/mcp/[username]/[slug]/[transport]/route.ts +++ b/apps/web/src/app/api/mcp/[username]/[slug]/[transport]/route.ts @@ -52,7 +52,7 @@ async function getPublicCollectionByUsernameAndSlug(username: string, slug: stri isPublic: true, user: { username }, }, - select: { id: true, name: true, description: true }, + select: { id: true, name: true, description: true, userId: true }, }), DB_TIMEOUT_MS, `Database query timed out after ${DB_TIMEOUT_MS}ms` @@ -289,6 +289,23 @@ export async function POST(request: NextRequest, context: RouteContext): Promise ); } + // Owner-only enforcement: Only the collection owner can execute tools via MCP + if (authResult.userId !== collection.userId) { + return NextResponse.json( + { + jsonrpc: '2.0', + error: { + code: -32403, + message: + 'Fork this collection to use it. Only the collection owner can execute tools via MCP. ' + + 'Visit the collection page to fork it to your account.', + }, + id: null, + }, + { status: 403 } + ); + } + let response: Response; if (transport === 'sse') { response = await handleSseTransport(request, collection.id, collection.name); diff --git a/apps/web/src/app/api/public/users/[username]/agents/[uid]/route.ts b/apps/web/src/app/api/public/users/[username]/agents/[uid]/route.ts index e08ab24..a74a059 100644 --- a/apps/web/src/app/api/public/users/[username]/agents/[uid]/route.ts +++ b/apps/web/src/app/api/public/users/[username]/agents/[uid]/route.ts @@ -71,6 +71,16 @@ export async function GET(_request: NextRequest, context: RouteContext) { orderBy: { position: 'asc' }, take: 20, }, + forkedFrom: { + select: { + id: true, + name: true, + uid: true, + user: { + select: { username: true }, + }, + }, + }, _count: { select: { tools: true, collections: true }, }, @@ -97,6 +107,7 @@ export async function GET(_request: NextRequest, context: RouteContext) { systemPrompt: agent.systemPrompt, temperature: agent.temperature, likeCount: agent.likeCount, + forkCount: agent.forkCount, toolCount: agent._count.tools, collectionCount: agent._count.collections, createdAt: agent.createdAt.toISOString(), @@ -122,6 +133,8 @@ export async function GET(_request: NextRequest, context: RouteContext) { toolCount: ac.collection._count.tools, }, })), + forkedFromId: agent.forkedFromId, + forkedFrom: agent.forkedFrom, }, { requestId } ); diff --git a/apps/web/src/app/api/public/users/[username]/collections/[slug]/route.ts b/apps/web/src/app/api/public/users/[username]/collections/[slug]/route.ts index 23e1626..6137329 100644 --- a/apps/web/src/app/api/public/users/[username]/collections/[slug]/route.ts +++ b/apps/web/src/app/api/public/users/[username]/collections/[slug]/route.ts @@ -58,6 +58,16 @@ export async function GET(_request: NextRequest, context: RouteContext) { orderBy: { position: 'asc' }, take: 100, }, + forkedFrom: { + select: { + id: true, + name: true, + slug: true, + user: { + select: { username: true }, + }, + }, + }, _count: { select: { tools: true }, }, @@ -80,6 +90,7 @@ export async function GET(_request: NextRequest, context: RouteContext) { name: collection.name, description: collection.description, likeCount: collection.likeCount, + forkCount: collection.forkCount, toolCount: collection._count.tools, createdAt: collection.createdAt.toISOString(), createdBy: { @@ -95,6 +106,8 @@ export async function GET(_request: NextRequest, context: RouteContext) { note: ct.note, tool: ct.tool, })), + forkedFromId: collection.forkedFromId, + forkedFrom: collection.forkedFrom, }, { requestId } ); diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx index f7ea854..60dfec9 100644 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/page.tsx @@ -179,7 +179,9 @@ export default async function HomePage(): Promise { {/* Generator Highlight Box */}
-
+

Start with Our Package Generator @@ -208,21 +210,27 @@ export default async function HomePage(): Promise {
-
🚀
+

Quick Setup

Add one keyword to package.json and publish to NPM

-
+

Auto Discovery

Your tool appears on tpmjs.com within 15 minutes

-
📊
+

Quality Metrics

Automatic scoring based on docs, downloads, and stars diff --git a/apps/web/src/components/AppHeader.tsx b/apps/web/src/components/AppHeader.tsx index 9a53909..bbfe3a7 100644 --- a/apps/web/src/components/AppHeader.tsx +++ b/apps/web/src/components/AppHeader.tsx @@ -23,27 +23,80 @@ interface NavDropdownProps { function NavDropdown({ label, items }: NavDropdownProps): React.ReactElement { const [isOpen, setIsOpen] = useState(false); + const [focusedIndex, setFocusedIndex] = useState(-1); const dropdownRef = useRef(null); + const menuRef = useRef(null); + const buttonRef = useRef(null); useEffect(() => { function handleClickOutside(event: MouseEvent) { if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { setIsOpen(false); + setFocusedIndex(-1); } } document.addEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside); }, []); + // Focus menu item when focusedIndex changes + useEffect(() => { + if (isOpen && focusedIndex >= 0 && menuRef.current) { + const menuItems = menuRef.current.querySelectorAll('[role="menuitem"]'); + menuItems[focusedIndex]?.focus(); + } + }, [focusedIndex, isOpen]); + + const handleKeyDown = (event: React.KeyboardEvent) => { + switch (event.key) { + case 'Escape': + setIsOpen(false); + setFocusedIndex(-1); + buttonRef.current?.focus(); + event.preventDefault(); + break; + case 'ArrowDown': + event.preventDefault(); + if (!isOpen) { + setIsOpen(true); + setFocusedIndex(0); + } else { + setFocusedIndex((prev) => (prev < items.length - 1 ? prev + 1 : 0)); + } + break; + case 'ArrowUp': + event.preventDefault(); + if (isOpen) { + setFocusedIndex((prev) => (prev > 0 ? prev - 1 : items.length - 1)); + } + break; + case 'Tab': + if (isOpen) { + setIsOpen(false); + setFocusedIndex(-1); + } + break; + } + }; + + const handleItemClick = () => { + setIsOpen(false); + setFocusedIndex(-1); + }; + return ( -

+
{isOpen && ( -
- {items.map((item) => +
+ {items.map((item, index) => item.external ? ( setIsOpen(false)} + role="menuitem" + tabIndex={focusedIndex === index ? 0 : -1} + className="flex items-center justify-between px-4 py-2 text-sm text-foreground hover:bg-surface focus:bg-surface focus:outline-none transition-colors" + onClick={handleItemClick} >
{item.label}
@@ -76,8 +136,10 @@ function NavDropdown({ label, items }: NavDropdownProps): React.ReactElement { setIsOpen(false)} + role="menuitem" + tabIndex={focusedIndex === index ? 0 : -1} + className="block px-4 py-2 text-sm text-foreground hover:bg-surface focus:bg-surface focus:outline-none transition-colors" + onClick={handleItemClick} >
{item.label}
{item.description && ( diff --git a/apps/web/src/components/ForkButton.tsx b/apps/web/src/components/ForkButton.tsx new file mode 100644 index 0000000..531fd9d --- /dev/null +++ b/apps/web/src/components/ForkButton.tsx @@ -0,0 +1,312 @@ +'use client'; + +import { Button } from '@tpmjs/ui/Button/Button'; +import { Icon } from '@tpmjs/ui/Icon/Icon'; +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; +import { useEffect, useState } from 'react'; +import { useSession } from '~/lib/auth-client'; + +interface ForkStatus { + hasFork: boolean; + fork: { id: string; slug?: string; uid?: string; name: string } | null; + isOwner: boolean; + canFork: boolean; +} + +interface ForkButtonProps { + type: 'agent' | 'collection'; + sourceId: string; + sourceName: string; + className?: string; + /** Show full-width variant with description text */ + variant?: 'compact' | 'full'; +} + +export function ForkButton({ + type, + sourceId, + sourceName, + className, + variant = 'compact', +}: ForkButtonProps): React.ReactElement { + void sourceName; + const { data: session } = useSession(); + const router = useRouter(); + const [isForking, setIsForking] = useState(false); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [forkStatus, setForkStatus] = useState(null); + + // Fetch fork status on mount + useEffect(() => { + if (!session?.user) { + setIsLoading(false); + return; + } + + async function checkForkStatus() { + try { + const endpoint = + type === 'agent' + ? `/api/agents/${sourceId}/fork-status` + : `/api/collections/${sourceId}/fork-status`; + + const response = await fetch(endpoint); + const data = await response.json(); + + if (data.success) { + setForkStatus(data.data); + } + } catch { + // Silently fail - will show fork button as fallback + } finally { + setIsLoading(false); + } + } + + checkForkStatus(); + }, [session?.user, sourceId, type]); + + async function handleFork() { + if (!session?.user) { + router.push(`/sign-in?redirect=${encodeURIComponent(window.location.pathname)}`); + return; + } + + setIsForking(true); + setError(null); + + try { + const endpoint = + type === 'agent' ? `/api/agents/${sourceId}/clone` : `/api/collections/${sourceId}/clone`; + + const response = await fetch(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }); + + const data = await response.json(); + + if (data.success) { + // Redirect to the forked item in dashboard + if (type === 'agent') { + router.push(`/dashboard/agents/${data.data.id}`); + } else { + router.push(`/dashboard/collections/${data.data.id}`); + } + } else { + setError(data.error?.message || 'Failed to fork'); + } + } catch { + setError('Failed to fork'); + } finally { + setIsForking(false); + } + } + + // Not logged in - show sign in prompt + if (!session?.user) { + if (variant === 'full') { + return ( +
+
+
+ +
+
+

Fork to Use

+

+ Sign in to fork this {type} to your account +

+
+
+ + + +
+ ); + } + return ( +
+ + + +
+ ); + } + + // Loading state + if (isLoading) { + return ( +
+ +
+ ); + } + + // Owner - show "Your" badge + if (forkStatus?.isOwner) { + if (variant === 'full') { + return ( +
+
+
+ +
+
+

+ Your {type === 'agent' ? 'Agent' : 'Collection'} +

+

You own this {type}

+
+
+
+ ); + } + return ( +
+ + + Your {type === 'agent' ? 'Agent' : 'Collection'} + +
+ ); + } + + // Already forked - show link to fork + if (forkStatus?.hasFork && forkStatus.fork) { + const forkUrl = + type === 'agent' + ? `/dashboard/agents/${forkStatus.fork.id}` + : `/dashboard/collections/${forkStatus.fork.id}`; + + if (variant === 'full') { + return ( +
+
+
+ +
+
+

Already Forked

+

+ You have a fork: “{forkStatus.fork.name}” +

+
+
+ + + +
+ ); + } + return ( +
+ + + +
+ ); + } + + // Can't fork (over limit) + if (!forkStatus?.canFork) { + if (variant === 'full') { + return ( +
+
+
+ +
+
+

Limit Reached

+

+ You've reached the maximum number of{' '} + {type === 'agent' ? 'agents' : 'collections'} +

+
+
+
+ ); + } + return ( +
+ +
+ ); + } + + // Can fork - show fork button + if (variant === 'full') { + return ( +
+
+
+ +
+
+

Fork to Use

+

+ Fork this {type} to your account to use it with your own API keys +

+
+
+ + {error &&

{error}

} +
+ ); + } + + return ( +
+ + {error &&

{error}

} +
+ ); +} diff --git a/apps/web/src/components/ForkedFromBadge.tsx b/apps/web/src/components/ForkedFromBadge.tsx new file mode 100644 index 0000000..a63bb28 --- /dev/null +++ b/apps/web/src/components/ForkedFromBadge.tsx @@ -0,0 +1,61 @@ +'use client'; + +import { Icon } from '@tpmjs/ui/Icon/Icon'; +import Link from 'next/link'; + +interface ForkedFromInfo { + id: string; + name: string; + slug?: string; + uid?: string; + user?: { + username: string; + }; +} + +interface ForkedFromBadgeProps { + type: 'agent' | 'collection'; + forkedFrom: ForkedFromInfo | null; + className?: string; +} + +export function ForkedFromBadge({ + type, + forkedFrom, + className, +}: ForkedFromBadgeProps): React.ReactElement | null { + if (!forkedFrom) { + return null; + } + + // Build the link to the original + let href: string; + if (forkedFrom.user?.username) { + if (type === 'agent' && forkedFrom.uid) { + href = `/${forkedFrom.user.username}/agents/${forkedFrom.uid}`; + } else if (type === 'collection' && forkedFrom.slug) { + href = `/${forkedFrom.user.username}/collections/${forkedFrom.slug}`; + } else { + // Fallback to ID-based URL + href = type === 'agent' ? `/agents/${forkedFrom.id}` : `/collections/${forkedFrom.id}`; + } + } else { + // No username available, use ID-based URL + href = type === 'agent' ? `/agents/${forkedFrom.id}` : `/collections/${forkedFrom.id}`; + } + + return ( + + + + Forked from{' '} + + {forkedFrom.name} + + + + ); +} diff --git a/apps/web/src/components/home/HeroSection.tsx b/apps/web/src/components/home/HeroSection.tsx index 69858e2..f728dc1 100644 --- a/apps/web/src/components/home/HeroSection.tsx +++ b/apps/web/src/components/home/HeroSection.tsx @@ -90,7 +90,10 @@ export function HeroSection({ stats }: HeroSectionProps): React.ReactElement {
{/* Command Line Prompt */} -
+ @@ -100,6 +103,7 @@ export function HeroSection({ stats }: HeroSectionProps): React.ReactElement { onChange={(e) => setSearchQuery(e.target.value)} onKeyDown={handleKeyDown} placeholder="search tools..." + aria-label="Search tools" className="brutalist-border h-16 md:h-20 pl-14 pr-36 md:pr-40 text-lg md:text-xl font-mono placeholder:text-foreground-tertiary placeholder:uppercase focus:ring-4 focus:ring-brutalist-accent focus:ring-offset-0 bg-background" style={{ borderRadius: 0 }} /> @@ -126,7 +130,10 @@ export function HeroSection({ stats }: HeroSectionProps): React.ReactElement {
{/* Scroll Indicator */} -
+