From eaaa40f130790d57ff11eb6d7caf011ffd7499a9 Mon Sep 17 00:00:00 2001 From: Ajax Davis Date: Thu, 8 Jan 2026 20:47:08 +1000 Subject: [PATCH] feat: add usernames, pretty URLs, cloning, and sharing docs ## Usernames - Add username field to User model (unique, URL-friendly) - Add slug field to Collection model (unique per user) - Update sign-up flow to require username with availability checking - Create username check API endpoint ## Pretty URLs - Add route group (profile) with pretty URL pages: - /{username} - User profile - /{username}/agents/{uid} - Agent detail - /{username}/agents/{uid}/chat - Chat redirect - /{username}/collections/{slug} - Collection detail - Add client-side redirects from old /agents/[id] and /collections/[id] URLs ## Cloning - Add clone API endpoints for agents and collections - Create CloneButton component - Add AGENT_CLONED and COLLECTION_CLONED activity types ## Documentation - Add /docs/sharing page explaining all shareable URLs - Document cloning functionality and visibility settings Co-Authored-By: Claude --- apps/web/src/app/(auth)/sign-up/page.tsx | 202 +++++- .../[username]/agents/[uid]/chat/page.tsx | 63 ++ .../[username]/agents/[uid]/page.tsx | 230 ++++++ .../[username]/collections/[slug]/page.tsx | 191 +++++ .../web/src/app/(profile)/[username]/page.tsx | 185 +++++ apps/web/src/app/agents/[id]/page.tsx | 11 +- .../src/app/api/agents/[id]/clone/route.ts | 243 +++++++ .../app/api/collections/[id]/clone/route.ts | 201 ++++++ apps/web/src/app/api/collections/route.ts | 51 ++ .../src/app/api/public/agents/[id]/route.ts | 1 + .../app/api/public/collections/[id]/route.ts | 2 + .../users/[username]/agents/[uid]/route.ts | 132 ++++ .../[username]/collections/[slug]/route.ts | 105 +++ .../app/api/public/users/[username]/route.ts | 94 +++ apps/web/src/app/api/user/profile/route.ts | 147 ++++ .../src/app/api/user/username/check/route.ts | 101 +++ apps/web/src/app/collections/[id]/page.tsx | 12 +- apps/web/src/app/docs/page.tsx | 19 + apps/web/src/app/docs/sharing/page.tsx | 654 ++++++++++++++++++ apps/web/src/components/CloneButton.tsx | 85 +++ apps/web/src/lib/activity.ts | 4 + packages/db/prisma/schema.prisma | 10 +- .../db/scripts/populate-usernames-slugs.ts | 143 ++++ packages/types/package.json | 4 + packages/types/src/agent.ts | 15 + packages/types/src/collection.ts | 15 + packages/types/src/user.ts | 146 ++++ packages/types/tsup.config.ts | 9 +- 28 files changed, 3061 insertions(+), 14 deletions(-) create mode 100644 apps/web/src/app/(profile)/[username]/agents/[uid]/chat/page.tsx create mode 100644 apps/web/src/app/(profile)/[username]/agents/[uid]/page.tsx create mode 100644 apps/web/src/app/(profile)/[username]/collections/[slug]/page.tsx create mode 100644 apps/web/src/app/(profile)/[username]/page.tsx create mode 100644 apps/web/src/app/api/agents/[id]/clone/route.ts create mode 100644 apps/web/src/app/api/collections/[id]/clone/route.ts create mode 100644 apps/web/src/app/api/public/users/[username]/agents/[uid]/route.ts create mode 100644 apps/web/src/app/api/public/users/[username]/collections/[slug]/route.ts create mode 100644 apps/web/src/app/api/public/users/[username]/route.ts create mode 100644 apps/web/src/app/api/user/profile/route.ts create mode 100644 apps/web/src/app/api/user/username/check/route.ts create mode 100644 apps/web/src/app/docs/sharing/page.tsx create mode 100644 apps/web/src/components/CloneButton.tsx create mode 100644 packages/db/scripts/populate-usernames-slugs.ts create mode 100644 packages/types/src/user.ts diff --git a/apps/web/src/app/(auth)/sign-up/page.tsx b/apps/web/src/app/(auth)/sign-up/page.tsx index 31a2e0e..6ea7ae0 100644 --- a/apps/web/src/app/(auth)/sign-up/page.tsx +++ b/apps/web/src/app/(auth)/sign-up/page.tsx @@ -1,37 +1,133 @@ 'use client'; import { signUp } from '@/lib/auth-client'; +import { suggestUsername } from '@tpmjs/types/user'; import Link from 'next/link'; -import { useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; + +interface UsernameCheckResult { + available: boolean; + reason?: string; +} export default function SignUpPage() { const [name, setName] = useState(''); + const [username, setUsername] = useState(''); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [error, setError] = useState(''); const [loading, setLoading] = useState(false); + // Username availability state + const [usernameCheck, setUsernameCheck] = useState(null); + const [checkingUsername, setCheckingUsername] = useState(false); + const [usernameEdited, setUsernameEdited] = useState(false); + + // Auto-generate username from name (only if user hasn't manually edited it) + useEffect(() => { + if (!usernameEdited && name.length >= 2) { + const suggested = suggestUsername(name); + if (suggested.length >= 3) { + setUsername(suggested); + } + } + }, [name, usernameEdited]); + + // Debounced username availability check + const checkUsernameAvailability = useCallback(async (usernameToCheck: string) => { + if (usernameToCheck.length < 3) { + setUsernameCheck({ available: false, reason: 'Username must be at least 3 characters' }); + return; + } + + setCheckingUsername(true); + try { + const response = await fetch( + `/api/user/username/check?username=${encodeURIComponent(usernameToCheck)}` + ); + const data = await response.json(); + + if (data.success) { + setUsernameCheck(data.data); + } else { + setUsernameCheck({ available: false, reason: 'Failed to check availability' }); + } + } catch { + setUsernameCheck({ available: false, reason: 'Failed to check availability' }); + } finally { + setCheckingUsername(false); + } + }, []); + + // Debounce the username check + useEffect(() => { + if (username.length < 3) { + setUsernameCheck(null); + return; + } + + const timeout = setTimeout(() => { + checkUsernameAvailability(username); + }, 300); + + return () => clearTimeout(timeout); + }, [username, checkUsernameAvailability]); + + function handleUsernameChange(e: React.ChangeEvent) { + setUsernameEdited(true); + // Force lowercase and remove invalid characters + const value = e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ''); + setUsername(value); + } + async function handleSubmit(e: React.FormEvent) { e.preventDefault(); setError(''); + + // Validate username + if (!username || username.length < 3) { + setError('Please choose a valid username (at least 3 characters)'); + return; + } + + if (usernameCheck && !usernameCheck.available) { + setError(usernameCheck.reason || 'Please choose a different username'); + return; + } + setLoading(true); try { - const { data, error } = await signUp.email({ + const { data, error: signUpError } = await signUp.email({ name, email, password, }); - if (error) { - console.error('Sign up error:', error); - setError(error.message || 'Failed to create account'); + if (signUpError) { + console.error('Sign up error:', signUpError); + setError(signUpError.message || 'Failed to create account'); setLoading(false); return; } if (data) { - // Successfully signed up - redirect to verify email page + // Account created - now set the username + try { + const profileResponse = await fetch('/api/user/profile', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username }), + }); + + if (!profileResponse.ok) { + console.warn('Failed to set username, user can set it later'); + } + } catch { + console.warn('Failed to set username, user can set it later'); + } + + // Redirect to verify email page window.location.href = '/verify-email'; } } catch { @@ -69,6 +165,98 @@ export default function SignUpPage() { /> +
+ +
+
+ @ +
+ + {/* Status indicator */} +
+ {checkingUsername && ( + + + + + )} + {!checkingUsername && usernameCheck?.available && ( + + + + )} + {!checkingUsername && + usernameCheck && + !usernameCheck.available && + username.length >= 3 && ( + + + + )} +
+
+ {/* Username availability message */} + {username.length >= 3 && usernameCheck && !usernameCheck.available && ( +

{usernameCheck.reason}

+ )} + {username.length >= 3 && usernameCheck?.available && ( +

Username available

+ )} + {username && ( +

+ Your profile: tpmjs.com/@{username} +

+ )} +
+
+ + + {/* Stats */} +
+ + + {agent.toolCount} tools + + + + {agent.collectionCount} collections + + Model: {agent.modelId} + Temperature: {agent.temperature} +
+ + {/* System Prompt */} + {agent.systemPrompt && ( +
+

System Prompt

+
+
+                    {agent.systemPrompt}
+                  
+
+
+ )} + + {/* Tools */} + {agent.tools.length > 0 && ( +
+

Tools

+
+ {agent.tools.map((at) => ( + +
+
+

{at.tool.name}

+

+ {at.tool.description} +

+
+ + {at.tool.package.category} + + + {at.tool.package.npmPackageName} + +
+
+
+ + ))} +
+
+ )} + + {/* Collections */} + {agent.collections.length > 0 && ( +
+

Collections

+
+ {agent.collections.map((ac) => ( +
+

{ac.collection.name}

+ {ac.collection.description && ( +

+ {ac.collection.description} +

+ )} + + {ac.collection.toolCount} tools + +
+ ))} +
+
+ )} + + ) : null} + + + ); +} diff --git a/apps/web/src/app/(profile)/[username]/collections/[slug]/page.tsx b/apps/web/src/app/(profile)/[username]/collections/[slug]/page.tsx new file mode 100644 index 0000000..8faee68 --- /dev/null +++ b/apps/web/src/app/(profile)/[username]/collections/[slug]/page.tsx @@ -0,0 +1,191 @@ +'use client'; + +import { Badge } from '@tpmjs/ui/Badge/Badge'; +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 { CloneButton } from '~/components/CloneButton'; +import { LikeButton } from '~/components/LikeButton'; + +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 PublicCollection { + id: string; + slug: string; + name: string; + description: string | null; + likeCount: number; + toolCount: number; + createdAt: string; + createdBy: { + id: string; + username: string; + name: string; + image: string | null; + }; + tools: CollectionTool[]; +} + +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 [collection, setCollection] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + 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(); + + 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') { + notFound(); + } + + return ( +
+ + +
+ {isLoading ? ( +
+ +
+ ) : error ? ( +
+

{error}

+
+ ) : collection ? ( +
+ {/* Collection Header */} +
+
+

{collection.name}

+ {collection.description && ( +

{collection.description}

+ )} + + by @{collection.createdBy.username} + +
+
+ + +
+
+ + {/* Stats */} +
+ + + {collection.toolCount} tools + + + + {collection.likeCount} likes + +
+ + {/* Tools */} + {collection.tools.length > 0 ? ( +
+

Tools in Collection

+
+ {collection.tools.map((ct) => ( + +
+
+

{ct.tool.name}

+

+ {ct.tool.description} +

+ {ct.note && ( +

+ Note: {ct.note} +

+ )} +
+ + {ct.tool.package.category} + + + {ct.tool.package.npmPackageName} + +
+
+
+ + {ct.tool.likeCount} +
+
+ + ))} +
+
+ ) : ( +
+ +

This collection is empty.

+
+ )} +
+ ) : null} +
+
+ ); +} diff --git a/apps/web/src/app/(profile)/[username]/page.tsx b/apps/web/src/app/(profile)/[username]/page.tsx new file mode 100644 index 0000000..e97f7b5 --- /dev/null +++ b/apps/web/src/app/(profile)/[username]/page.tsx @@ -0,0 +1,185 @@ +'use client'; + +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'; + +interface PublicAgent { + id: string; + uid: string; + name: string; + description: string | null; + likeCount: number; + toolCount: number; +} + +interface PublicCollection { + id: string; + slug: string; + name: string; + description: string | null; + toolCount: number; + likeCount: number; +} + +interface UserProfile { + id: string; + username: string; + name: string; + image: string | null; + agents: PublicAgent[]; + collections: PublicCollection[]; +} + +export default function UserProfilePage(): React.ReactElement { + const params = useParams(); + // Handle both /username and /@username patterns + const rawUsername = params.username as string; + const username = rawUsername.startsWith('@') ? rawUsername.slice(1) : rawUsername; + + const [profile, setProfile] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchProfile = useCallback(async () => { + try { + const response = await fetch(`/api/public/users/${username}`); + if (response.status === 404) { + setError('not_found'); + return; + } + const data = await response.json(); + + if (data.success) { + setProfile(data.data); + } else { + setError(data.error?.message || 'Failed to load profile'); + } + } catch { + setError('Failed to load profile'); + } finally { + setIsLoading(false); + } + }, [username]); + + useEffect(() => { + fetchProfile(); + }, [fetchProfile]); + + if (error === 'not_found') { + notFound(); + } + + return ( +
+ + +
+ {isLoading ? ( +
+ +
+ ) : error ? ( +
+

{error}

+
+ ) : profile ? ( +
+ {/* User Header */} +
+ {profile.image ? ( + {profile.name} + ) : ( +
+ +
+ )} +
+

{profile.name}

+

@{profile.username}

+
+
+ + {/* Public Agents */} + {profile.agents.length > 0 && ( +
+

Public Agents

+
+ {profile.agents.map((agent) => ( + +

{agent.name}

+ {agent.description && ( +

+ {agent.description} +

+ )} +
+ + + {agent.likeCount} + + + + {agent.toolCount} tools + +
+ + ))} +
+
+ )} + + {/* Public Collections */} + {profile.collections.length > 0 && ( +
+

Public Collections

+
+ {profile.collections.map((collection) => ( + +

{collection.name}

+ {collection.description && ( +

+ {collection.description} +

+ )} +
+ + + {collection.likeCount} + + + + {collection.toolCount} tools + +
+ + ))} +
+
+ )} + + {/* Empty State */} + {profile.agents.length === 0 && profile.collections.length === 0 && ( +
+ +

+ {profile.name} hasn't shared any public agents or collections yet. +

+
+ )} +
+ ) : null} +
+
+ ); +} diff --git a/apps/web/src/app/agents/[id]/page.tsx b/apps/web/src/app/agents/[id]/page.tsx index dc479a9..9826c3c 100644 --- a/apps/web/src/app/agents/[id]/page.tsx +++ b/apps/web/src/app/agents/[id]/page.tsx @@ -4,7 +4,7 @@ import { Badge } from '@tpmjs/ui/Badge/Badge'; import { Button } from '@tpmjs/ui/Button/Button'; import { Icon } from '@tpmjs/ui/Icon/Icon'; import Link from 'next/link'; -import { useParams } from 'next/navigation'; +import { useParams, useRouter } from 'next/navigation'; import { useCallback, useEffect, useState } from 'react'; import { AppHeader } from '~/components/AppHeader'; import { LikeButton } from '~/components/LikeButton'; @@ -57,6 +57,7 @@ interface PublicAgent { updatedAt: string; createdBy: { id: string; + username: string | null; name: string; image: string | null; }; @@ -66,6 +67,7 @@ interface PublicAgent { export default function PublicAgentDetailPage(): React.ReactElement { const params = useParams(); + const router = useRouter(); const agentId = params.id as string; const [agent, setAgent] = useState(null); @@ -78,6 +80,11 @@ export default function PublicAgentDetailPage(): React.ReactElement { const data = await response.json(); if (data.success) { + // Redirect to pretty URL if username is available + if (data.data.createdBy?.username && data.data.uid) { + router.replace(`/${data.data.createdBy.username}/agents/${data.data.uid}`); + return; + } setAgent(data.data); } else { if (data.error?.code === 'NOT_FOUND' || data.error?.code === 'FORBIDDEN') { @@ -92,7 +99,7 @@ export default function PublicAgentDetailPage(): React.ReactElement { } finally { setIsLoading(false); } - }, [agentId]); + }, [agentId, router]); useEffect(() => { fetchAgent(); diff --git a/apps/web/src/app/api/agents/[id]/clone/route.ts b/apps/web/src/app/api/agents/[id]/clone/route.ts new file mode 100644 index 0000000..84753bf --- /dev/null +++ b/apps/web/src/app/api/agents/[id]/clone/route.ts @@ -0,0 +1,243 @@ +import { prisma } from '@tpmjs/db'; +import { AGENT_LIMITS, CloneAgentSchema } from '@tpmjs/types/agent'; +import { headers } from 'next/headers'; +import type { NextRequest } from 'next/server'; + +import { logActivity } from '~/lib/activity'; +import { + apiForbidden, + apiInternalError, + apiNotFound, + apiSuccess, + apiUnauthorized, + apiValidationError, +} from '~/lib/api-response'; +import { auth } from '~/lib/auth'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +type RouteContext = { + params: Promise<{ id: string }>; +}; + +/** + * Generate a URL-friendly UID from a name + */ +function slugify(name: string): string { + return name + .toLowerCase() + .trim() + .replace(/[^\w\s-]/g, '') + .replace(/\s+/g, '-') + .replace(/-+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 50); +} + +/** + * Generate a unique UID for an agent (globally unique) + */ +async function generateUniqueUid(baseName: string): Promise { + let uid = slugify(baseName); + if (!uid) uid = 'agent'; + + // Check if uid exists globally + const existing = await prisma.agent.findUnique({ + where: { uid }, + select: { id: true }, + }); + + if (!existing) return uid; + + // Append numbers until unique + let counter = 1; + while (counter < 1000) { + const candidate = `${uid.slice(0, 46)}-${counter}`; + const exists = await prisma.agent.findUnique({ + where: { uid: candidate }, + select: { id: true }, + }); + if (!exists) return candidate; + counter++; + } + + // Fallback: use random suffix + return `${uid.slice(0, 42)}-${Date.now().toString(36)}`; +} + +/** + * POST /api/agents/[id]/clone + * Clone a public agent to the current user's account + */ +export async function POST(request: NextRequest, context: RouteContext) { + const requestId = crypto.randomUUID(); + + try { + 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 sourceAgent = await prisma.agent.findUnique({ + where: { id }, + include: { + tools: { + select: { toolId: true, position: true }, + }, + collections: { + select: { collectionId: true, position: true }, + }, + }, + }); + + if (!sourceAgent) { + return apiNotFound('Agent', requestId); + } + + // Only public agents can be cloned + if (!sourceAgent.isPublic) { + return apiForbidden('Only public agents can be cloned', requestId); + } + + // Don't allow cloning your own agent + if (sourceAgent.userId === session.user.id) { + return apiValidationError('Cannot clone your own agent', undefined, requestId); + } + + // Check agent limit + const existingCount = await prisma.agent.count({ + where: { userId: session.user.id }, + }); + + if (existingCount >= AGENT_LIMITS.MAX_AGENTS_PER_USER) { + return apiValidationError( + `Maximum ${AGENT_LIMITS.MAX_AGENTS_PER_USER} agents allowed`, + undefined, + requestId + ); + } + + // Parse optional body for custom name/uid + let customName: string | undefined; + let customUid: string | undefined; + + try { + const body = await request.json(); + const parsed = CloneAgentSchema.safeParse(body); + if (parsed.success) { + customName = parsed.data.name; + customUid = parsed.data.uid; + } + } catch { + // No body or invalid JSON - use defaults + } + + // Generate name and uid + const name = customName || `${sourceAgent.name} (copy)`; + const uid = customUid || (await generateUniqueUid(name)); + + // Verify uid uniqueness + if (customUid) { + const existingUid = await prisma.agent.findUnique({ + where: { uid: customUid }, + select: { id: true }, + }); + if (existingUid) { + return apiValidationError('UID is already taken', { uid: customUid }, requestId); + } + } + + // Create the cloned agent with all its relationships + const clonedAgent = await prisma.$transaction(async (tx) => { + // Create the agent + const newAgent = await tx.agent.create({ + data: { + userId: session.user.id, + uid, + name, + description: sourceAgent.description, + provider: sourceAgent.provider, + modelId: sourceAgent.modelId, + systemPrompt: sourceAgent.systemPrompt, + temperature: sourceAgent.temperature, + maxToolCallsPerTurn: sourceAgent.maxToolCallsPerTurn, + maxMessagesInContext: sourceAgent.maxMessagesInContext, + isPublic: false, // Cloned agents start as private + likeCount: 1, // Start with 1 like (from owner) + }, + }); + + // Auto-like the agent + await tx.agentLike.create({ + data: { + userId: session.user.id, + agentId: newAgent.id, + }, + }); + + // Clone tool relationships + if (sourceAgent.tools.length > 0) { + await tx.agentTool.createMany({ + data: sourceAgent.tools.map((at) => ({ + agentId: newAgent.id, + toolId: at.toolId, + position: at.position, + })), + }); + } + + // Clone collection relationships (only user's own collections) + const userCollectionIds = await tx.collection.findMany({ + where: { + userId: session.user.id, + id: { in: sourceAgent.collections.map((ac) => ac.collectionId) }, + }, + select: { id: true }, + }); + + if (userCollectionIds.length > 0) { + const validCollectionIds = new Set(userCollectionIds.map((c) => c.id)); + await tx.agentCollection.createMany({ + data: sourceAgent.collections + .filter((ac) => validCollectionIds.has(ac.collectionId)) + .map((ac) => ({ + agentId: newAgent.id, + collectionId: ac.collectionId, + position: ac.position, + })), + }); + } + + return newAgent; + }); + + // Log activity + logActivity({ + userId: session.user.id, + type: 'AGENT_CLONED', + targetName: clonedAgent.name, + targetType: 'agent', + agentId: clonedAgent.id, + metadata: { sourceAgentId: sourceAgent.id }, + }); + + return apiSuccess( + { + id: clonedAgent.id, + uid: clonedAgent.uid, + name: clonedAgent.name, + description: clonedAgent.description, + isPublic: clonedAgent.isPublic, + createdAt: clonedAgent.createdAt, + }, + { requestId, status: 201 } + ); + } catch (error) { + console.error('[API Error] POST /api/agents/[id]/clone:', error); + return apiInternalError('Failed to clone agent', 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 new file mode 100644 index 0000000..3c5480f --- /dev/null +++ b/apps/web/src/app/api/collections/[id]/clone/route.ts @@ -0,0 +1,201 @@ +import { prisma } from '@tpmjs/db'; +import { COLLECTION_LIMITS, CloneCollectionSchema } from '@tpmjs/types/collection'; +import { headers } from 'next/headers'; +import type { NextRequest } from 'next/server'; + +import { logActivity } from '~/lib/activity'; +import { + apiForbidden, + apiInternalError, + apiNotFound, + apiSuccess, + apiUnauthorized, + apiValidationError, +} from '~/lib/api-response'; +import { auth } from '~/lib/auth'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +type RouteContext = { + params: Promise<{ id: string }>; +}; + +/** + * Generate a URL-friendly slug from a name + */ +function slugify(name: string): string { + return name + .toLowerCase() + .trim() + .replace(/[^\w\s-]/g, '') + .replace(/\s+/g, '-') + .replace(/-+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 50); +} + +/** + * Generate a unique slug for a collection within a user's scope + */ +async function generateUniqueSlug(userId: string, baseName: string): Promise { + let slug = slugify(baseName); + if (!slug) slug = 'collection'; + + // Check if slug exists for this user + const existing = await prisma.collection.findFirst({ + where: { userId, slug }, + select: { id: true }, + }); + + if (!existing) return slug; + + // Append numbers until unique + let counter = 1; + while (counter < 1000) { + const candidate = `${slug.slice(0, 46)}-${counter}`; + const exists = await prisma.collection.findFirst({ + where: { userId, slug: candidate }, + select: { id: true }, + }); + if (!exists) return candidate; + counter++; + } + + // Fallback: use random suffix + return `${slug.slice(0, 42)}-${Date.now().toString(36)}`; +} + +/** + * POST /api/collections/[id]/clone + * Clone a public collection to the current user's account + */ +export async function POST(request: NextRequest, context: RouteContext) { + const requestId = crypto.randomUUID(); + + try { + 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 sourceCollection = await prisma.collection.findUnique({ + where: { id }, + include: { + tools: { + select: { toolId: true, position: true, note: true }, + }, + }, + }); + + if (!sourceCollection) { + return apiNotFound('Collection', requestId); + } + + // Only public collections can be cloned + if (!sourceCollection.isPublic) { + return apiForbidden('Only public collections can be cloned', requestId); + } + + // Don't allow cloning your own collection + if (sourceCollection.userId === session.user.id) { + return apiValidationError('Cannot clone your own collection', undefined, requestId); + } + + // Check collection limit + const existingCount = await prisma.collection.count({ + where: { userId: session.user.id }, + }); + + if (existingCount >= COLLECTION_LIMITS.MAX_COLLECTIONS_PER_USER) { + return apiValidationError( + `Maximum ${COLLECTION_LIMITS.MAX_COLLECTIONS_PER_USER} collections allowed`, + undefined, + requestId + ); + } + + // Parse optional body for custom name + let customName: string | undefined; + + try { + const body = await request.json(); + const parsed = CloneCollectionSchema.safeParse(body); + if (parsed.success) { + customName = parsed.data.name; + } + } catch { + // No body or invalid JSON - use defaults + } + + // Generate name and slug + 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 + const newCollection = await tx.collection.create({ + data: { + userId: session.user.id, + name, + slug, + description: sourceCollection.description, + isPublic: false, // Cloned collections start as private + likeCount: 1, // Start with 1 like (from owner) + }, + }); + + // Auto-like the collection + await tx.collectionLike.create({ + data: { + userId: session.user.id, + collectionId: newCollection.id, + }, + }); + + // Clone tool relationships + if (sourceCollection.tools.length > 0) { + await tx.collectionTool.createMany({ + data: sourceCollection.tools.map((ct) => ({ + collectionId: newCollection.id, + toolId: ct.toolId, + position: ct.position, + note: ct.note, + })), + }); + } + + return newCollection; + }); + + // Log activity + logActivity({ + userId: session.user.id, + type: 'COLLECTION_CLONED', + targetName: clonedCollection.name, + targetType: 'collection', + collectionId: clonedCollection.id, + metadata: { sourceCollectionId: sourceCollection.id }, + }); + + return apiSuccess( + { + id: clonedCollection.id, + name: clonedCollection.name, + slug: clonedCollection.slug, + description: clonedCollection.description, + isPublic: clonedCollection.isPublic, + toolCount: sourceCollection.tools.length, + createdAt: clonedCollection.createdAt, + }, + { requestId, status: 201 } + ); + } catch (error) { + console.error('[API Error] POST /api/collections/[id]/clone:', error); + return apiInternalError('Failed to clone collection', requestId); + } +} diff --git a/apps/web/src/app/api/collections/route.ts b/apps/web/src/app/api/collections/route.ts index 9e7a54c..d646873 100644 --- a/apps/web/src/app/api/collections/route.ts +++ b/apps/web/src/app/api/collections/route.ts @@ -11,6 +11,51 @@ export const maxDuration = 60; const API_VERSION = '1.0.0'; +/** + * Generate a URL-friendly slug from a name + */ +function slugify(name: string): string { + return name + .toLowerCase() + .trim() + .replace(/[^\w\s-]/g, '') // Remove special chars + .replace(/\s+/g, '-') // Replace spaces with hyphens + .replace(/-+/g, '-') // Remove consecutive hyphens + .replace(/^-+|-+$/g, '') // Trim hyphens + .slice(0, 50); +} + +/** + * Generate a unique slug for a collection within a user's scope + */ +async function generateUniqueSlug(userId: string, baseName: string): Promise { + let slug = slugify(baseName); + if (!slug) slug = 'collection'; + + // Check if slug exists for this user + const existing = await prisma.collection.findFirst({ + where: { userId, slug }, + select: { id: true }, + }); + + if (!existing) return slug; + + // Append numbers until unique + let counter = 1; + while (counter < 1000) { + const candidate = `${slug.slice(0, 46)}-${counter}`; + const exists = await prisma.collection.findFirst({ + where: { userId, slug: candidate }, + select: { id: true }, + }); + if (!exists) return candidate; + counter++; + } + + // Fallback: use random suffix + return `${slug.slice(0, 42)}-${Date.now().toString(36)}`; +} + /** * Standard API response structure */ @@ -92,6 +137,7 @@ export async function GET(request: NextRequest): Promise ({ id: c.id, name: c.name, + slug: c.slug, description: c.description, isPublic: c.isPublic, toolCount: c._count.tools, @@ -200,12 +246,16 @@ export async function POST(request: NextRequest): Promise { const newCollection = await tx.collection.create({ data: { userId: session.user.id, name, + slug, description: description || null, isPublic, likeCount: 1, // Start with 1 like (from owner) @@ -238,6 +288,7 @@ export async function POST(request: NextRequest): Promise; +}; + +/** + * GET /api/public/users/[username]/agents/[uid] + * Get a public agent by username and uid + */ +export async function GET(_request: NextRequest, context: RouteContext) { + const requestId = crypto.randomUUID(); + + try { + const { username: rawUsername, uid } = await context.params; + const username = rawUsername.startsWith('@') ? rawUsername.slice(1) : rawUsername; + + // Find the user first + const user = await prisma.user.findUnique({ + where: { username }, + select: { id: true, username: true, name: true, image: true }, + }); + + if (!user || !user.username) { + return apiNotFound('User', requestId); + } + + // Find the agent by uid belonging to this user + const agent = await prisma.agent.findFirst({ + where: { + uid, + userId: user.id, + }, + include: { + tools: { + include: { + tool: { + select: { + id: true, + name: true, + description: true, + package: { + select: { + npmPackageName: true, + category: true, + }, + }, + }, + }, + }, + orderBy: { position: 'asc' }, + take: 50, + }, + collections: { + include: { + collection: { + select: { + id: true, + name: true, + description: true, + _count: { select: { tools: true } }, + }, + }, + }, + orderBy: { position: 'asc' }, + take: 20, + }, + _count: { + select: { tools: true, collections: true }, + }, + }, + }); + + if (!agent) { + return apiNotFound('Agent', requestId); + } + + // Only return if public + if (!agent.isPublic) { + return apiForbidden('This agent is not public', requestId); + } + + return apiSuccess( + { + id: agent.id, + uid: agent.uid, + name: agent.name, + description: agent.description, + provider: agent.provider, + modelId: agent.modelId, + systemPrompt: agent.systemPrompt, + temperature: agent.temperature, + likeCount: agent.likeCount, + toolCount: agent._count.tools, + collectionCount: agent._count.collections, + createdAt: agent.createdAt.toISOString(), + createdBy: { + id: user.id, + username: user.username, + name: user.name, + image: user.image, + }, + tools: agent.tools.map((at) => ({ + id: at.id, + toolId: at.toolId, + position: at.position, + tool: at.tool, + })), + collections: agent.collections.map((ac) => ({ + id: ac.id, + collectionId: ac.collectionId, + collection: { + id: ac.collection.id, + name: ac.collection.name, + description: ac.collection.description, + toolCount: ac.collection._count.tools, + }, + })), + }, + { requestId } + ); + } catch (error) { + console.error('[API Error] GET /api/public/users/[username]/agents/[uid]:', error); + return apiInternalError('Failed to fetch agent', 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 new file mode 100644 index 0000000..23e1626 --- /dev/null +++ b/apps/web/src/app/api/public/users/[username]/collections/[slug]/route.ts @@ -0,0 +1,105 @@ +import { prisma } from '@tpmjs/db'; +import type { NextRequest } from 'next/server'; + +import { apiForbidden, apiInternalError, apiNotFound, apiSuccess } from '~/lib/api-response'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +type RouteContext = { + params: Promise<{ username: string; slug: string }>; +}; + +/** + * GET /api/public/users/[username]/collections/[slug] + * Get a public collection by username and slug + */ +export async function GET(_request: NextRequest, context: RouteContext) { + const requestId = crypto.randomUUID(); + + try { + const { username: rawUsername, slug } = await context.params; + const username = rawUsername.startsWith('@') ? rawUsername.slice(1) : rawUsername; + + // Find the user first + const user = await prisma.user.findUnique({ + where: { username }, + select: { id: true, username: true, name: true, image: true }, + }); + + if (!user || !user.username) { + return apiNotFound('User', requestId); + } + + // Find the collection by slug belonging to this user + const collection = await prisma.collection.findFirst({ + where: { + slug, + userId: user.id, + }, + include: { + tools: { + include: { + tool: { + select: { + id: true, + name: true, + description: true, + likeCount: true, + package: { + select: { + npmPackageName: true, + category: true, + }, + }, + }, + }, + }, + orderBy: { position: 'asc' }, + take: 100, + }, + _count: { + select: { tools: true }, + }, + }, + }); + + if (!collection) { + return apiNotFound('Collection', requestId); + } + + // Only return if public + if (!collection.isPublic) { + return apiForbidden('This collection is not public', requestId); + } + + return apiSuccess( + { + id: collection.id, + slug: collection.slug, + name: collection.name, + description: collection.description, + likeCount: collection.likeCount, + toolCount: collection._count.tools, + createdAt: collection.createdAt.toISOString(), + createdBy: { + id: user.id, + username: user.username, + name: user.name, + image: user.image, + }, + tools: collection.tools.map((ct) => ({ + id: ct.id, + toolId: ct.toolId, + position: ct.position, + note: ct.note, + tool: ct.tool, + })), + }, + { requestId } + ); + } catch (error) { + console.error('[API Error] GET /api/public/users/[username]/collections/[slug]:', error); + return apiInternalError('Failed to fetch collection', requestId); + } +} diff --git a/apps/web/src/app/api/public/users/[username]/route.ts b/apps/web/src/app/api/public/users/[username]/route.ts new file mode 100644 index 0000000..48d7f68 --- /dev/null +++ b/apps/web/src/app/api/public/users/[username]/route.ts @@ -0,0 +1,94 @@ +import { prisma } from '@tpmjs/db'; +import type { NextRequest } from 'next/server'; + +import { apiInternalError, apiNotFound, apiSuccess } from '~/lib/api-response'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +type RouteContext = { + params: Promise<{ username: string }>; +}; + +/** + * GET /api/public/users/[username] + * Get a user's public profile by username + */ +export async function GET(_request: NextRequest, context: RouteContext) { + const requestId = crypto.randomUUID(); + + try { + const { username: rawUsername } = await context.params; + // Handle @ prefix + const username = rawUsername.startsWith('@') ? rawUsername.slice(1) : rawUsername; + + const user = await prisma.user.findUnique({ + where: { username }, + select: { + id: true, + username: true, + name: true, + image: true, + agents: { + where: { isPublic: true }, + select: { + id: true, + uid: true, + name: true, + description: true, + likeCount: true, + _count: { select: { tools: true } }, + }, + orderBy: { likeCount: 'desc' }, + take: 20, + }, + collections: { + where: { isPublic: true }, + select: { + id: true, + slug: true, + name: true, + description: true, + likeCount: true, + _count: { select: { tools: true } }, + }, + orderBy: { likeCount: 'desc' }, + take: 20, + }, + }, + }); + + if (!user || !user.username) { + return apiNotFound('User', requestId); + } + + return apiSuccess( + { + id: user.id, + username: user.username, + name: user.name, + image: user.image, + agents: user.agents.map((a) => ({ + id: a.id, + uid: a.uid, + name: a.name, + description: a.description, + likeCount: a.likeCount, + toolCount: a._count.tools, + })), + collections: user.collections.map((c) => ({ + id: c.id, + slug: c.slug, + name: c.name, + description: c.description, + likeCount: c.likeCount, + toolCount: c._count.tools, + })), + }, + { requestId } + ); + } catch (error) { + console.error('[API Error] GET /api/public/users/[username]:', error); + return apiInternalError('Failed to fetch user profile', requestId); + } +} diff --git a/apps/web/src/app/api/user/profile/route.ts b/apps/web/src/app/api/user/profile/route.ts new file mode 100644 index 0000000..5c4b75d --- /dev/null +++ b/apps/web/src/app/api/user/profile/route.ts @@ -0,0 +1,147 @@ +import { prisma } from '@tpmjs/db'; +import { RESERVED_USERNAMES, USERNAME_REGEX, UpdateUserProfileSchema } from '@tpmjs/types/user'; +import { headers } from 'next/headers'; +import { type NextRequest, NextResponse } from 'next/server'; + +import { auth } from '~/lib/auth'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +/** + * GET /api/user/profile + * Get the current user's profile + */ +export async function GET(): Promise { + try { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session?.user?.id) { + return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 }); + } + + const user = await prisma.user.findUnique({ + where: { id: session.user.id }, + select: { + id: true, + name: true, + username: true, + email: true, + image: true, + createdAt: true, + }, + }); + + if (!user) { + return NextResponse.json({ success: false, error: 'User not found' }, { status: 404 }); + } + + return NextResponse.json({ + success: true, + data: user, + }); + } catch (error) { + console.error('Failed to get user profile:', error); + return NextResponse.json({ success: false, error: 'Failed to get profile' }, { status: 500 }); + } +} + +/** + * PATCH /api/user/profile + * Update the current user's profile + */ +export async function PATCH(request: NextRequest): Promise { + try { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session?.user?.id) { + return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 }); + } + + const body = await request.json(); + const result = UpdateUserProfileSchema.safeParse(body); + + if (!result.success) { + return NextResponse.json( + { + success: false, + error: 'Invalid input', + details: result.error.flatten().fieldErrors, + }, + { status: 400 } + ); + } + + const { name, username, image } = result.data; + + // If updating username, validate availability + if (username) { + // Check if reserved + if ((RESERVED_USERNAMES as readonly string[]).includes(username)) { + return NextResponse.json( + { + success: false, + error: 'This username is reserved', + }, + { status: 400 } + ); + } + + // Check regex + if (!USERNAME_REGEX.test(username)) { + return NextResponse.json( + { + success: false, + error: 'Username must be lowercase alphanumeric with single hyphens only', + }, + { status: 400 } + ); + } + + // Check if already taken by another user + const existingUser = await prisma.user.findFirst({ + where: { + username, + NOT: { id: session.user.id }, + }, + select: { id: true }, + }); + + if (existingUser) { + return NextResponse.json( + { + success: false, + error: 'This username is already taken', + }, + { status: 400 } + ); + } + } + + const updatedUser = await prisma.user.update({ + where: { id: session.user.id }, + data: { + ...(name !== undefined && { name }), + ...(username !== undefined && { username }), + ...(image !== undefined && { image }), + }, + select: { + id: true, + name: true, + username: true, + email: true, + image: true, + createdAt: true, + }, + }); + + return NextResponse.json({ + success: true, + data: updatedUser, + }); + } catch (error) { + console.error('Failed to update user profile:', error); + return NextResponse.json( + { success: false, error: 'Failed to update profile' }, + { status: 500 } + ); + } +} diff --git a/apps/web/src/app/api/user/username/check/route.ts b/apps/web/src/app/api/user/username/check/route.ts new file mode 100644 index 0000000..e9823ca --- /dev/null +++ b/apps/web/src/app/api/user/username/check/route.ts @@ -0,0 +1,101 @@ +import { prisma } from '@tpmjs/db'; +import { CheckUsernameSchema, RESERVED_USERNAMES, USERNAME_REGEX } from '@tpmjs/types/user'; +import { type NextRequest, NextResponse } from 'next/server'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +/** + * GET /api/user/username/check?username=xxx + * Check if a username is available + */ +export async function GET(request: NextRequest): Promise { + try { + const { searchParams } = new URL(request.url); + const usernameParam = searchParams.get('username'); + + if (!usernameParam) { + return NextResponse.json( + { + success: false, + error: 'Username parameter is required', + }, + { status: 400 } + ); + } + + // Validate and normalize + const result = CheckUsernameSchema.safeParse({ username: usernameParam }); + if (!result.success) { + const issues = result.error.issues; + return NextResponse.json({ + success: true, + data: { + username: usernameParam.toLowerCase(), + available: false, + reason: issues[0]?.message || 'Invalid username format', + }, + }); + } + + const username = result.data.username; + + // Check if reserved + if ((RESERVED_USERNAMES as readonly string[]).includes(username)) { + return NextResponse.json({ + success: true, + data: { + username, + available: false, + reason: 'This username is reserved', + }, + }); + } + + // Check regex format + if (!USERNAME_REGEX.test(username)) { + return NextResponse.json({ + success: true, + data: { + username, + available: false, + reason: 'Username must be lowercase alphanumeric with single hyphens only', + }, + }); + } + + // Check database + const existingUser = await prisma.user.findUnique({ + where: { username }, + select: { id: true }, + }); + + if (existingUser) { + return NextResponse.json({ + success: true, + data: { + username, + available: false, + reason: 'This username is already taken', + }, + }); + } + + return NextResponse.json({ + success: true, + data: { + username, + available: true, + }, + }); + } catch (error) { + console.error('Failed to check username:', error); + return NextResponse.json( + { + success: false, + error: 'Failed to check username', + }, + { status: 500 } + ); + } +} diff --git a/apps/web/src/app/collections/[id]/page.tsx b/apps/web/src/app/collections/[id]/page.tsx index 436a28d..f448a32 100644 --- a/apps/web/src/app/collections/[id]/page.tsx +++ b/apps/web/src/app/collections/[id]/page.tsx @@ -4,7 +4,7 @@ import { Badge } from '@tpmjs/ui/Badge/Badge'; import { Button } from '@tpmjs/ui/Button/Button'; import { Icon } from '@tpmjs/ui/Icon/Icon'; import Link from 'next/link'; -import { useParams } from 'next/navigation'; +import { useParams, useRouter } from 'next/navigation'; import { useCallback, useEffect, useState } from 'react'; import { AppHeader } from '~/components/AppHeader'; import { LikeButton } from '~/components/LikeButton'; @@ -30,6 +30,7 @@ interface CollectionTool { interface PublicCollection { id: string; + slug: string | null; name: string; description: string | null; likeCount: number; @@ -38,6 +39,7 @@ interface PublicCollection { updatedAt: string; createdBy: { id: string; + username: string | null; name: string; image: string | null; }; @@ -173,6 +175,7 @@ function McpUrlSection({ collectionId }: { collectionId: string }) { export default function PublicCollectionDetailPage(): React.ReactElement { const params = useParams(); + const router = useRouter(); const collectionId = params.id as string; const [collection, setCollection] = useState(null); @@ -185,6 +188,11 @@ export default function PublicCollectionDetailPage(): React.ReactElement { const data = await response.json(); if (data.success) { + // Redirect to pretty URL if username and slug are available + if (data.data.createdBy?.username && data.data.slug) { + router.replace(`/${data.data.createdBy.username}/collections/${data.data.slug}`); + return; + } setCollection(data.data); } else { if (data.error?.code === 'NOT_FOUND' || data.error?.code === 'FORBIDDEN') { @@ -199,7 +207,7 @@ export default function PublicCollectionDetailPage(): React.ReactElement { } finally { setIsLoading(false); } - }, [collectionId]); + }, [collectionId, router]); useEffect(() => { fetchCollection(); diff --git a/apps/web/src/app/docs/page.tsx b/apps/web/src/app/docs/page.tsx index a79dea3..abe3b17 100644 --- a/apps/web/src/app/docs/page.tsx +++ b/apps/web/src/app/docs/page.tsx @@ -627,6 +627,25 @@ const result = streamText({ + {/* Sharing & URLs - Separate documentation page */} +
+
+ šŸ”— +
+

Sharing & URLs

+

+ Learn how to share your agents, collections, and profile using human-readable + URLs. Clone public agents and collections to customize them. +

+ + + +
+
+
+ {/* ==================== API REFERENCE ==================== */}

diff --git a/apps/web/src/app/docs/sharing/page.tsx b/apps/web/src/app/docs/sharing/page.tsx new file mode 100644 index 0000000..a969961 --- /dev/null +++ b/apps/web/src/app/docs/sharing/page.tsx @@ -0,0 +1,654 @@ +'use client'; + +import { Badge } from '@tpmjs/ui/Badge/Badge'; +import { Button } from '@tpmjs/ui/Button/Button'; +import { CodeBlock } from '@tpmjs/ui/CodeBlock/CodeBlock'; +import Link from 'next/link'; +import { useEffect, useState } from 'react'; +import { AppHeader } from '~/components/AppHeader'; + +const NAV_SECTIONS = [ + { + title: 'URLs', + items: [ + { id: 'overview', label: 'Overview' }, + { id: 'user-profiles', label: 'User Profiles' }, + { id: 'agents', label: 'Agents' }, + { id: 'collections', label: 'Collections' }, + { id: 'tools', label: 'Tools' }, + ], + }, + { + title: 'Cloning', + items: [ + { id: 'clone-agents', label: 'Clone Agents' }, + { id: 'clone-collections', label: 'Clone Collections' }, + ], + }, + { + title: 'Reference', + items: [ + { id: 'url-reference', label: 'URL Reference' }, + { id: 'visibility', label: 'Visibility Settings' }, + ], + }, +]; + +function SidebarNav({ + activeSection, + onSectionClick, +}: { + activeSection: string; + onSectionClick: (id: string) => void; +}) { + return ( +

+ ); +} + +function DocSection({ + id, + title, + children, +}: { + id: string; + title: string; + children: React.ReactNode; +}) { + return ( +
+

+ {title} +

+ {children} +
+ ); +} + +function DocSubSection({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+

{title}

+ {children} +
+ ); +} + +function UrlExample({ + url, + description, + example, +}: { + url: string; + description: string; + example?: string; +}) { + return ( +
+ {url} +

{description}

+ {example && ( +

+ Example: {example} +

+ )} +
+ ); +} + +function InfoCard({ + icon, + title, + children, +}: { + icon: string; + title: string; + children: React.ReactNode; +}) { + return ( +
+
+ {icon} +
+

{title}

+

{children}

+
+
+
+ ); +} + +export default function SharingDocsPage(): React.ReactElement { + const [activeSection, setActiveSection] = useState('overview'); + const [mobileNavOpen, setMobileNavOpen] = useState(false); + + useEffect(() => { + const observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + setActiveSection(entry.target.id); + } + }); + }, + { rootMargin: '-100px 0px -66%' } + ); + + NAV_SECTIONS.forEach((section) => { + section.items.forEach((item) => { + const element = document.getElementById(item.id); + if (element) observer.observe(element); + }); + }); + + return () => observer.disconnect(); + }, []); + + const scrollToSection = (id: string) => { + const element = document.getElementById(id); + if (element) { + element.scrollIntoView({ behavior: 'smooth' }); + setMobileNavOpen(false); + } + }; + + return ( +
+ + +
+ {/* Mobile Navigation Toggle */} +
+ + {mobileNavOpen && ( +
+ +
+ )} +
+ + {/* Desktop Sidebar */} + + + {/* Main Content */} +
+
+ {/* Hero */} +
+

+ Sharing & URLs +

+

+ Learn how to share your agents, collections, and profile with others using + human-readable URLs. +

+
+ + + + + + +
+
+ + {/* ==================== URLS ==================== */} + +

+ TPMJS uses human-readable URLs based on your username. When you create an account, + you choose a unique username that becomes part of your shareable URLs. +

+
+ + Share your profile page showing all your public agents and collections + + + Share individual agents so others can chat with them or clone them + + + Share tool collections for easy MCP server setup + +
+
+

+ Note: Only public agents and + collections are visible to others. You can control visibility in the settings for + each item. +

+
+
+ + +

+ Your profile page displays your name, avatar, and all your public agents and + collections. +

+ + +

+ You can also use the @ prefix for social-media style URLs: +

+ +
+ +

+ Usernames must be 3-30 characters and can contain lowercase letters, numbers, and + hyphens. They cannot start or end with a hyphen. +

+ +
+
+ + +

+ Share your AI agents so others can interact with them or clone them to their own + account. +

+ + +

+ The agent UID is auto-generated from the agent name when you create it. For + example, an agent named "Research Assistant" gets the UID + "research-assistant". +

+
+ + +
+

+ Note: Chatting with someone + else's agent uses their API keys and tool configuration. The agent owner is + responsible for any API usage costs. +

+
+
+
+ + +

+ Collections bundle multiple tools together for easy sharing and MCP server setup. +

+ + +

+ Collection pages include ready-to-use MCP server URLs that others can copy into + their Claude Desktop or Cursor configuration. +

+
+ +

+ Each collection provides HTTP and SSE transport URLs: +

+ +
+
+ + +

+ Tools in the registry have URLs based on their npm package name and tool name. +

+ + +

+ Tools are not user-owned - they come from npm packages published with the{' '} + tpmjs{' '} + keyword. +

+
+
+ + {/* ==================== CLONING ==================== */} + +

+ When you find a public agent you like, you can clone it to your own account to + customize it. +

+ +
+

+ 1. Navigate to a public agent's detail page (e.g.,{' '} + + tpmjs.com/ajax/agents/research-assistant + + ) +

+

+ 2. Click the "Clone"{' '} + button in the header +

+

3. The agent is copied to your account with all its tools and settings

+

+ 4. You'll be redirected to your dashboard where you can customize the + cloned agent +

+
+
+ +
+
+

+ + Included + +

+
    +
  • • Name and description
  • +
  • • System prompt
  • +
  • • Provider and model settings
  • +
  • • Temperature and other parameters
  • +
  • • All attached tools
  • +
  • • All attached collections
  • +
+
+
+

+ + Not Included + +

+
    +
  • • Conversation history
  • +
  • • API keys (you use your own)
  • +
  • • Like count
  • +
  • • Original owner attribution
  • +
+
+
+
+
+ + +

+ Clone collections to get a copy you can modify without affecting the original. +

+ +
+

+ 1. Navigate to a public collection's detail page (e.g.,{' '} + + tpmjs.com/ajax/collections/web-scraping + + ) +

+

+ 2. Click the "Clone"{' '} + button +

+

3. The collection is copied to your account with all its tools

+

4. You can then add, remove, or reorder tools as you like

+
+
+ +
+
+

+ + Included + +

+
    +
  • • Name and description
  • +
  • • All tools in the collection
  • +
  • • Tool order
  • +
  • • Tool notes
  • +
+
+
+

+ + Not Included + +

+
    +
  • • Like count
  • +
  • • Original owner attribution
  • +
  • • MCP server URLs (new ones generated)
  • +
+
+
+
+
+ + {/* ==================== REFERENCE ==================== */} + +

+ Complete reference of all shareable URLs on TPMJS. +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Type + URL Pattern + Example
User Profile/{'{username}'} + /ajax +
User Profile (@)/@{'{username}'} + /@ajax +
Agent Detail + /{'{username}'}/agents/{'{uid}'} + + /ajax/agents/research-bot +
Agent Chat + /{'{username}'}/agents/{'{uid}'}/chat + + /ajax/agents/research-bot/chat +
Collection + /{'{username}'}/collections/{'{slug}'} + + /ajax/collections/web-tools +
Tool + /tool/{'{package}'}/{'{tool}'} + + /tool/@firecrawl/ai-sdk/scrape +
+
+
+ + +

+ Control who can see your agents and collections. +

+ +
+
+

Public

+
    +
  • • Visible on your profile page
  • +
  • • Anyone can view the detail page
  • +
  • • Can be cloned by other users
  • +
  • • Shows up in search results
  • +
  • • Others can chat with public agents
  • +
+
+
+

Private

+
    +
  • • Only visible to you
  • +
  • • Not shown on profile
  • +
  • • Cannot be cloned
  • +
  • • Direct URL returns 404 for others
  • +
  • • Only you can chat with the agent
  • +
+
+
+
+ +

+ To change an item's visibility: +

+
+

+ 1. Go to your dashboard (Dashboard → Agents or{' '} + Dashboard → Collections) +

+

2. Click on the item you want to modify

+

+ 3. Toggle the "Public" switch +

+

4. Changes take effect immediately

+
+
+
+ + {/* CTA */} +
+

Start Sharing

+

+ Create public agents and collections to share your work with the community. +

+
+ + + + + + +
+
+
+
+
+
+ ); +} diff --git a/apps/web/src/components/CloneButton.tsx b/apps/web/src/components/CloneButton.tsx new file mode 100644 index 0000000..9319185 --- /dev/null +++ b/apps/web/src/components/CloneButton.tsx @@ -0,0 +1,85 @@ +'use client'; + +import { Button } from '@tpmjs/ui/Button/Button'; +import { Icon } from '@tpmjs/ui/Icon/Icon'; +import { useRouter } from 'next/navigation'; +import { useState } from 'react'; +import { useSession } from '~/lib/auth-client'; + +interface CloneButtonProps { + type: 'agent' | 'collection'; + sourceId: string; + sourceName: string; + className?: string; +} + +export function CloneButton({ + type, + sourceId, + sourceName, + className, +}: CloneButtonProps): React.ReactElement { + // sourceName is used in the button title + void sourceName; + const { data: session } = useSession(); + const router = useRouter(); + const [isCloning, setIsCloning] = useState(false); + const [error, setError] = useState(null); + + async function handleClone() { + if (!session?.user) { + // Redirect to sign in + router.push(`/sign-in?redirect=${encodeURIComponent(window.location.pathname)}`); + return; + } + + setIsCloning(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 cloned 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 clone'); + } + } catch { + setError('Failed to clone'); + } finally { + setIsCloning(false); + } + } + + return ( +
+ + {error &&

{error}

} +
+ ); +} diff --git a/apps/web/src/lib/activity.ts b/apps/web/src/lib/activity.ts index 044ff1c..70416f7 100644 --- a/apps/web/src/lib/activity.ts +++ b/apps/web/src/lib/activity.ts @@ -46,6 +46,7 @@ export const ACTIVITY_MESSAGES: Record< AGENT_CREATED: (name) => `Created agent "${name}"`, AGENT_UPDATED: (name) => `Updated agent "${name}"`, AGENT_DELETED: (name) => `Deleted agent "${name}"`, + AGENT_CLONED: (name) => `Cloned agent "${name}"`, AGENT_TOOL_ADDED: (name, meta) => meta?.toolName ? `Added tool "${meta.toolName}" to agent "${name}"` @@ -65,6 +66,7 @@ export const ACTIVITY_MESSAGES: Record< COLLECTION_CREATED: (name) => `Created collection "${name}"`, COLLECTION_UPDATED: (name) => `Updated collection "${name}"`, COLLECTION_DELETED: (name) => `Deleted collection "${name}"`, + COLLECTION_CLONED: (name) => `Cloned collection "${name}"`, COLLECTION_TOOL_ADDED: (name, meta) => meta?.toolName ? `Added tool "${meta.toolName}" to collection "${name}"` @@ -88,6 +90,7 @@ export const ACTIVITY_ICONS: Record = { AGENT_CREATED: 'plus', AGENT_UPDATED: 'pencil', AGENT_DELETED: 'trash', + AGENT_CLONED: 'copy', AGENT_TOOL_ADDED: 'link', AGENT_TOOL_REMOVED: 'unlink', AGENT_COLLECTION_ADDED: 'folderPlus', @@ -95,6 +98,7 @@ export const ACTIVITY_ICONS: Record = { COLLECTION_CREATED: 'folderPlus', COLLECTION_UPDATED: 'pencil', COLLECTION_DELETED: 'trash', + COLLECTION_CLONED: 'copy', COLLECTION_TOOL_ADDED: 'link', COLLECTION_TOOL_REMOVED: 'unlink', TOOL_LIKED: 'heart', diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index e8eda3d..6d2f47f 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -332,6 +332,7 @@ model User { email String @unique emailVerified Boolean @default(false) @map("email_verified") image String? + username String? @unique @db.VarChar(30) // URL-friendly username (nullable for migration) createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") @@ -346,6 +347,7 @@ model User { agentLikes AgentLike[] activities UserActivity[] + @@index([username]) @@map("users") } @@ -415,6 +417,7 @@ model Collection { // Collection metadata name String @db.VarChar(100) + slug String? @db.VarChar(50) // URL-friendly identifier (nullable for migration) description String? @db.VarChar(500) isPublic Boolean @default(false) @map("is_public") likeCount Int @default(0) @map("like_count") @@ -428,9 +431,10 @@ model Collection { agents AgentCollection[] likes CollectionLike[] - // Unique constraint: user can't have duplicate collection names - @@unique([userId, name]) + // Unique constraint: user can't have duplicate collection slugs + @@unique([userId, slug]) @@index([userId]) + @@index([slug]) @@index([isPublic]) @@index([likeCount]) @@index([createdAt]) @@ -726,6 +730,7 @@ enum ActivityType { AGENT_CREATED AGENT_UPDATED AGENT_DELETED + AGENT_CLONED AGENT_TOOL_ADDED AGENT_TOOL_REMOVED AGENT_COLLECTION_ADDED @@ -733,6 +738,7 @@ enum ActivityType { COLLECTION_CREATED COLLECTION_UPDATED COLLECTION_DELETED + COLLECTION_CLONED COLLECTION_TOOL_ADDED COLLECTION_TOOL_REMOVED TOOL_LIKED diff --git a/packages/db/scripts/populate-usernames-slugs.ts b/packages/db/scripts/populate-usernames-slugs.ts new file mode 100644 index 0000000..c39338c --- /dev/null +++ b/packages/db/scripts/populate-usernames-slugs.ts @@ -0,0 +1,143 @@ +/** + * Migration script to populate usernames and collection slugs for existing data. + * + * Run with: pnpm --filter=@tpmjs/db tsx scripts/populate-usernames-slugs.ts + */ + +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +/** + * Convert a display name to a URL-friendly slug/username. + * - Lowercase + * - Replace spaces and special chars with hyphens + * - Remove consecutive hyphens + * - Trim hyphens from start/end + */ +function slugify(text: string): string { + return text + .toLowerCase() + .trim() + .replace(/[^\w\s-]/g, '') // Remove special chars except spaces and hyphens + .replace(/\s+/g, '-') // Replace spaces with hyphens + .replace(/-+/g, '-') // Remove consecutive hyphens + .replace(/^-+|-+$/g, '') // Trim hyphens from start/end + .slice(0, 30); // Max length for username/slug +} + +/** + * Generate a unique username by appending a number suffix if needed. + */ +async function generateUniqueUsername(baseName: string): Promise { + let username = slugify(baseName); + + // If empty after slugify, use a default + if (!username) { + username = 'user'; + } + + // Check if username exists + const existing = await prisma.user.findUnique({ where: { username } }); + if (!existing) { + return username; + } + + // Append numbers until unique + let counter = 1; + while (true) { + const candidate = `${username.slice(0, 26)}-${counter}`; // Leave room for suffix + const exists = await prisma.user.findUnique({ where: { username: candidate } }); + if (!exists) { + return candidate; + } + counter++; + if (counter > 1000) { + throw new Error(`Could not generate unique username for ${baseName}`); + } + } +} + +/** + * Generate a unique slug for a collection within a user's scope. + */ +async function generateUniqueSlug(userId: string, baseName: string): Promise { + let slug = slugify(baseName); + + // If empty after slugify, use a default + if (!slug) { + slug = 'collection'; + } + + // Check if slug exists for this user + const existing = await prisma.collection.findFirst({ + where: { userId, slug }, + }); + if (!existing) { + return slug; + } + + // Append numbers until unique within user scope + let counter = 1; + while (true) { + const candidate = `${slug.slice(0, 46)}-${counter}`; // Leave room for suffix + const exists = await prisma.collection.findFirst({ + where: { userId, slug: candidate }, + }); + if (!exists) { + return candidate; + } + counter++; + if (counter > 1000) { + throw new Error(`Could not generate unique slug for ${baseName}`); + } + } +} + +async function main() { + console.log('šŸš€ Starting username and slug population...\n'); + + // Populate usernames for users without one + const usersWithoutUsername = await prisma.user.findMany({ + where: { username: null }, + }); + + console.log(`Found ${usersWithoutUsername.length} users without usernames`); + + for (const user of usersWithoutUsername) { + const username = await generateUniqueUsername(user.name || user.email.split('@')[0]); + await prisma.user.update({ + where: { id: user.id }, + data: { username }, + }); + console.log(` āœ“ User "${user.name || user.email}" → @${username}`); + } + + // Populate slugs for collections without one + const collectionsWithoutSlug = await prisma.collection.findMany({ + where: { slug: null }, + include: { user: true }, + }); + + console.log(`\nFound ${collectionsWithoutSlug.length} collections without slugs`); + + for (const collection of collectionsWithoutSlug) { + const slug = await generateUniqueSlug(collection.userId, collection.name); + await prisma.collection.update({ + where: { id: collection.id }, + data: { slug }, + }); + console.log(` āœ“ Collection "${collection.name}" → ${slug}`); + } + + console.log('\nāœ… Migration complete!'); +} + +main() + .catch((e) => { + console.error('āŒ Migration failed:', e); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/packages/types/package.json b/packages/types/package.json index 2ab2953..8db4f49 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -32,6 +32,10 @@ "./agent": { "types": "./dist/agent.d.ts", "default": "./dist/agent.js" + }, + "./user": { + "types": "./dist/user.d.ts", + "default": "./dist/user.js" } }, "files": ["dist"], diff --git a/packages/types/src/agent.ts b/packages/types/src/agent.ts index f077679..acda038 100644 --- a/packages/types/src/agent.ts +++ b/packages/types/src/agent.ts @@ -61,6 +61,20 @@ export const AddToolToAgentSchema = z.object({ position: z.number().int().min(0).optional(), }); +// ============================================================================ +// Clone Schemas +// ============================================================================ + +export const CloneAgentSchema = z.object({ + name: z.string().min(1).max(100).optional(), // If not provided, will append "(copy)" + uid: z + .string() + .min(1) + .max(50) + .regex(UID_REGEX, 'UID must be lowercase alphanumeric with hyphens') + .optional(), // If not provided, will generate from name +}); + // ============================================================================ // User API Key Schemas // ============================================================================ @@ -165,6 +179,7 @@ export type CreateAgentInput = z.infer; export type UpdateAgentInput = z.infer; export type AddCollectionToAgentInput = z.infer; export type AddToolToAgentInput = z.infer; +export type CloneAgentInput = z.infer; export type AddApiKeyInput = z.infer; export type ApiKeyInfo = z.infer; export type CreateConversationInput = z.infer; diff --git a/packages/types/src/collection.ts b/packages/types/src/collection.ts index b6fe307..f7fb513 100644 --- a/packages/types/src/collection.ts +++ b/packages/types/src/collection.ts @@ -51,6 +51,19 @@ export const ReorderToolsSchema = z.object({ toolIds: z.array(z.string().min(1)), }); +// ============================================================================ +// Clone Schemas +// ============================================================================ + +export const CloneCollectionSchema = z.object({ + name: z + .string() + .min(1, 'Name is required') + .max(100, 'Name must be 100 characters or less') + .regex(NAME_REGEX, 'Name can only contain letters, numbers, spaces, hyphens, and underscores') + .optional(), // If not provided, will use original name or append "(copy)" +}); + // ============================================================================ // Response Types (for API responses) // ============================================================================ @@ -58,6 +71,7 @@ export const ReorderToolsSchema = z.object({ export const CollectionSchema = z.object({ id: z.string(), name: z.string(), + slug: z.string().nullable(), description: z.string().nullable(), isPublic: z.boolean(), toolCount: z.number(), @@ -96,6 +110,7 @@ export type UpdateCollectionInput = z.infer; export type AddToolToCollectionInput = z.infer; export type UpdateCollectionToolInput = z.infer; export type ReorderToolsInput = z.infer; +export type CloneCollectionInput = z.infer; export type Collection = z.infer; export type CollectionTool = z.infer; export type CollectionWithTools = z.infer; diff --git a/packages/types/src/user.ts b/packages/types/src/user.ts new file mode 100644 index 0000000..c1b9408 --- /dev/null +++ b/packages/types/src/user.ts @@ -0,0 +1,146 @@ +import { z } from 'zod'; + +// ============================================================================ +// Reserved Usernames (defined first since UsernameSchema references it) +// ============================================================================ + +export const RESERVED_USERNAMES = [ + // System routes + 'admin', + 'api', + 'auth', + 'dashboard', + 'help', + 'support', + 'system', + 'www', + 'settings', + 'login', + 'logout', + 'register', + 'signup', + 'signin', + // Content routes + 'agents', + 'collections', + 'tools', + 'tool', + 'playground', + 'explore', + 'search', + // Reserved for future + 'about', + 'blog', + 'docs', + 'pricing', + 'terms', + 'privacy', + 'contact', + 'status', + // Brand/official + 'tpmjs', + 'tpm', + 'official', +] as const; + +// ============================================================================ +// Username Validation +// ============================================================================ + +/** + * Username requirements: + * - 3-30 characters + * - Lowercase alphanumeric and hyphens only + * - Must start and end with alphanumeric (unless 1-2 chars) + * - No consecutive hyphens + */ +export const USERNAME_REGEX = /^[a-z0-9](?:[a-z0-9]|(?:-(?!-))){1,28}[a-z0-9]$|^[a-z0-9]{1,2}$/; + +export const UsernameSchema = z + .string() + .min(3, 'Username must be at least 3 characters') + .max(30, 'Username must be 30 characters or less') + .regex(USERNAME_REGEX, 'Username must be lowercase, alphanumeric, with single hyphens only') + .refine( + (val) => !(RESERVED_USERNAMES as readonly string[]).includes(val), + 'This username is reserved' + ); + +// ============================================================================ +// User Schemas +// ============================================================================ + +export const UpdateUserProfileSchema = z.object({ + name: z.string().min(1, 'Name is required').max(100).optional(), + username: UsernameSchema.optional(), + image: z.string().url('Invalid image URL').nullable().optional(), +}); + +export const CheckUsernameSchema = z.object({ + username: z + .string() + .min(3, 'Username must be at least 3 characters') + .max(30, 'Username must be 30 characters or less') + .transform((val) => val.toLowerCase()), +}); + +// ============================================================================ +// Response Types +// ============================================================================ + +export const UserProfileSchema = z.object({ + id: z.string(), + name: z.string(), + username: z.string().nullable(), + email: z.string().email(), + image: z.string().nullable(), + createdAt: z.date(), +}); + +export const PublicUserSchema = z.object({ + id: z.string(), + username: z.string(), + name: z.string(), + image: z.string().nullable(), +}); + +export const UsernameAvailabilitySchema = z.object({ + username: z.string(), + available: z.boolean(), + reason: z.string().optional(), +}); + +// ============================================================================ +// Type Exports +// ============================================================================ + +export type UpdateUserProfileInput = z.infer; +export type CheckUsernameInput = z.infer; +export type UserProfile = z.infer; +export type PublicUser = z.infer; +export type UsernameAvailability = z.infer; + +// ============================================================================ +// Helper Functions +// ============================================================================ + +/** + * Convert a display name to a URL-friendly username suggestion. + */ +export function suggestUsername(name: string): string { + return name + .toLowerCase() + .trim() + .replace(/[^\w\s-]/g, '') // Remove special chars + .replace(/\s+/g, '-') // Replace spaces with hyphens + .replace(/-+/g, '-') // Remove consecutive hyphens + .replace(/^-+|-+$/g, '') // Trim hyphens + .slice(0, 30); +} + +/** + * Check if a username is valid (without checking availability). + */ +export function isValidUsername(username: string): boolean { + return UsernameSchema.safeParse(username).success; +} diff --git a/packages/types/tsup.config.ts b/packages/types/tsup.config.ts index 7e4d403..3a30a19 100644 --- a/packages/types/tsup.config.ts +++ b/packages/types/tsup.config.ts @@ -1,7 +1,14 @@ import { defineConfig } from 'tsup'; export default defineConfig({ - entry: ['src/tool.ts', 'src/registry.ts', 'src/tpmjs.ts', 'src/collection.ts', 'src/agent.ts'], + entry: [ + 'src/tool.ts', + 'src/registry.ts', + 'src/tpmjs.ts', + 'src/collection.ts', + 'src/agent.ts', + 'src/user.ts', + ], format: ['esm'], dts: true, clean: true,