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:
parent
1ff0e49e41
commit
eaaa40f130
28 changed files with 3061 additions and 14 deletions
|
|
@ -1,37 +1,133 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { signUp } from '@/lib/auth-client';
|
import { signUp } from '@/lib/auth-client';
|
||||||
|
import { suggestUsername } from '@tpmjs/types/user';
|
||||||
import Link from 'next/link';
|
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() {
|
export default function SignUpPage() {
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
// Username availability state
|
||||||
|
const [usernameCheck, setUsernameCheck] = useState<UsernameCheckResult | null>(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<HTMLInputElement>) {
|
||||||
|
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) {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError('');
|
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);
|
setLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { data, error } = await signUp.email({
|
const { data, error: signUpError } = await signUp.email({
|
||||||
name,
|
name,
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (error) {
|
if (signUpError) {
|
||||||
console.error('Sign up error:', error);
|
console.error('Sign up error:', signUpError);
|
||||||
setError(error.message || 'Failed to create account');
|
setError(signUpError.message || 'Failed to create account');
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data) {
|
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';
|
window.location.href = '/verify-email';
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -69,6 +165,98 @@ export default function SignUpPage() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="username" className="block text-sm font-medium text-foreground mb-1">
|
||||||
|
Username
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-foreground-secondary">
|
||||||
|
@
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
id="username"
|
||||||
|
type="text"
|
||||||
|
value={username}
|
||||||
|
onChange={handleUsernameChange}
|
||||||
|
required
|
||||||
|
minLength={3}
|
||||||
|
maxLength={30}
|
||||||
|
className="w-full pl-7 pr-10 py-2 border border-border rounded-md bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-foreground focus:border-transparent"
|
||||||
|
placeholder="username"
|
||||||
|
/>
|
||||||
|
{/* Status indicator */}
|
||||||
|
<div className="absolute inset-y-0 right-0 pr-3 flex items-center">
|
||||||
|
{checkingUsername && (
|
||||||
|
<svg
|
||||||
|
className="animate-spin h-4 w-4 text-foreground-secondary"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
className="opacity-25"
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="4"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
className="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
{!checkingUsername && usernameCheck?.available && (
|
||||||
|
<svg
|
||||||
|
className="h-4 w-4 text-green-500"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M5 13l4 4L19 7"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
{!checkingUsername &&
|
||||||
|
usernameCheck &&
|
||||||
|
!usernameCheck.available &&
|
||||||
|
username.length >= 3 && (
|
||||||
|
<svg
|
||||||
|
className="h-4 w-4 text-red-500"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M6 18L18 6M6 6l12 12"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Username availability message */}
|
||||||
|
{username.length >= 3 && usernameCheck && !usernameCheck.available && (
|
||||||
|
<p className="mt-1 text-xs text-red-500">{usernameCheck.reason}</p>
|
||||||
|
)}
|
||||||
|
{username.length >= 3 && usernameCheck?.available && (
|
||||||
|
<p className="mt-1 text-xs text-green-600">Username available</p>
|
||||||
|
)}
|
||||||
|
{username && (
|
||||||
|
<p className="mt-1 text-xs text-foreground-tertiary">
|
||||||
|
Your profile: tpmjs.com/@{username}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="email" className="block text-sm font-medium text-foreground mb-1">
|
<label htmlFor="email" className="block text-sm font-medium text-foreground mb-1">
|
||||||
Email
|
Email
|
||||||
|
|
@ -102,7 +290,7 @@ export default function SignUpPage() {
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading}
|
disabled={loading || (usernameCheck !== null && !usernameCheck.available)}
|
||||||
className="w-full py-2 px-4 bg-foreground text-background font-medium rounded-md hover:opacity-90 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-foreground disabled:opacity-50 disabled:cursor-not-allowed"
|
className="w-full py-2 px-4 bg-foreground text-background font-medium rounded-md hover:opacity-90 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-foreground disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
{loading ? 'Creating account...' : 'Create Account'}
|
{loading ? 'Creating account...' : 'Create Account'}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,63 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { notFound, useParams, useRouter } from 'next/navigation';
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chat page for pretty URLs.
|
||||||
|
* This redirects to the existing chat system with the agent ID.
|
||||||
|
*/
|
||||||
|
export default function PrettyChatPage(): React.ReactElement {
|
||||||
|
const params = useParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const rawUsername = params.username as string;
|
||||||
|
const username = rawUsername.startsWith('@') ? rawUsername.slice(1) : rawUsername;
|
||||||
|
const uid = params.uid as string;
|
||||||
|
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [notFoundError, setNotFoundError] = useState(false);
|
||||||
|
|
||||||
|
const resolveAndRedirect = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
// Fetch the agent to get its ID
|
||||||
|
const response = await fetch(`/api/public/users/${username}/agents/${uid}`);
|
||||||
|
|
||||||
|
if (response.status === 404) {
|
||||||
|
setNotFoundError(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success && data.data?.id) {
|
||||||
|
// Redirect to the agent chat page using the agent ID
|
||||||
|
router.replace(`/agents/${data.data.id}/chat`);
|
||||||
|
} else {
|
||||||
|
setNotFoundError(true);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setNotFoundError(true);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [username, uid, router]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
resolveAndRedirect();
|
||||||
|
}, [resolveAndRedirect]);
|
||||||
|
|
||||||
|
if (notFoundError) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-foreground" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returning empty fragment while redirect happens
|
||||||
|
return <></>;
|
||||||
|
}
|
||||||
230
apps/web/src/app/(profile)/[username]/agents/[uid]/page.tsx
Normal file
230
apps/web/src/app/(profile)/[username]/agents/[uid]/page.tsx
Normal file
|
|
@ -0,0 +1,230 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
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 { 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 AgentTool {
|
||||||
|
id: string;
|
||||||
|
toolId: string;
|
||||||
|
position: number;
|
||||||
|
tool: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
package: {
|
||||||
|
npmPackageName: string;
|
||||||
|
category: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AgentCollection {
|
||||||
|
id: string;
|
||||||
|
collectionId: string;
|
||||||
|
collection: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
toolCount: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PublicAgent {
|
||||||
|
id: string;
|
||||||
|
uid: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
provider: string;
|
||||||
|
modelId: string;
|
||||||
|
systemPrompt: string | null;
|
||||||
|
temperature: number;
|
||||||
|
likeCount: number;
|
||||||
|
toolCount: number;
|
||||||
|
collectionCount: number;
|
||||||
|
createdAt: string;
|
||||||
|
createdBy: {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
name: string;
|
||||||
|
image: string | null;
|
||||||
|
};
|
||||||
|
tools: AgentTool[];
|
||||||
|
collections: AgentCollection[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PrettyAgentDetailPage(): React.ReactElement {
|
||||||
|
const params = useParams();
|
||||||
|
const rawUsername = params.username as string;
|
||||||
|
const username = rawUsername.startsWith('@') ? rawUsername.slice(1) : rawUsername;
|
||||||
|
const uid = params.uid as string;
|
||||||
|
|
||||||
|
const [agent, setAgent] = useState<PublicAgent | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchAgent = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/public/users/${username}/agents/${uid}`);
|
||||||
|
if (response.status === 404) {
|
||||||
|
setError('not_found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
setAgent(data.data);
|
||||||
|
} else {
|
||||||
|
setError(data.error?.message || 'Failed to load agent');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError('Failed to load agent');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [username, uid]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchAgent();
|
||||||
|
}, [fetchAgent]);
|
||||||
|
|
||||||
|
if (error === 'not_found') {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background">
|
||||||
|
<AppHeader />
|
||||||
|
|
||||||
|
<main className="max-w-5xl mx-auto px-4 py-8">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex justify-center py-12">
|
||||||
|
<Icon icon="loader" className="w-8 h-8 animate-spin text-foreground-secondary" />
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<p className="text-red-500">{error}</p>
|
||||||
|
</div>
|
||||||
|
) : agent ? (
|
||||||
|
<div className="space-y-8">
|
||||||
|
{/* Agent Header */}
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<h1 className="text-2xl font-bold text-foreground">{agent.name}</h1>
|
||||||
|
<Badge variant="outline">{agent.provider}</Badge>
|
||||||
|
</div>
|
||||||
|
{agent.description && (
|
||||||
|
<p className="text-foreground-secondary">{agent.description}</p>
|
||||||
|
)}
|
||||||
|
<Link
|
||||||
|
href={`/${username}`}
|
||||||
|
className="text-sm text-foreground-tertiary hover:text-foreground-secondary mt-2 inline-flex items-center gap-1"
|
||||||
|
>
|
||||||
|
by @{agent.createdBy.username}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<LikeButton entityType="agent" entityId={agent.id} initialCount={agent.likeCount} />
|
||||||
|
<CloneButton type="agent" sourceId={agent.id} sourceName={agent.name} />
|
||||||
|
<Link href={`/${username}/agents/${uid}/chat`}>
|
||||||
|
<Button>
|
||||||
|
<Icon icon="message" className="w-4 h-4 mr-2" />
|
||||||
|
Chat
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="flex items-center gap-6 text-sm text-foreground-secondary">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Icon icon="puzzle" className="w-4 h-4" />
|
||||||
|
{agent.toolCount} tools
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Icon icon="folder" className="w-4 h-4" />
|
||||||
|
{agent.collectionCount} collections
|
||||||
|
</span>
|
||||||
|
<span>Model: {agent.modelId}</span>
|
||||||
|
<span>Temperature: {agent.temperature}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* System Prompt */}
|
||||||
|
{agent.systemPrompt && (
|
||||||
|
<section>
|
||||||
|
<h2 className="text-lg font-semibold text-foreground mb-2">System Prompt</h2>
|
||||||
|
<div className="bg-surface border border-border rounded-lg p-4">
|
||||||
|
<pre className="text-sm text-foreground-secondary whitespace-pre-wrap font-mono">
|
||||||
|
{agent.systemPrompt}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tools */}
|
||||||
|
{agent.tools.length > 0 && (
|
||||||
|
<section>
|
||||||
|
<h2 className="text-lg font-semibold text-foreground mb-4">Tools</h2>
|
||||||
|
<div className="grid gap-3">
|
||||||
|
{agent.tools.map((at) => (
|
||||||
|
<Link
|
||||||
|
key={at.id}
|
||||||
|
href={`/tool/${at.tool.package.npmPackageName}/${at.tool.name}`}
|
||||||
|
className="block p-4 bg-surface border border-border rounded-lg hover:border-foreground-secondary transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-medium text-foreground">{at.tool.name}</h3>
|
||||||
|
<p className="text-sm text-foreground-secondary mt-1 line-clamp-2">
|
||||||
|
{at.tool.description}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2 mt-2">
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
{at.tool.package.category}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-xs text-foreground-tertiary">
|
||||||
|
{at.tool.package.npmPackageName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Collections */}
|
||||||
|
{agent.collections.length > 0 && (
|
||||||
|
<section>
|
||||||
|
<h2 className="text-lg font-semibold text-foreground mb-4">Collections</h2>
|
||||||
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
|
{agent.collections.map((ac) => (
|
||||||
|
<div key={ac.id} className="p-4 bg-surface border border-border rounded-lg">
|
||||||
|
<h3 className="font-medium text-foreground">{ac.collection.name}</h3>
|
||||||
|
{ac.collection.description && (
|
||||||
|
<p className="text-sm text-foreground-secondary mt-1 line-clamp-2">
|
||||||
|
{ac.collection.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<span className="text-xs text-foreground-tertiary mt-2 inline-block">
|
||||||
|
{ac.collection.toolCount} tools
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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<PublicCollection | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(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 (
|
||||||
|
<div className="min-h-screen bg-background">
|
||||||
|
<AppHeader />
|
||||||
|
|
||||||
|
<main className="max-w-5xl mx-auto px-4 py-8">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex justify-center py-12">
|
||||||
|
<Icon icon="loader" className="w-8 h-8 animate-spin text-foreground-secondary" />
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<p className="text-red-500">{error}</p>
|
||||||
|
</div>
|
||||||
|
) : collection ? (
|
||||||
|
<div className="space-y-8">
|
||||||
|
{/* Collection Header */}
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-foreground">{collection.name}</h1>
|
||||||
|
{collection.description && (
|
||||||
|
<p className="text-foreground-secondary mt-2">{collection.description}</p>
|
||||||
|
)}
|
||||||
|
<Link
|
||||||
|
href={`/${username}`}
|
||||||
|
className="text-sm text-foreground-tertiary hover:text-foreground-secondary mt-2 inline-flex items-center gap-1"
|
||||||
|
>
|
||||||
|
by @{collection.createdBy.username}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<LikeButton
|
||||||
|
entityType="collection"
|
||||||
|
entityId={collection.id}
|
||||||
|
initialCount={collection.likeCount}
|
||||||
|
/>
|
||||||
|
<CloneButton
|
||||||
|
type="collection"
|
||||||
|
sourceId={collection.id}
|
||||||
|
sourceName={collection.name}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="flex items-center gap-6 text-sm text-foreground-secondary">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Icon icon="puzzle" className="w-4 h-4" />
|
||||||
|
{collection.toolCount} tools
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Icon icon="heart" className="w-4 h-4" />
|
||||||
|
{collection.likeCount} likes
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tools */}
|
||||||
|
{collection.tools.length > 0 ? (
|
||||||
|
<section>
|
||||||
|
<h2 className="text-lg font-semibold text-foreground mb-4">Tools in Collection</h2>
|
||||||
|
<div className="grid gap-3">
|
||||||
|
{collection.tools.map((ct) => (
|
||||||
|
<Link
|
||||||
|
key={ct.id}
|
||||||
|
href={`/tool/${ct.tool.package.npmPackageName}/${ct.tool.name}`}
|
||||||
|
className="block p-4 bg-surface border border-border rounded-lg hover:border-foreground-secondary transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-medium text-foreground">{ct.tool.name}</h3>
|
||||||
|
<p className="text-sm text-foreground-secondary mt-1 line-clamp-2">
|
||||||
|
{ct.tool.description}
|
||||||
|
</p>
|
||||||
|
{ct.note && (
|
||||||
|
<p className="text-xs text-foreground-tertiary italic mt-2">
|
||||||
|
Note: {ct.note}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-2 mt-2">
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
{ct.tool.package.category}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-xs text-foreground-tertiary">
|
||||||
|
{ct.tool.package.npmPackageName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 text-xs text-foreground-tertiary">
|
||||||
|
<Icon icon="heart" className="w-3.5 h-3.5" />
|
||||||
|
{ct.tool.likeCount}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<Icon icon="box" className="w-12 h-12 mx-auto text-foreground-secondary mb-4" />
|
||||||
|
<p className="text-foreground-secondary">This collection is empty.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
185
apps/web/src/app/(profile)/[username]/page.tsx
Normal file
185
apps/web/src/app/(profile)/[username]/page.tsx
Normal file
|
|
@ -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<UserProfile | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(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 (
|
||||||
|
<div className="min-h-screen bg-background">
|
||||||
|
<AppHeader />
|
||||||
|
|
||||||
|
<main className="max-w-5xl mx-auto px-4 py-8">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex justify-center py-12">
|
||||||
|
<Icon icon="loader" className="w-8 h-8 animate-spin text-foreground-secondary" />
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<p className="text-red-500">{error}</p>
|
||||||
|
</div>
|
||||||
|
) : profile ? (
|
||||||
|
<div className="space-y-8">
|
||||||
|
{/* User Header */}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{profile.image ? (
|
||||||
|
<img src={profile.image} alt={profile.name} className="w-20 h-20 rounded-full" />
|
||||||
|
) : (
|
||||||
|
<div className="w-20 h-20 rounded-full bg-foreground-secondary/20 flex items-center justify-center">
|
||||||
|
<Icon icon="user" className="w-10 h-10 text-foreground-secondary" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-foreground">{profile.name}</h1>
|
||||||
|
<p className="text-foreground-secondary">@{profile.username}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Public Agents */}
|
||||||
|
{profile.agents.length > 0 && (
|
||||||
|
<section>
|
||||||
|
<h2 className="text-xl font-semibold text-foreground mb-4">Public Agents</h2>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
{profile.agents.map((agent) => (
|
||||||
|
<Link
|
||||||
|
key={agent.id}
|
||||||
|
href={`/${username}/agents/${agent.uid}`}
|
||||||
|
className="block p-4 bg-surface border border-border rounded-lg hover:border-foreground-secondary transition-colors"
|
||||||
|
>
|
||||||
|
<h3 className="font-medium text-foreground">{agent.name}</h3>
|
||||||
|
{agent.description && (
|
||||||
|
<p className="text-sm text-foreground-secondary mt-1 line-clamp-2">
|
||||||
|
{agent.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-3 mt-3 text-xs text-foreground-tertiary">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Icon icon="heart" className="w-3.5 h-3.5" />
|
||||||
|
{agent.likeCount}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Icon icon="puzzle" className="w-3.5 h-3.5" />
|
||||||
|
{agent.toolCount} tools
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Public Collections */}
|
||||||
|
{profile.collections.length > 0 && (
|
||||||
|
<section>
|
||||||
|
<h2 className="text-xl font-semibold text-foreground mb-4">Public Collections</h2>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
{profile.collections.map((collection) => (
|
||||||
|
<Link
|
||||||
|
key={collection.id}
|
||||||
|
href={`/${username}/collections/${collection.slug}`}
|
||||||
|
className="block p-4 bg-surface border border-border rounded-lg hover:border-foreground-secondary transition-colors"
|
||||||
|
>
|
||||||
|
<h3 className="font-medium text-foreground">{collection.name}</h3>
|
||||||
|
{collection.description && (
|
||||||
|
<p className="text-sm text-foreground-secondary mt-1 line-clamp-2">
|
||||||
|
{collection.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-3 mt-3 text-xs text-foreground-tertiary">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Icon icon="heart" className="w-3.5 h-3.5" />
|
||||||
|
{collection.likeCount}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Icon icon="puzzle" className="w-3.5 h-3.5" />
|
||||||
|
{collection.toolCount} tools
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty State */}
|
||||||
|
{profile.agents.length === 0 && profile.collections.length === 0 && (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<Icon icon="box" className="w-12 h-12 mx-auto text-foreground-secondary mb-4" />
|
||||||
|
<p className="text-foreground-secondary">
|
||||||
|
{profile.name} hasn't shared any public agents or collections yet.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -4,7 +4,7 @@ import { Badge } from '@tpmjs/ui/Badge/Badge';
|
||||||
import { Button } from '@tpmjs/ui/Button/Button';
|
import { Button } from '@tpmjs/ui/Button/Button';
|
||||||
import { Icon } from '@tpmjs/ui/Icon/Icon';
|
import { Icon } from '@tpmjs/ui/Icon/Icon';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useParams } from 'next/navigation';
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { AppHeader } from '~/components/AppHeader';
|
import { AppHeader } from '~/components/AppHeader';
|
||||||
import { LikeButton } from '~/components/LikeButton';
|
import { LikeButton } from '~/components/LikeButton';
|
||||||
|
|
@ -57,6 +57,7 @@ interface PublicAgent {
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
createdBy: {
|
createdBy: {
|
||||||
id: string;
|
id: string;
|
||||||
|
username: string | null;
|
||||||
name: string;
|
name: string;
|
||||||
image: string | null;
|
image: string | null;
|
||||||
};
|
};
|
||||||
|
|
@ -66,6 +67,7 @@ interface PublicAgent {
|
||||||
|
|
||||||
export default function PublicAgentDetailPage(): React.ReactElement {
|
export default function PublicAgentDetailPage(): React.ReactElement {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
|
const router = useRouter();
|
||||||
const agentId = params.id as string;
|
const agentId = params.id as string;
|
||||||
|
|
||||||
const [agent, setAgent] = useState<PublicAgent | null>(null);
|
const [agent, setAgent] = useState<PublicAgent | null>(null);
|
||||||
|
|
@ -78,6 +80,11 @@ export default function PublicAgentDetailPage(): React.ReactElement {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
if (data.success) {
|
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);
|
setAgent(data.data);
|
||||||
} else {
|
} else {
|
||||||
if (data.error?.code === 'NOT_FOUND' || data.error?.code === 'FORBIDDEN') {
|
if (data.error?.code === 'NOT_FOUND' || data.error?.code === 'FORBIDDEN') {
|
||||||
|
|
@ -92,7 +99,7 @@ export default function PublicAgentDetailPage(): React.ReactElement {
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}, [agentId]);
|
}, [agentId, router]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchAgent();
|
fetchAgent();
|
||||||
|
|
|
||||||
243
apps/web/src/app/api/agents/[id]/clone/route.ts
Normal file
243
apps/web/src/app/api/agents/[id]/clone/route.ts
Normal file
|
|
@ -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<string> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
201
apps/web/src/app/api/collections/[id]/clone/route.ts
Normal file
201
apps/web/src/app/api/collections/[id]/clone/route.ts
Normal file
|
|
@ -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<string> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -11,6 +11,51 @@ export const maxDuration = 60;
|
||||||
|
|
||||||
const API_VERSION = '1.0.0';
|
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<string> {
|
||||||
|
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
|
* Standard API response structure
|
||||||
*/
|
*/
|
||||||
|
|
@ -92,6 +137,7 @@ export async function GET(request: NextRequest): Promise<NextResponse<ApiRespons
|
||||||
data: data.map((c) => ({
|
data: data.map((c) => ({
|
||||||
id: c.id,
|
id: c.id,
|
||||||
name: c.name,
|
name: c.name,
|
||||||
|
slug: c.slug,
|
||||||
description: c.description,
|
description: c.description,
|
||||||
isPublic: c.isPublic,
|
isPublic: c.isPublic,
|
||||||
toolCount: c._count.tools,
|
toolCount: c._count.tools,
|
||||||
|
|
@ -200,12 +246,16 @@ export async function POST(request: NextRequest): Promise<NextResponse<ApiRespon
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Generate unique slug for the collection
|
||||||
|
const slug = await generateUniqueSlug(session.user.id, name);
|
||||||
|
|
||||||
// Create collection with auto-like (user likes their own collection)
|
// Create collection with auto-like (user likes their own collection)
|
||||||
const collection = await prisma.$transaction(async (tx) => {
|
const collection = await prisma.$transaction(async (tx) => {
|
||||||
const newCollection = await tx.collection.create({
|
const newCollection = await tx.collection.create({
|
||||||
data: {
|
data: {
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
name,
|
name,
|
||||||
|
slug,
|
||||||
description: description || null,
|
description: description || null,
|
||||||
isPublic,
|
isPublic,
|
||||||
likeCount: 1, // Start with 1 like (from owner)
|
likeCount: 1, // Start with 1 like (from owner)
|
||||||
|
|
@ -238,6 +288,7 @@ export async function POST(request: NextRequest): Promise<NextResponse<ApiRespon
|
||||||
data: {
|
data: {
|
||||||
id: collection.id,
|
id: collection.id,
|
||||||
name: collection.name,
|
name: collection.name,
|
||||||
|
slug: collection.slug,
|
||||||
description: collection.description,
|
description: collection.description,
|
||||||
isPublic: collection.isPublic,
|
isPublic: collection.isPublic,
|
||||||
toolCount: 0,
|
toolCount: 0,
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ export async function GET(
|
||||||
user: {
|
user: {
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
|
username: true,
|
||||||
name: true,
|
name: true,
|
||||||
image: true,
|
image: true,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ export async function GET(
|
||||||
user: {
|
user: {
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
|
username: true,
|
||||||
name: true,
|
name: true,
|
||||||
image: true,
|
image: true,
|
||||||
},
|
},
|
||||||
|
|
@ -94,6 +95,7 @@ export async function GET(
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
id: collection.id,
|
id: collection.id,
|
||||||
|
slug: collection.slug,
|
||||||
name: collection.name,
|
name: collection.name,
|
||||||
description: collection.description,
|
description: collection.description,
|
||||||
likeCount: collection.likeCount,
|
likeCount: collection.likeCount,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,132 @@
|
||||||
|
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; uid: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
94
apps/web/src/app/api/public/users/[username]/route.ts
Normal file
94
apps/web/src/app/api/public/users/[username]/route.ts
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
147
apps/web/src/app/api/user/profile/route.ts
Normal file
147
apps/web/src/app/api/user/profile/route.ts
Normal file
|
|
@ -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<NextResponse> {
|
||||||
|
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<NextResponse> {
|
||||||
|
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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
101
apps/web/src/app/api/user/username/check/route.ts
Normal file
101
apps/web/src/app/api/user/username/check/route.ts
Normal file
|
|
@ -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<NextResponse> {
|
||||||
|
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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,7 +4,7 @@ import { Badge } from '@tpmjs/ui/Badge/Badge';
|
||||||
import { Button } from '@tpmjs/ui/Button/Button';
|
import { Button } from '@tpmjs/ui/Button/Button';
|
||||||
import { Icon } from '@tpmjs/ui/Icon/Icon';
|
import { Icon } from '@tpmjs/ui/Icon/Icon';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useParams } from 'next/navigation';
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { AppHeader } from '~/components/AppHeader';
|
import { AppHeader } from '~/components/AppHeader';
|
||||||
import { LikeButton } from '~/components/LikeButton';
|
import { LikeButton } from '~/components/LikeButton';
|
||||||
|
|
@ -30,6 +30,7 @@ interface CollectionTool {
|
||||||
|
|
||||||
interface PublicCollection {
|
interface PublicCollection {
|
||||||
id: string;
|
id: string;
|
||||||
|
slug: string | null;
|
||||||
name: string;
|
name: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
likeCount: number;
|
likeCount: number;
|
||||||
|
|
@ -38,6 +39,7 @@ interface PublicCollection {
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
createdBy: {
|
createdBy: {
|
||||||
id: string;
|
id: string;
|
||||||
|
username: string | null;
|
||||||
name: string;
|
name: string;
|
||||||
image: string | null;
|
image: string | null;
|
||||||
};
|
};
|
||||||
|
|
@ -173,6 +175,7 @@ function McpUrlSection({ collectionId }: { collectionId: string }) {
|
||||||
|
|
||||||
export default function PublicCollectionDetailPage(): React.ReactElement {
|
export default function PublicCollectionDetailPage(): React.ReactElement {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
|
const router = useRouter();
|
||||||
const collectionId = params.id as string;
|
const collectionId = params.id as string;
|
||||||
|
|
||||||
const [collection, setCollection] = useState<PublicCollection | null>(null);
|
const [collection, setCollection] = useState<PublicCollection | null>(null);
|
||||||
|
|
@ -185,6 +188,11 @@ export default function PublicCollectionDetailPage(): React.ReactElement {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
if (data.success) {
|
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);
|
setCollection(data.data);
|
||||||
} else {
|
} else {
|
||||||
if (data.error?.code === 'NOT_FOUND' || data.error?.code === 'FORBIDDEN') {
|
if (data.error?.code === 'NOT_FOUND' || data.error?.code === 'FORBIDDEN') {
|
||||||
|
|
@ -199,7 +207,7 @@ export default function PublicCollectionDetailPage(): React.ReactElement {
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}, [collectionId]);
|
}, [collectionId, router]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchCollection();
|
fetchCollection();
|
||||||
|
|
|
||||||
|
|
@ -627,6 +627,25 @@ const result = streamText({
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* Sharing & URLs - Separate documentation page */}
|
||||||
|
<section className="mb-16 p-6 border border-border rounded-lg bg-surface">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<span className="text-3xl">🔗</span>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold text-foreground mb-2">Sharing & URLs</h2>
|
||||||
|
<p className="text-foreground-secondary mb-4">
|
||||||
|
Learn how to share your agents, collections, and profile using human-readable
|
||||||
|
URLs. Clone public agents and collections to customize them.
|
||||||
|
</p>
|
||||||
|
<Link href="/docs/sharing">
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
View Sharing Documentation →
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
{/* ==================== API REFERENCE ==================== */}
|
{/* ==================== API REFERENCE ==================== */}
|
||||||
<DocSection id="api-overview" title="API Overview">
|
<DocSection id="api-overview" title="API Overview">
|
||||||
<p className="text-foreground-secondary mb-6">
|
<p className="text-foreground-secondary mb-6">
|
||||||
|
|
|
||||||
654
apps/web/src/app/docs/sharing/page.tsx
Normal file
654
apps/web/src/app/docs/sharing/page.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<nav className="space-y-6">
|
||||||
|
{NAV_SECTIONS.map((section) => (
|
||||||
|
<div key={section.title}>
|
||||||
|
<h3 className="text-xs font-semibold uppercase tracking-wider text-foreground-tertiary mb-2">
|
||||||
|
{section.title}
|
||||||
|
</h3>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{section.items.map((item) => (
|
||||||
|
<li key={item.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSectionClick(item.id)}
|
||||||
|
className={`block w-full text-left px-3 py-1.5 text-sm rounded-md transition-colors ${
|
||||||
|
activeSection === item.id
|
||||||
|
? 'bg-primary/10 text-primary font-medium'
|
||||||
|
: 'text-foreground-secondary hover:text-foreground hover:bg-surface-elevated'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DocSection({
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<section id={id} className="scroll-mt-24 mb-16">
|
||||||
|
<h2 className="text-2xl font-bold mb-6 text-foreground pb-3 border-b border-border">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DocSubSection({ title, children }: { title: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="mb-8">
|
||||||
|
<h3 className="text-lg font-semibold mb-4 text-foreground">{title}</h3>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function UrlExample({
|
||||||
|
url,
|
||||||
|
description,
|
||||||
|
example,
|
||||||
|
}: {
|
||||||
|
url: string;
|
||||||
|
description: string;
|
||||||
|
example?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="p-4 border border-border rounded-lg bg-surface mb-3">
|
||||||
|
<code className="text-primary font-mono text-sm block mb-2">{url}</code>
|
||||||
|
<p className="text-sm text-foreground-secondary">{description}</p>
|
||||||
|
{example && (
|
||||||
|
<p className="text-xs text-foreground-tertiary mt-2">
|
||||||
|
Example: <code className="text-primary">{example}</code>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InfoCard({
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
icon: string;
|
||||||
|
title: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="p-5 border border-border rounded-lg bg-surface">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<span className="text-2xl flex-shrink-0">{icon}</span>
|
||||||
|
<div>
|
||||||
|
<h4 className="font-semibold text-foreground mb-1">{title}</h4>
|
||||||
|
<p className="text-sm text-foreground-secondary">{children}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="min-h-screen flex flex-col bg-background">
|
||||||
|
<AppHeader />
|
||||||
|
|
||||||
|
<div className="flex-1 flex flex-col lg:flex-row">
|
||||||
|
{/* Mobile Navigation Toggle */}
|
||||||
|
<div className="lg:hidden sticky top-0 z-30 bg-background border-b border-border px-4 py-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setMobileNavOpen(!mobileNavOpen)}
|
||||||
|
className="flex items-center gap-2 text-sm font-medium text-foreground"
|
||||||
|
>
|
||||||
|
<span className="text-lg">{mobileNavOpen ? '✕' : '☰'}</span>
|
||||||
|
<span>Sharing & URLs</span>
|
||||||
|
</button>
|
||||||
|
{mobileNavOpen && (
|
||||||
|
<div className="absolute left-0 right-0 top-full bg-background border-b border-border shadow-lg max-h-[70vh] overflow-y-auto px-4 py-4">
|
||||||
|
<SidebarNav activeSection={activeSection} onSectionClick={scrollToSection} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Desktop Sidebar */}
|
||||||
|
<aside className="hidden lg:block w-64 flex-shrink-0 border-r border-border bg-surface/50">
|
||||||
|
<div className="sticky top-0 h-screen overflow-y-auto py-8 px-4">
|
||||||
|
<div className="mb-6">
|
||||||
|
<Link
|
||||||
|
href="/docs"
|
||||||
|
className="text-sm text-foreground-secondary hover:text-foreground mb-2 block"
|
||||||
|
>
|
||||||
|
← Back to Docs
|
||||||
|
</Link>
|
||||||
|
<h2 className="text-lg font-bold text-foreground">Sharing & URLs</h2>
|
||||||
|
<p className="text-sm text-foreground-tertiary">Share your work with others</p>
|
||||||
|
</div>
|
||||||
|
<SidebarNav activeSection={activeSection} onSectionClick={scrollToSection} />
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<main className="flex-1 min-w-0">
|
||||||
|
<div className="max-w-4xl mx-auto px-4 sm:px-6 py-8 sm:py-12">
|
||||||
|
{/* Hero */}
|
||||||
|
<div className="mb-12">
|
||||||
|
<h1 className="text-2xl sm:text-3xl md:text-4xl font-bold mb-4 text-foreground">
|
||||||
|
Sharing & URLs
|
||||||
|
</h1>
|
||||||
|
<p className="text-xl text-foreground-secondary mb-6">
|
||||||
|
Learn how to share your agents, collections, and profile with others using
|
||||||
|
human-readable URLs.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<Link href="/dashboard/settings">
|
||||||
|
<Button variant="default" size="sm">
|
||||||
|
Edit Your Profile
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<Link href="/dashboard/agents">
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
Manage Agents
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ==================== URLS ==================== */}
|
||||||
|
<DocSection id="overview" title="Overview">
|
||||||
|
<p className="text-foreground-secondary mb-6">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
||||||
|
<InfoCard icon="👤" title="User Profiles">
|
||||||
|
Share your profile page showing all your public agents and collections
|
||||||
|
</InfoCard>
|
||||||
|
<InfoCard icon="🤖" title="Agents">
|
||||||
|
Share individual agents so others can chat with them or clone them
|
||||||
|
</InfoCard>
|
||||||
|
<InfoCard icon="📦" title="Collections">
|
||||||
|
Share tool collections for easy MCP server setup
|
||||||
|
</InfoCard>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 border border-primary/30 rounded-lg bg-primary/5">
|
||||||
|
<p className="text-sm text-foreground-secondary">
|
||||||
|
<strong className="text-foreground">Note:</strong> Only public agents and
|
||||||
|
collections are visible to others. You can control visibility in the settings for
|
||||||
|
each item.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</DocSection>
|
||||||
|
|
||||||
|
<DocSection id="user-profiles" title="User Profiles">
|
||||||
|
<p className="text-foreground-secondary mb-6">
|
||||||
|
Your profile page displays your name, avatar, and all your public agents and
|
||||||
|
collections.
|
||||||
|
</p>
|
||||||
|
<DocSubSection title="Profile URL">
|
||||||
|
<UrlExample
|
||||||
|
url="tpmjs.com/{username}"
|
||||||
|
description="Your public profile page. Shows all your public agents and collections."
|
||||||
|
example="tpmjs.com/ajax"
|
||||||
|
/>
|
||||||
|
<p className="text-foreground-secondary mb-4">
|
||||||
|
You can also use the @ prefix for social-media style URLs:
|
||||||
|
</p>
|
||||||
|
<UrlExample
|
||||||
|
url="tpmjs.com/@{username}"
|
||||||
|
description="Alternative format with @ prefix. Works identically to the version without @."
|
||||||
|
example="tpmjs.com/@ajax"
|
||||||
|
/>
|
||||||
|
</DocSubSection>
|
||||||
|
<DocSubSection title="Choosing a Username">
|
||||||
|
<p className="text-foreground-secondary mb-4">
|
||||||
|
Usernames must be 3-30 characters and can contain lowercase letters, numbers, and
|
||||||
|
hyphens. They cannot start or end with a hyphen.
|
||||||
|
</p>
|
||||||
|
<CodeBlock
|
||||||
|
language="text"
|
||||||
|
code={`Valid usernames:
|
||||||
|
- ajax
|
||||||
|
- john-doe
|
||||||
|
- dev123
|
||||||
|
- my-cool-name
|
||||||
|
|
||||||
|
Invalid usernames:
|
||||||
|
- -invalid (starts with hyphen)
|
||||||
|
- also-invalid- (ends with hyphen)
|
||||||
|
- Hi (uppercase not allowed)
|
||||||
|
- ab (too short)`}
|
||||||
|
/>
|
||||||
|
</DocSubSection>
|
||||||
|
</DocSection>
|
||||||
|
|
||||||
|
<DocSection id="agents" title="Agents">
|
||||||
|
<p className="text-foreground-secondary mb-6">
|
||||||
|
Share your AI agents so others can interact with them or clone them to their own
|
||||||
|
account.
|
||||||
|
</p>
|
||||||
|
<DocSubSection title="Agent Detail Page">
|
||||||
|
<UrlExample
|
||||||
|
url="tpmjs.com/{username}/agents/{agent-uid}"
|
||||||
|
description="View an agent's details including its system prompt, attached tools, and model configuration."
|
||||||
|
example="tpmjs.com/ajax/agents/research-assistant"
|
||||||
|
/>
|
||||||
|
<p className="text-foreground-secondary">
|
||||||
|
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".
|
||||||
|
</p>
|
||||||
|
</DocSubSection>
|
||||||
|
<DocSubSection title="Chat with Agent">
|
||||||
|
<UrlExample
|
||||||
|
url="tpmjs.com/{username}/agents/{agent-uid}/chat"
|
||||||
|
description="Open a chat interface to interact with the agent directly."
|
||||||
|
example="tpmjs.com/ajax/agents/research-assistant/chat"
|
||||||
|
/>
|
||||||
|
<div className="p-4 border border-border rounded-lg bg-surface mt-4">
|
||||||
|
<p className="text-sm text-foreground-secondary">
|
||||||
|
<strong className="text-foreground">Note:</strong> Chatting with someone
|
||||||
|
else's agent uses their API keys and tool configuration. The agent owner is
|
||||||
|
responsible for any API usage costs.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</DocSubSection>
|
||||||
|
</DocSection>
|
||||||
|
|
||||||
|
<DocSection id="collections" title="Collections">
|
||||||
|
<p className="text-foreground-secondary mb-6">
|
||||||
|
Collections bundle multiple tools together for easy sharing and MCP server setup.
|
||||||
|
</p>
|
||||||
|
<DocSubSection title="Collection Page">
|
||||||
|
<UrlExample
|
||||||
|
url="tpmjs.com/{username}/collections/{collection-slug}"
|
||||||
|
description="View a collection with all its tools and MCP server URLs."
|
||||||
|
example="tpmjs.com/ajax/collections/web-scraping-tools"
|
||||||
|
/>
|
||||||
|
<p className="text-foreground-secondary">
|
||||||
|
Collection pages include ready-to-use MCP server URLs that others can copy into
|
||||||
|
their Claude Desktop or Cursor configuration.
|
||||||
|
</p>
|
||||||
|
</DocSubSection>
|
||||||
|
<DocSubSection title="MCP Server URLs">
|
||||||
|
<p className="text-foreground-secondary mb-4">
|
||||||
|
Each collection provides HTTP and SSE transport URLs:
|
||||||
|
</p>
|
||||||
|
<CodeBlock
|
||||||
|
language="json"
|
||||||
|
code={`{
|
||||||
|
"mcpServers": {
|
||||||
|
"tpmjs-collection": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"mcp-remote",
|
||||||
|
"https://tpmjs.com/api/collections/{collection-id}/mcp/http"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</DocSubSection>
|
||||||
|
</DocSection>
|
||||||
|
|
||||||
|
<DocSection id="tools" title="Tools">
|
||||||
|
<p className="text-foreground-secondary mb-6">
|
||||||
|
Tools in the registry have URLs based on their npm package name and tool name.
|
||||||
|
</p>
|
||||||
|
<DocSubSection title="Tool Page">
|
||||||
|
<UrlExample
|
||||||
|
url="tpmjs.com/tool/{package-name}/{tool-name}"
|
||||||
|
description="View a tool's documentation, parameters, and usage examples."
|
||||||
|
example="tpmjs.com/tool/@anthropic-ai/mcp-fetch/fetch"
|
||||||
|
/>
|
||||||
|
<p className="text-foreground-secondary">
|
||||||
|
Tools are not user-owned - they come from npm packages published with the{' '}
|
||||||
|
<code className="text-primary bg-surface px-1.5 py-0.5 rounded">tpmjs</code>{' '}
|
||||||
|
keyword.
|
||||||
|
</p>
|
||||||
|
</DocSubSection>
|
||||||
|
</DocSection>
|
||||||
|
|
||||||
|
{/* ==================== CLONING ==================== */}
|
||||||
|
<DocSection id="clone-agents" title="Clone Agents">
|
||||||
|
<p className="text-foreground-secondary mb-6">
|
||||||
|
When you find a public agent you like, you can clone it to your own account to
|
||||||
|
customize it.
|
||||||
|
</p>
|
||||||
|
<DocSubSection title="How to Clone">
|
||||||
|
<div className="space-y-4 text-foreground-secondary">
|
||||||
|
<p>
|
||||||
|
1. Navigate to a public agent's detail page (e.g.,{' '}
|
||||||
|
<code className="text-primary bg-surface px-1.5 py-0.5 rounded">
|
||||||
|
tpmjs.com/ajax/agents/research-assistant
|
||||||
|
</code>
|
||||||
|
)
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
2. Click the <strong className="text-foreground">"Clone"</strong>{' '}
|
||||||
|
button in the header
|
||||||
|
</p>
|
||||||
|
<p>3. The agent is copied to your account with all its tools and settings</p>
|
||||||
|
<p>
|
||||||
|
4. You'll be redirected to your dashboard where you can customize the
|
||||||
|
cloned agent
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</DocSubSection>
|
||||||
|
<DocSubSection title="What Gets Cloned">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="p-4 border border-border rounded-lg bg-surface">
|
||||||
|
<h4 className="font-semibold text-foreground mb-2 flex items-center gap-2">
|
||||||
|
<Badge variant="success" size="sm">
|
||||||
|
Included
|
||||||
|
</Badge>
|
||||||
|
</h4>
|
||||||
|
<ul className="text-sm text-foreground-secondary space-y-1">
|
||||||
|
<li>• Name and description</li>
|
||||||
|
<li>• System prompt</li>
|
||||||
|
<li>• Provider and model settings</li>
|
||||||
|
<li>• Temperature and other parameters</li>
|
||||||
|
<li>• All attached tools</li>
|
||||||
|
<li>• All attached collections</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 border border-border rounded-lg bg-surface">
|
||||||
|
<h4 className="font-semibold text-foreground mb-2 flex items-center gap-2">
|
||||||
|
<Badge variant="secondary" size="sm">
|
||||||
|
Not Included
|
||||||
|
</Badge>
|
||||||
|
</h4>
|
||||||
|
<ul className="text-sm text-foreground-secondary space-y-1">
|
||||||
|
<li>• Conversation history</li>
|
||||||
|
<li>• API keys (you use your own)</li>
|
||||||
|
<li>• Like count</li>
|
||||||
|
<li>• Original owner attribution</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DocSubSection>
|
||||||
|
</DocSection>
|
||||||
|
|
||||||
|
<DocSection id="clone-collections" title="Clone Collections">
|
||||||
|
<p className="text-foreground-secondary mb-6">
|
||||||
|
Clone collections to get a copy you can modify without affecting the original.
|
||||||
|
</p>
|
||||||
|
<DocSubSection title="How to Clone">
|
||||||
|
<div className="space-y-4 text-foreground-secondary">
|
||||||
|
<p>
|
||||||
|
1. Navigate to a public collection's detail page (e.g.,{' '}
|
||||||
|
<code className="text-primary bg-surface px-1.5 py-0.5 rounded">
|
||||||
|
tpmjs.com/ajax/collections/web-scraping
|
||||||
|
</code>
|
||||||
|
)
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
2. Click the <strong className="text-foreground">"Clone"</strong>{' '}
|
||||||
|
button
|
||||||
|
</p>
|
||||||
|
<p>3. The collection is copied to your account with all its tools</p>
|
||||||
|
<p>4. You can then add, remove, or reorder tools as you like</p>
|
||||||
|
</div>
|
||||||
|
</DocSubSection>
|
||||||
|
<DocSubSection title="What Gets Cloned">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="p-4 border border-border rounded-lg bg-surface">
|
||||||
|
<h4 className="font-semibold text-foreground mb-2 flex items-center gap-2">
|
||||||
|
<Badge variant="success" size="sm">
|
||||||
|
Included
|
||||||
|
</Badge>
|
||||||
|
</h4>
|
||||||
|
<ul className="text-sm text-foreground-secondary space-y-1">
|
||||||
|
<li>• Name and description</li>
|
||||||
|
<li>• All tools in the collection</li>
|
||||||
|
<li>• Tool order</li>
|
||||||
|
<li>• Tool notes</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 border border-border rounded-lg bg-surface">
|
||||||
|
<h4 className="font-semibold text-foreground mb-2 flex items-center gap-2">
|
||||||
|
<Badge variant="secondary" size="sm">
|
||||||
|
Not Included
|
||||||
|
</Badge>
|
||||||
|
</h4>
|
||||||
|
<ul className="text-sm text-foreground-secondary space-y-1">
|
||||||
|
<li>• Like count</li>
|
||||||
|
<li>• Original owner attribution</li>
|
||||||
|
<li>• MCP server URLs (new ones generated)</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DocSubSection>
|
||||||
|
</DocSection>
|
||||||
|
|
||||||
|
{/* ==================== REFERENCE ==================== */}
|
||||||
|
<DocSection id="url-reference" title="URL Reference">
|
||||||
|
<p className="text-foreground-secondary mb-6">
|
||||||
|
Complete reference of all shareable URLs on TPMJS.
|
||||||
|
</p>
|
||||||
|
<div className="overflow-x-auto border border-border rounded-lg">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border bg-surface">
|
||||||
|
<th className="text-left py-3 px-4 text-foreground font-medium">Type</th>
|
||||||
|
<th className="text-left py-3 px-4 text-foreground font-medium">
|
||||||
|
URL Pattern
|
||||||
|
</th>
|
||||||
|
<th className="text-left py-3 px-4 text-foreground font-medium">Example</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr className="border-b border-border">
|
||||||
|
<td className="py-3 px-4 text-foreground">User Profile</td>
|
||||||
|
<td className="py-3 px-4 font-mono text-primary text-xs">/{'{username}'}</td>
|
||||||
|
<td className="py-3 px-4 font-mono text-foreground-secondary text-xs">
|
||||||
|
/ajax
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr className="border-b border-border">
|
||||||
|
<td className="py-3 px-4 text-foreground">User Profile (@)</td>
|
||||||
|
<td className="py-3 px-4 font-mono text-primary text-xs">/@{'{username}'}</td>
|
||||||
|
<td className="py-3 px-4 font-mono text-foreground-secondary text-xs">
|
||||||
|
/@ajax
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr className="border-b border-border">
|
||||||
|
<td className="py-3 px-4 text-foreground">Agent Detail</td>
|
||||||
|
<td className="py-3 px-4 font-mono text-primary text-xs">
|
||||||
|
/{'{username}'}/agents/{'{uid}'}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4 font-mono text-foreground-secondary text-xs">
|
||||||
|
/ajax/agents/research-bot
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr className="border-b border-border">
|
||||||
|
<td className="py-3 px-4 text-foreground">Agent Chat</td>
|
||||||
|
<td className="py-3 px-4 font-mono text-primary text-xs">
|
||||||
|
/{'{username}'}/agents/{'{uid}'}/chat
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4 font-mono text-foreground-secondary text-xs">
|
||||||
|
/ajax/agents/research-bot/chat
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr className="border-b border-border">
|
||||||
|
<td className="py-3 px-4 text-foreground">Collection</td>
|
||||||
|
<td className="py-3 px-4 font-mono text-primary text-xs">
|
||||||
|
/{'{username}'}/collections/{'{slug}'}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4 font-mono text-foreground-secondary text-xs">
|
||||||
|
/ajax/collections/web-tools
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td className="py-3 px-4 text-foreground">Tool</td>
|
||||||
|
<td className="py-3 px-4 font-mono text-primary text-xs">
|
||||||
|
/tool/{'{package}'}/{'{tool}'}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4 font-mono text-foreground-secondary text-xs">
|
||||||
|
/tool/@firecrawl/ai-sdk/scrape
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</DocSection>
|
||||||
|
|
||||||
|
<DocSection id="visibility" title="Visibility Settings">
|
||||||
|
<p className="text-foreground-secondary mb-6">
|
||||||
|
Control who can see your agents and collections.
|
||||||
|
</p>
|
||||||
|
<DocSubSection title="Public vs Private">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="p-4 border border-border rounded-lg bg-surface">
|
||||||
|
<h4 className="font-semibold text-foreground mb-2">Public</h4>
|
||||||
|
<ul className="text-sm text-foreground-secondary space-y-1">
|
||||||
|
<li>• Visible on your profile page</li>
|
||||||
|
<li>• Anyone can view the detail page</li>
|
||||||
|
<li>• Can be cloned by other users</li>
|
||||||
|
<li>• Shows up in search results</li>
|
||||||
|
<li>• Others can chat with public agents</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 border border-border rounded-lg bg-surface">
|
||||||
|
<h4 className="font-semibold text-foreground mb-2">Private</h4>
|
||||||
|
<ul className="text-sm text-foreground-secondary space-y-1">
|
||||||
|
<li>• Only visible to you</li>
|
||||||
|
<li>• Not shown on profile</li>
|
||||||
|
<li>• Cannot be cloned</li>
|
||||||
|
<li>• Direct URL returns 404 for others</li>
|
||||||
|
<li>• Only you can chat with the agent</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DocSubSection>
|
||||||
|
<DocSubSection title="Changing Visibility">
|
||||||
|
<p className="text-foreground-secondary mb-4">
|
||||||
|
To change an item's visibility:
|
||||||
|
</p>
|
||||||
|
<div className="space-y-3 text-foreground-secondary">
|
||||||
|
<p>
|
||||||
|
1. Go to your dashboard (<strong>Dashboard → Agents</strong> or{' '}
|
||||||
|
<strong>Dashboard → Collections</strong>)
|
||||||
|
</p>
|
||||||
|
<p>2. Click on the item you want to modify</p>
|
||||||
|
<p>
|
||||||
|
3. Toggle the <strong>"Public"</strong> switch
|
||||||
|
</p>
|
||||||
|
<p>4. Changes take effect immediately</p>
|
||||||
|
</div>
|
||||||
|
</DocSubSection>
|
||||||
|
</DocSection>
|
||||||
|
|
||||||
|
{/* CTA */}
|
||||||
|
<section className="text-center py-12 border border-border rounded-lg bg-surface">
|
||||||
|
<h2 className="text-2xl font-bold mb-4 text-foreground">Start Sharing</h2>
|
||||||
|
<p className="text-foreground-secondary mb-6 max-w-xl mx-auto">
|
||||||
|
Create public agents and collections to share your work with the community.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-4 justify-center">
|
||||||
|
<Link href="/dashboard/agents/new">
|
||||||
|
<Button variant="default" size="lg">
|
||||||
|
Create an Agent
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<Link href="/dashboard/collections/new">
|
||||||
|
<Button variant="outline" size="lg">
|
||||||
|
Create a Collection
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
85
apps/web/src/components/CloneButton.tsx
Normal file
85
apps/web/src/components/CloneButton.tsx
Normal file
|
|
@ -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<string | null>(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 (
|
||||||
|
<div className={className}>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleClone}
|
||||||
|
disabled={isCloning}
|
||||||
|
title={`Clone this ${type} to your account`}
|
||||||
|
>
|
||||||
|
{isCloning ? (
|
||||||
|
<Icon icon="loader" className="w-4 h-4 animate-spin mr-2" />
|
||||||
|
) : (
|
||||||
|
<Icon icon="copy" className="w-4 h-4 mr-2" />
|
||||||
|
)}
|
||||||
|
Clone
|
||||||
|
</Button>
|
||||||
|
{error && <p className="text-xs text-red-500 mt-1">{error}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -46,6 +46,7 @@ export const ACTIVITY_MESSAGES: Record<
|
||||||
AGENT_CREATED: (name) => `Created agent "${name}"`,
|
AGENT_CREATED: (name) => `Created agent "${name}"`,
|
||||||
AGENT_UPDATED: (name) => `Updated agent "${name}"`,
|
AGENT_UPDATED: (name) => `Updated agent "${name}"`,
|
||||||
AGENT_DELETED: (name) => `Deleted agent "${name}"`,
|
AGENT_DELETED: (name) => `Deleted agent "${name}"`,
|
||||||
|
AGENT_CLONED: (name) => `Cloned agent "${name}"`,
|
||||||
AGENT_TOOL_ADDED: (name, meta) =>
|
AGENT_TOOL_ADDED: (name, meta) =>
|
||||||
meta?.toolName
|
meta?.toolName
|
||||||
? `Added tool "${meta.toolName}" to agent "${name}"`
|
? `Added tool "${meta.toolName}" to agent "${name}"`
|
||||||
|
|
@ -65,6 +66,7 @@ export const ACTIVITY_MESSAGES: Record<
|
||||||
COLLECTION_CREATED: (name) => `Created collection "${name}"`,
|
COLLECTION_CREATED: (name) => `Created collection "${name}"`,
|
||||||
COLLECTION_UPDATED: (name) => `Updated collection "${name}"`,
|
COLLECTION_UPDATED: (name) => `Updated collection "${name}"`,
|
||||||
COLLECTION_DELETED: (name) => `Deleted collection "${name}"`,
|
COLLECTION_DELETED: (name) => `Deleted collection "${name}"`,
|
||||||
|
COLLECTION_CLONED: (name) => `Cloned collection "${name}"`,
|
||||||
COLLECTION_TOOL_ADDED: (name, meta) =>
|
COLLECTION_TOOL_ADDED: (name, meta) =>
|
||||||
meta?.toolName
|
meta?.toolName
|
||||||
? `Added tool "${meta.toolName}" to collection "${name}"`
|
? `Added tool "${meta.toolName}" to collection "${name}"`
|
||||||
|
|
@ -88,6 +90,7 @@ export const ACTIVITY_ICONS: Record<ActivityType, string> = {
|
||||||
AGENT_CREATED: 'plus',
|
AGENT_CREATED: 'plus',
|
||||||
AGENT_UPDATED: 'pencil',
|
AGENT_UPDATED: 'pencil',
|
||||||
AGENT_DELETED: 'trash',
|
AGENT_DELETED: 'trash',
|
||||||
|
AGENT_CLONED: 'copy',
|
||||||
AGENT_TOOL_ADDED: 'link',
|
AGENT_TOOL_ADDED: 'link',
|
||||||
AGENT_TOOL_REMOVED: 'unlink',
|
AGENT_TOOL_REMOVED: 'unlink',
|
||||||
AGENT_COLLECTION_ADDED: 'folderPlus',
|
AGENT_COLLECTION_ADDED: 'folderPlus',
|
||||||
|
|
@ -95,6 +98,7 @@ export const ACTIVITY_ICONS: Record<ActivityType, string> = {
|
||||||
COLLECTION_CREATED: 'folderPlus',
|
COLLECTION_CREATED: 'folderPlus',
|
||||||
COLLECTION_UPDATED: 'pencil',
|
COLLECTION_UPDATED: 'pencil',
|
||||||
COLLECTION_DELETED: 'trash',
|
COLLECTION_DELETED: 'trash',
|
||||||
|
COLLECTION_CLONED: 'copy',
|
||||||
COLLECTION_TOOL_ADDED: 'link',
|
COLLECTION_TOOL_ADDED: 'link',
|
||||||
COLLECTION_TOOL_REMOVED: 'unlink',
|
COLLECTION_TOOL_REMOVED: 'unlink',
|
||||||
TOOL_LIKED: 'heart',
|
TOOL_LIKED: 'heart',
|
||||||
|
|
|
||||||
|
|
@ -332,6 +332,7 @@ model User {
|
||||||
email String @unique
|
email String @unique
|
||||||
emailVerified Boolean @default(false) @map("email_verified")
|
emailVerified Boolean @default(false) @map("email_verified")
|
||||||
image String?
|
image String?
|
||||||
|
username String? @unique @db.VarChar(30) // URL-friendly username (nullable for migration)
|
||||||
|
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
@ -346,6 +347,7 @@ model User {
|
||||||
agentLikes AgentLike[]
|
agentLikes AgentLike[]
|
||||||
activities UserActivity[]
|
activities UserActivity[]
|
||||||
|
|
||||||
|
@@index([username])
|
||||||
@@map("users")
|
@@map("users")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -415,6 +417,7 @@ model Collection {
|
||||||
|
|
||||||
// Collection metadata
|
// Collection metadata
|
||||||
name String @db.VarChar(100)
|
name String @db.VarChar(100)
|
||||||
|
slug String? @db.VarChar(50) // URL-friendly identifier (nullable for migration)
|
||||||
description String? @db.VarChar(500)
|
description String? @db.VarChar(500)
|
||||||
isPublic Boolean @default(false) @map("is_public")
|
isPublic Boolean @default(false) @map("is_public")
|
||||||
likeCount Int @default(0) @map("like_count")
|
likeCount Int @default(0) @map("like_count")
|
||||||
|
|
@ -428,9 +431,10 @@ model Collection {
|
||||||
agents AgentCollection[]
|
agents AgentCollection[]
|
||||||
likes CollectionLike[]
|
likes CollectionLike[]
|
||||||
|
|
||||||
// Unique constraint: user can't have duplicate collection names
|
// Unique constraint: user can't have duplicate collection slugs
|
||||||
@@unique([userId, name])
|
@@unique([userId, slug])
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
|
@@index([slug])
|
||||||
@@index([isPublic])
|
@@index([isPublic])
|
||||||
@@index([likeCount])
|
@@index([likeCount])
|
||||||
@@index([createdAt])
|
@@index([createdAt])
|
||||||
|
|
@ -726,6 +730,7 @@ enum ActivityType {
|
||||||
AGENT_CREATED
|
AGENT_CREATED
|
||||||
AGENT_UPDATED
|
AGENT_UPDATED
|
||||||
AGENT_DELETED
|
AGENT_DELETED
|
||||||
|
AGENT_CLONED
|
||||||
AGENT_TOOL_ADDED
|
AGENT_TOOL_ADDED
|
||||||
AGENT_TOOL_REMOVED
|
AGENT_TOOL_REMOVED
|
||||||
AGENT_COLLECTION_ADDED
|
AGENT_COLLECTION_ADDED
|
||||||
|
|
@ -733,6 +738,7 @@ enum ActivityType {
|
||||||
COLLECTION_CREATED
|
COLLECTION_CREATED
|
||||||
COLLECTION_UPDATED
|
COLLECTION_UPDATED
|
||||||
COLLECTION_DELETED
|
COLLECTION_DELETED
|
||||||
|
COLLECTION_CLONED
|
||||||
COLLECTION_TOOL_ADDED
|
COLLECTION_TOOL_ADDED
|
||||||
COLLECTION_TOOL_REMOVED
|
COLLECTION_TOOL_REMOVED
|
||||||
TOOL_LIKED
|
TOOL_LIKED
|
||||||
|
|
|
||||||
143
packages/db/scripts/populate-usernames-slugs.ts
Normal file
143
packages/db/scripts/populate-usernames-slugs.ts
Normal 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();
|
||||||
|
});
|
||||||
|
|
@ -32,6 +32,10 @@
|
||||||
"./agent": {
|
"./agent": {
|
||||||
"types": "./dist/agent.d.ts",
|
"types": "./dist/agent.d.ts",
|
||||||
"default": "./dist/agent.js"
|
"default": "./dist/agent.js"
|
||||||
|
},
|
||||||
|
"./user": {
|
||||||
|
"types": "./dist/user.d.ts",
|
||||||
|
"default": "./dist/user.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"files": ["dist"],
|
"files": ["dist"],
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,20 @@ export const AddToolToAgentSchema = z.object({
|
||||||
position: z.number().int().min(0).optional(),
|
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
|
// User API Key Schemas
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
@ -165,6 +179,7 @@ export type CreateAgentInput = z.infer<typeof CreateAgentSchema>;
|
||||||
export type UpdateAgentInput = z.infer<typeof UpdateAgentSchema>;
|
export type UpdateAgentInput = z.infer<typeof UpdateAgentSchema>;
|
||||||
export type AddCollectionToAgentInput = z.infer<typeof AddCollectionToAgentSchema>;
|
export type AddCollectionToAgentInput = z.infer<typeof AddCollectionToAgentSchema>;
|
||||||
export type AddToolToAgentInput = z.infer<typeof AddToolToAgentSchema>;
|
export type AddToolToAgentInput = z.infer<typeof AddToolToAgentSchema>;
|
||||||
|
export type CloneAgentInput = z.infer<typeof CloneAgentSchema>;
|
||||||
export type AddApiKeyInput = z.infer<typeof AddApiKeySchema>;
|
export type AddApiKeyInput = z.infer<typeof AddApiKeySchema>;
|
||||||
export type ApiKeyInfo = z.infer<typeof ApiKeyInfoSchema>;
|
export type ApiKeyInfo = z.infer<typeof ApiKeyInfoSchema>;
|
||||||
export type CreateConversationInput = z.infer<typeof CreateConversationSchema>;
|
export type CreateConversationInput = z.infer<typeof CreateConversationSchema>;
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,19 @@ export const ReorderToolsSchema = z.object({
|
||||||
toolIds: z.array(z.string().min(1)),
|
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)
|
// Response Types (for API responses)
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
@ -58,6 +71,7 @@ export const ReorderToolsSchema = z.object({
|
||||||
export const CollectionSchema = z.object({
|
export const CollectionSchema = z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
|
slug: z.string().nullable(),
|
||||||
description: z.string().nullable(),
|
description: z.string().nullable(),
|
||||||
isPublic: z.boolean(),
|
isPublic: z.boolean(),
|
||||||
toolCount: z.number(),
|
toolCount: z.number(),
|
||||||
|
|
@ -96,6 +110,7 @@ export type UpdateCollectionInput = z.infer<typeof UpdateCollectionSchema>;
|
||||||
export type AddToolToCollectionInput = z.infer<typeof AddToolToCollectionSchema>;
|
export type AddToolToCollectionInput = z.infer<typeof AddToolToCollectionSchema>;
|
||||||
export type UpdateCollectionToolInput = z.infer<typeof UpdateCollectionToolSchema>;
|
export type UpdateCollectionToolInput = z.infer<typeof UpdateCollectionToolSchema>;
|
||||||
export type ReorderToolsInput = z.infer<typeof ReorderToolsSchema>;
|
export type ReorderToolsInput = z.infer<typeof ReorderToolsSchema>;
|
||||||
|
export type CloneCollectionInput = z.infer<typeof CloneCollectionSchema>;
|
||||||
export type Collection = z.infer<typeof CollectionSchema>;
|
export type Collection = z.infer<typeof CollectionSchema>;
|
||||||
export type CollectionTool = z.infer<typeof CollectionToolSchema>;
|
export type CollectionTool = z.infer<typeof CollectionToolSchema>;
|
||||||
export type CollectionWithTools = z.infer<typeof CollectionWithToolsSchema>;
|
export type CollectionWithTools = z.infer<typeof CollectionWithToolsSchema>;
|
||||||
|
|
|
||||||
146
packages/types/src/user.ts
Normal file
146
packages/types/src/user.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,14 @@
|
||||||
import { defineConfig } from 'tsup';
|
import { defineConfig } from 'tsup';
|
||||||
|
|
||||||
export default defineConfig({
|
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'],
|
format: ['esm'],
|
||||||
dts: true,
|
dts: true,
|
||||||
clean: true,
|
clean: true,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue