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

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

View file

@ -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<string> {
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<string> {
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();
});