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 <noreply@anthropic.com>
This commit is contained in:
Ajax Davis 2026-01-08 20:47:08 +10:00
parent 1ff0e49e41
commit eaaa40f130
28 changed files with 3061 additions and 14 deletions

View file

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

View file

@ -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<typeof CreateAgentSchema>;
export type UpdateAgentInput = z.infer<typeof UpdateAgentSchema>;
export type AddCollectionToAgentInput = z.infer<typeof AddCollectionToAgentSchema>;
export type AddToolToAgentInput = z.infer<typeof AddToolToAgentSchema>;
export type CloneAgentInput = z.infer<typeof CloneAgentSchema>;
export type AddApiKeyInput = z.infer<typeof AddApiKeySchema>;
export type ApiKeyInfo = z.infer<typeof ApiKeyInfoSchema>;
export type CreateConversationInput = z.infer<typeof CreateConversationSchema>;

View file

@ -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<typeof UpdateCollectionSchema>;
export type AddToolToCollectionInput = z.infer<typeof AddToolToCollectionSchema>;
export type UpdateCollectionToolInput = z.infer<typeof UpdateCollectionToolSchema>;
export type ReorderToolsInput = z.infer<typeof ReorderToolsSchema>;
export type CloneCollectionInput = z.infer<typeof CloneCollectionSchema>;
export type Collection = z.infer<typeof CollectionSchema>;
export type CollectionTool = z.infer<typeof CollectionToolSchema>;
export type CollectionWithTools = z.infer<typeof CollectionWithToolsSchema>;

146
packages/types/src/user.ts Normal file
View file

@ -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<typeof UpdateUserProfileSchema>;
export type CheckUsernameInput = z.infer<typeof CheckUsernameSchema>;
export type UserProfile = z.infer<typeof UserProfileSchema>;
export type PublicUser = z.infer<typeof PublicUserSchema>;
export type UsernameAvailability = z.infer<typeof UsernameAvailabilitySchema>;
// ============================================================================
// 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;
}

View file

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