feat: add like/love system for tools, collections, and agents

- Add ToolLike, CollectionLike, AgentLike junction tables with likeCount fields
- Create like/unlike API endpoints for all entity types
- Add user likes endpoints and public listings endpoints
- Create LikeButton component with optimistic UI updates
- Add collapsible Likes section in dashboard sidebar
- Create dashboard likes pages (overview, tools, collections, agents)
- Add public collections and agents pages with detail views
- Update AppHeader and MobileMenu with Collections/Agents navigation
- Auto-like on collection/agent creation
- Add heart icons to UI package

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Ajax Davis 2026-01-07 21:04:31 +10:00
parent b8b4e44b50
commit 54dedc3056
26 changed files with 3919 additions and 62 deletions

View file

@ -0,0 +1,305 @@
'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 { useParams } from 'next/navigation';
import { useCallback, useEffect, useState } from 'react';
import { AppHeader } from '~/components/AppHeader';
import { LikeButton } from '~/components/LikeButton';
interface AgentTool {
id: string;
toolId: string;
position: number;
addedAt: string;
tool: {
id: string;
name: string;
description: string;
likeCount: number;
package: {
id: string;
npmPackageName: string;
category: string;
};
};
}
interface AgentCollection {
id: string;
collectionId: string;
position: number;
addedAt: 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;
maxToolCallsPerTurn: number;
likeCount: number;
toolCount: number;
collectionCount: number;
createdAt: string;
updatedAt: string;
createdBy: {
id: string;
name: string;
image: string | null;
};
tools: AgentTool[];
collections: AgentCollection[];
}
export default function PublicAgentDetailPage(): React.ReactElement {
const params = useParams();
const agentId = params.id 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/agents/${agentId}`);
const data = await response.json();
if (data.success) {
setAgent(data.data);
} else {
if (data.error?.code === 'NOT_FOUND' || data.error?.code === 'FORBIDDEN') {
setError('This agent is not available or is private');
} else {
setError(data.error?.message || 'Failed to fetch agent');
}
}
} catch (err) {
console.error('Failed to fetch agent:', err);
setError('Failed to fetch agent');
} finally {
setIsLoading(false);
}
}, [agentId]);
useEffect(() => {
fetchAgent();
}, [fetchAgent]);
if (isLoading) {
return (
<div className="min-h-screen bg-background">
<AppHeader />
<main className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="animate-pulse">
<div className="h-8 bg-surface-secondary rounded w-1/2 mb-4" />
<div className="h-4 bg-surface-secondary rounded w-full mb-8" />
<div className="h-32 bg-surface-secondary rounded mb-8" />
<div className="space-y-4">
{[1, 2, 3].map((i) => (
<div key={i} className="h-24 bg-surface-secondary rounded" />
))}
</div>
</div>
</main>
</div>
);
}
if (error || !agent) {
return (
<div className="min-h-screen bg-background">
<AppHeader />
<main className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-16">
<div className="text-center">
<Icon icon="alertCircle" size="lg" className="mx-auto text-error mb-4" />
<h2 className="text-lg font-medium text-foreground mb-2">
{error || 'Agent not found'}
</h2>
<p className="text-foreground-secondary mb-4">
This agent may be private or no longer available.
</p>
<Link href="/agents">
<Button>Browse Agents</Button>
</Link>
</div>
</main>
</div>
);
}
return (
<div className="min-h-screen bg-background">
<AppHeader />
<main className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Back link */}
<Link
href="/agents"
className="inline-flex items-center gap-1 text-sm text-foreground-secondary hover:text-foreground mb-6"
>
<Icon icon="arrowLeft" size="xs" />
Back to Agents
</Link>
{/* Header */}
<div className="flex items-start justify-between mb-6">
<div>
<h1 className="text-3xl font-bold text-foreground mb-2">{agent.name}</h1>
{agent.description && <p className="text-foreground-secondary">{agent.description}</p>}
</div>
<LikeButton
entityType="agent"
entityId={agent.id}
initialCount={agent.likeCount}
showCount={true}
variant="outline"
/>
</div>
{/* Meta info */}
<div className="flex items-center gap-4 mb-8 text-sm text-foreground-tertiary">
<div className="flex items-center gap-2">
{agent.createdBy.image ? (
<img
src={agent.createdBy.image}
alt={agent.createdBy.name}
className="w-6 h-6 rounded-full"
/>
) : (
<div className="w-6 h-6 rounded-full bg-primary/10 flex items-center justify-center">
<Icon icon="user" size="xs" className="text-primary" />
</div>
)}
<span>Created by {agent.createdBy.name}</span>
</div>
<span></span>
<span>
{agent.toolCount} tool{agent.toolCount !== 1 ? 's' : ''}
</span>
</div>
{/* Configuration */}
<div className="bg-surface/50 border border-border rounded-lg p-6 mb-8">
<h2 className="text-lg font-semibold text-foreground mb-4">Configuration</h2>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<span className="text-sm text-foreground-tertiary">Provider</span>
<p className="font-medium text-foreground">{agent.provider}</p>
</div>
<div>
<span className="text-sm text-foreground-tertiary">Model</span>
<p className="font-medium text-foreground">{agent.modelId}</p>
</div>
<div>
<span className="text-sm text-foreground-tertiary">Temperature</span>
<p className="font-medium text-foreground">{agent.temperature}</p>
</div>
<div>
<span className="text-sm text-foreground-tertiary">Max Tool Calls</span>
<p className="font-medium text-foreground">{agent.maxToolCallsPerTurn}</p>
</div>
</div>
{agent.systemPrompt && (
<div className="mt-4 pt-4 border-t border-border">
<span className="text-sm text-foreground-tertiary">System Prompt</span>
<pre className="mt-2 p-3 bg-background border border-border rounded-lg text-sm text-foreground-secondary whitespace-pre-wrap font-mono">
{agent.systemPrompt}
</pre>
</div>
)}
</div>
{/* Tools */}
<div className="mb-8">
<h2 className="text-lg font-semibold text-foreground mb-4">Tools</h2>
{agent.tools.length === 0 ? (
<div className="text-center py-12 bg-surface/50 border border-border rounded-lg">
<Icon icon="puzzle" size="lg" className="mx-auto text-foreground-tertiary mb-2" />
<p className="text-foreground-secondary">No tools configured for this agent</p>
</div>
) : (
<div className="space-y-3">
{agent.tools.map((at) => (
<div
key={at.id}
className="bg-background border border-border rounded-lg p-4 hover:border-foreground/20 transition-colors"
>
<div className="flex items-start justify-between mb-2">
<div>
<Link
href={`/tool/${at.tool.package.npmPackageName}/${at.tool.name}`}
className="font-medium text-foreground hover:text-primary transition-colors"
>
{at.tool.name}
</Link>
<span className="text-sm text-foreground-tertiary ml-2">
from {at.tool.package.npmPackageName}
</span>
</div>
<LikeButton
entityType="tool"
entityId={at.tool.id}
initialCount={at.tool.likeCount}
size="sm"
/>
</div>
<p className="text-sm text-foreground-secondary line-clamp-2 mb-2">
{at.tool.description}
</p>
<Badge variant="secondary" size="sm">
{at.tool.package.category}
</Badge>
</div>
))}
</div>
)}
</div>
{/* Collections */}
{agent.collections.length > 0 && (
<div>
<h2 className="text-lg font-semibold text-foreground mb-4">Collections</h2>
<div className="space-y-3">
{agent.collections.map((ac) => (
<div
key={ac.id}
className="bg-background border border-border rounded-lg p-4 hover:border-foreground/20 transition-colors"
>
<Link
href={`/collections/${ac.collection.id}`}
className="font-medium text-foreground hover:text-primary transition-colors"
>
{ac.collection.name}
</Link>
{ac.collection.description && (
<p className="text-sm text-foreground-secondary mt-1 line-clamp-2">
{ac.collection.description}
</p>
)}
<p className="text-xs text-foreground-tertiary mt-2">
{ac.collection.toolCount} tool{ac.collection.toolCount !== 1 ? 's' : ''}
</p>
</div>
))}
</div>
</div>
)}
</main>
</div>
);
}

View file

@ -0,0 +1,247 @@
'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 { useCallback, useEffect, useState } from 'react';
import { AppHeader } from '~/components/AppHeader';
import { LikeButton } from '~/components/LikeButton';
interface PublicAgent {
id: string;
uid: string;
name: string;
description: string | null;
provider: string;
modelId: string;
likeCount: number;
toolCount: number;
collectionCount: number;
createdAt: string;
createdBy: {
id: string;
name: string;
image: string | null;
};
}
type SortOption = 'likes' | 'recent' | 'tools';
export default function PublicAgentsPage(): React.ReactElement {
const [agents, setAgents] = useState<PublicAgent[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [hasMore, setHasMore] = useState(false);
const [offset, setOffset] = useState(0);
const [search, setSearch] = useState('');
const [sort, setSort] = useState<SortOption>('likes');
const limit = 20;
const fetchAgents = useCallback(
async (currentOffset: number, resetList = false) => {
try {
const params = new URLSearchParams({
limit: String(limit),
offset: String(currentOffset),
sort,
...(search && { search }),
});
const response = await fetch(`/api/public/agents?${params}`);
const data = await response.json();
if (data.success) {
if (resetList || currentOffset === 0) {
setAgents(data.data);
} else {
setAgents((prev) => [...prev, ...data.data]);
}
setHasMore(data.pagination.hasMore);
} else {
setError(data.error?.message || 'Failed to fetch agents');
}
} catch (err) {
console.error('Failed to fetch agents:', err);
setError('Failed to fetch agents');
} finally {
setIsLoading(false);
}
},
[sort, search]
);
useEffect(() => {
setOffset(0);
setIsLoading(true);
fetchAgents(0, true);
}, [fetchAgents]);
const loadMore = () => {
const newOffset = offset + limit;
setOffset(newOffset);
fetchAgents(newOffset);
};
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
setOffset(0);
setIsLoading(true);
fetchAgents(0, true);
};
return (
<div className="min-h-screen bg-background">
<AppHeader />
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Header */}
<div className="mb-8">
<h1 className="text-3xl font-bold text-foreground mb-2">Public Agents</h1>
<p className="text-foreground-secondary">
Discover AI agents created and shared by the community
</p>
</div>
{/* Filters */}
<div className="flex flex-col sm:flex-row gap-4 mb-6">
<form onSubmit={handleSearch} className="flex-1">
<div className="relative">
<Icon
icon="search"
size="sm"
className="absolute left-3 top-1/2 -translate-y-1/2 text-foreground-tertiary"
/>
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search agents..."
className="w-full pl-10 pr-4 py-2 bg-surface border border-border rounded-lg text-foreground placeholder:text-foreground-tertiary focus:outline-none focus:ring-2 focus:ring-primary/50"
/>
</div>
</form>
<div className="flex items-center gap-2">
<span className="text-sm text-foreground-secondary">Sort:</span>
<select
value={sort}
onChange={(e) => setSort(e.target.value as SortOption)}
className="px-3 py-2 bg-surface border border-border rounded-lg text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
>
<option value="likes">Most Liked</option>
<option value="recent">Most Recent</option>
<option value="tools">Most Tools</option>
</select>
</div>
</div>
{/* Content */}
{error ? (
<div className="text-center py-16">
<Icon icon="alertCircle" size="lg" className="mx-auto text-error mb-4" />
<h2 className="text-lg font-medium text-foreground mb-2">Error</h2>
<p className="text-foreground-secondary mb-4">{error}</p>
<Button onClick={() => fetchAgents(0, true)}>Try Again</Button>
</div>
) : isLoading ? (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{[1, 2, 3, 4, 5, 6].map((i) => (
<div
key={i}
className="bg-background border border-border rounded-lg p-6 animate-pulse"
>
<div className="h-6 bg-surface-secondary rounded w-3/4 mb-3" />
<div className="h-4 bg-surface-secondary rounded w-full mb-2" />
<div className="h-4 bg-surface-secondary rounded w-2/3 mb-4" />
<div className="h-4 bg-surface-secondary rounded w-1/3" />
</div>
))}
</div>
) : agents.length === 0 ? (
<div className="text-center py-16">
<div className="w-16 h-16 rounded-full bg-primary/10 flex items-center justify-center mx-auto mb-4">
<Icon icon="terminal" size="lg" className="text-primary" />
</div>
<h2 className="text-lg font-medium text-foreground mb-2">No agents found</h2>
<p className="text-foreground-secondary">
{search ? 'Try adjusting your search terms' : 'Be the first to share a public agent!'}
</p>
</div>
) : (
<>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{agents.map((agent) => (
<div
key={agent.id}
className="bg-background border border-border rounded-lg p-6 hover:border-foreground/20 transition-colors"
>
<div className="flex items-start justify-between mb-3">
<Link
href={`/agents/${agent.id}`}
className="text-lg font-medium text-foreground hover:text-primary transition-colors"
>
{agent.name}
</Link>
<LikeButton
entityType="agent"
entityId={agent.id}
initialCount={agent.likeCount}
size="sm"
/>
</div>
{agent.description && (
<p className="text-sm text-foreground-secondary line-clamp-2 mb-3">
{agent.description}
</p>
)}
<div className="flex items-center gap-2 mb-3">
<Badge variant="secondary" size="sm">
{agent.provider}
</Badge>
<span className="text-xs text-foreground-tertiary">{agent.modelId}</span>
</div>
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-3 text-foreground-tertiary">
<span className="flex items-center gap-1">
<Icon icon="puzzle" size="xs" />
{agent.toolCount} tool{agent.toolCount !== 1 ? 's' : ''}
</span>
</div>
<div className="flex items-center gap-2">
{agent.createdBy.image ? (
<img
src={agent.createdBy.image}
alt={agent.createdBy.name}
className="w-5 h-5 rounded-full"
/>
) : (
<div className="w-5 h-5 rounded-full bg-primary/10 flex items-center justify-center">
<Icon icon="user" size="xs" className="text-primary" />
</div>
)}
<span className="text-xs text-foreground-tertiary">
{agent.createdBy.name}
</span>
</div>
</div>
</div>
))}
</div>
{hasMore && (
<div className="mt-8 text-center">
<Button variant="outline" onClick={loadMore}>
Load More
</Button>
</div>
)}
</>
)}
</main>
</div>
);
}

View file

@ -0,0 +1,280 @@
import { prisma } from '@tpmjs/db';
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';
export const maxDuration = 60;
const API_VERSION = '1.0.0';
interface ApiResponse<T = unknown> {
success: boolean;
data?: T;
error?: {
code: string;
message: string;
details?: Record<string, unknown>;
};
meta: {
version: string;
timestamp: string;
requestId?: string;
};
}
interface RouteContext {
params: Promise<{ id: string }>;
}
/**
* GET /api/agents/[id]/like
* Check if the current user has liked this agent
*/
export async function GET(
_request: NextRequest,
context: RouteContext
): Promise<NextResponse<ApiResponse>> {
const requestId = crypto.randomUUID();
const { id } = await context.params;
try {
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session) {
return NextResponse.json(
{
success: false,
error: { code: 'UNAUTHORIZED', message: 'Authentication required' },
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
},
{ status: 401 }
);
}
const like = await prisma.agentLike.findUnique({
where: {
userId_agentId: {
userId: session.user.id,
agentId: id,
},
},
});
const agent = await prisma.agent.findUnique({
where: { id },
select: { likeCount: true },
});
return NextResponse.json({
success: true,
data: {
liked: !!like,
likeCount: agent?.likeCount ?? 0,
},
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
});
} catch (error) {
console.error('[API Error] GET /api/agents/[id]/like:', error);
return NextResponse.json(
{
success: false,
error: { code: 'INTERNAL_ERROR', message: 'Failed to check like status' },
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
},
{ status: 500 }
);
}
}
/**
* POST /api/agents/[id]/like
* Like an agent
*/
export async function POST(
_request: NextRequest,
context: RouteContext
): Promise<NextResponse<ApiResponse>> {
const requestId = crypto.randomUUID();
const { id } = await context.params;
try {
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session) {
return NextResponse.json(
{
success: false,
error: { code: 'UNAUTHORIZED', message: 'Authentication required' },
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
},
{ status: 401 }
);
}
// Check agent exists
const agent = await prisma.agent.findUnique({
where: { id },
});
if (!agent) {
return NextResponse.json(
{
success: false,
error: { code: 'NOT_FOUND', message: 'Agent not found' },
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
},
{ status: 404 }
);
}
// Check if already liked
const existingLike = await prisma.agentLike.findUnique({
where: {
userId_agentId: {
userId: session.user.id,
agentId: id,
},
},
});
if (existingLike) {
return NextResponse.json({
success: true,
data: {
liked: true,
likeCount: agent.likeCount,
},
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
});
}
// Create like and increment count atomically
const [, updatedAgent] = await prisma.$transaction([
prisma.agentLike.create({
data: {
userId: session.user.id,
agentId: id,
},
}),
prisma.agent.update({
where: { id },
data: { likeCount: { increment: 1 } },
}),
]);
return NextResponse.json({
success: true,
data: {
liked: true,
likeCount: updatedAgent.likeCount,
},
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
});
} catch (error) {
console.error('[API Error] POST /api/agents/[id]/like:', error);
return NextResponse.json(
{
success: false,
error: { code: 'INTERNAL_ERROR', message: 'Failed to like agent' },
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
},
{ status: 500 }
);
}
}
/**
* DELETE /api/agents/[id]/like
* Unlike an agent
*/
export async function DELETE(
_request: NextRequest,
context: RouteContext
): Promise<NextResponse<ApiResponse>> {
const requestId = crypto.randomUUID();
const { id } = await context.params;
try {
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session) {
return NextResponse.json(
{
success: false,
error: { code: 'UNAUTHORIZED', message: 'Authentication required' },
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
},
{ status: 401 }
);
}
// Check if liked
const existingLike = await prisma.agentLike.findUnique({
where: {
userId_agentId: {
userId: session.user.id,
agentId: id,
},
},
});
if (!existingLike) {
const agent = await prisma.agent.findUnique({
where: { id },
select: { likeCount: true },
});
return NextResponse.json({
success: true,
data: {
liked: false,
likeCount: agent?.likeCount ?? 0,
},
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
});
}
// Delete like and decrement count atomically
const [, updatedAgent] = await prisma.$transaction([
prisma.agentLike.delete({
where: {
userId_agentId: {
userId: session.user.id,
agentId: id,
},
},
}),
prisma.agent.update({
where: { id },
data: { likeCount: { decrement: 1 } },
}),
]);
return NextResponse.json({
success: true,
data: {
liked: false,
likeCount: Math.max(0, updatedAgent.likeCount),
},
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
});
} catch (error) {
console.error('[API Error] DELETE /api/agents/[id]/like:', error);
return NextResponse.json(
{
success: false,
error: { code: 'INTERNAL_ERROR', message: 'Failed to unlike agent' },
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
},
{ status: 500 }
);
}
}

View file

@ -154,57 +154,71 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
);
}
const agent = await prisma.agent.create({
data: {
userId: session.user.id,
uid: finalUid,
name,
description,
provider,
modelId,
systemPrompt,
temperature,
maxToolCallsPerTurn,
maxMessagesInContext,
isPublic,
collections: collectionIds?.length
? {
create: collectionIds.map((collectionId, index) => ({
collectionId,
position: index,
})),
}
: undefined,
tools: toolIds?.length
? {
create: toolIds.map((toolId, index) => ({
toolId,
position: index,
})),
}
: undefined,
},
select: {
id: true,
uid: true,
name: true,
description: true,
provider: true,
modelId: true,
systemPrompt: true,
temperature: true,
maxToolCallsPerTurn: true,
maxMessagesInContext: true,
isPublic: true,
createdAt: true,
updatedAt: true,
_count: {
select: {
tools: true,
collections: true,
// Create agent with auto-like (user likes their own agent)
const agent = await prisma.$transaction(async (tx) => {
const newAgent = await tx.agent.create({
data: {
userId: session.user.id,
uid: finalUid,
name,
description,
provider,
modelId,
systemPrompt,
temperature,
maxToolCallsPerTurn,
maxMessagesInContext,
isPublic,
likeCount: 1, // Start with 1 like (from owner)
collections: collectionIds?.length
? {
create: collectionIds.map((collectionId, index) => ({
collectionId,
position: index,
})),
}
: undefined,
tools: toolIds?.length
? {
create: toolIds.map((toolId, index) => ({
toolId,
position: index,
})),
}
: undefined,
},
select: {
id: true,
uid: true,
name: true,
description: true,
provider: true,
modelId: true,
systemPrompt: true,
temperature: true,
maxToolCallsPerTurn: true,
maxMessagesInContext: true,
isPublic: true,
createdAt: true,
updatedAt: true,
_count: {
select: {
tools: true,
collections: true,
},
},
},
},
});
// Auto-like the agent
await tx.agentLike.create({
data: {
userId: session.user.id,
agentId: newAgent.id,
},
});
return newAgent;
});
return NextResponse.json(

View file

@ -0,0 +1,280 @@
import { prisma } from '@tpmjs/db';
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';
export const maxDuration = 60;
const API_VERSION = '1.0.0';
interface ApiResponse<T = unknown> {
success: boolean;
data?: T;
error?: {
code: string;
message: string;
details?: Record<string, unknown>;
};
meta: {
version: string;
timestamp: string;
requestId?: string;
};
}
interface RouteContext {
params: Promise<{ id: string }>;
}
/**
* GET /api/collections/[id]/like
* Check if the current user has liked this collection
*/
export async function GET(
_request: NextRequest,
context: RouteContext
): Promise<NextResponse<ApiResponse>> {
const requestId = crypto.randomUUID();
const { id } = await context.params;
try {
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session) {
return NextResponse.json(
{
success: false,
error: { code: 'UNAUTHORIZED', message: 'Authentication required' },
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
},
{ status: 401 }
);
}
const like = await prisma.collectionLike.findUnique({
where: {
userId_collectionId: {
userId: session.user.id,
collectionId: id,
},
},
});
const collection = await prisma.collection.findUnique({
where: { id },
select: { likeCount: true },
});
return NextResponse.json({
success: true,
data: {
liked: !!like,
likeCount: collection?.likeCount ?? 0,
},
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
});
} catch (error) {
console.error('[API Error] GET /api/collections/[id]/like:', error);
return NextResponse.json(
{
success: false,
error: { code: 'INTERNAL_ERROR', message: 'Failed to check like status' },
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
},
{ status: 500 }
);
}
}
/**
* POST /api/collections/[id]/like
* Like a collection
*/
export async function POST(
_request: NextRequest,
context: RouteContext
): Promise<NextResponse<ApiResponse>> {
const requestId = crypto.randomUUID();
const { id } = await context.params;
try {
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session) {
return NextResponse.json(
{
success: false,
error: { code: 'UNAUTHORIZED', message: 'Authentication required' },
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
},
{ status: 401 }
);
}
// Check collection exists
const collection = await prisma.collection.findUnique({
where: { id },
});
if (!collection) {
return NextResponse.json(
{
success: false,
error: { code: 'NOT_FOUND', message: 'Collection not found' },
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
},
{ status: 404 }
);
}
// Check if already liked
const existingLike = await prisma.collectionLike.findUnique({
where: {
userId_collectionId: {
userId: session.user.id,
collectionId: id,
},
},
});
if (existingLike) {
return NextResponse.json({
success: true,
data: {
liked: true,
likeCount: collection.likeCount,
},
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
});
}
// Create like and increment count atomically
const [, updatedCollection] = await prisma.$transaction([
prisma.collectionLike.create({
data: {
userId: session.user.id,
collectionId: id,
},
}),
prisma.collection.update({
where: { id },
data: { likeCount: { increment: 1 } },
}),
]);
return NextResponse.json({
success: true,
data: {
liked: true,
likeCount: updatedCollection.likeCount,
},
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
});
} catch (error) {
console.error('[API Error] POST /api/collections/[id]/like:', error);
return NextResponse.json(
{
success: false,
error: { code: 'INTERNAL_ERROR', message: 'Failed to like collection' },
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
},
{ status: 500 }
);
}
}
/**
* DELETE /api/collections/[id]/like
* Unlike a collection
*/
export async function DELETE(
_request: NextRequest,
context: RouteContext
): Promise<NextResponse<ApiResponse>> {
const requestId = crypto.randomUUID();
const { id } = await context.params;
try {
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session) {
return NextResponse.json(
{
success: false,
error: { code: 'UNAUTHORIZED', message: 'Authentication required' },
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
},
{ status: 401 }
);
}
// Check if liked
const existingLike = await prisma.collectionLike.findUnique({
where: {
userId_collectionId: {
userId: session.user.id,
collectionId: id,
},
},
});
if (!existingLike) {
const collection = await prisma.collection.findUnique({
where: { id },
select: { likeCount: true },
});
return NextResponse.json({
success: true,
data: {
liked: false,
likeCount: collection?.likeCount ?? 0,
},
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
});
}
// Delete like and decrement count atomically
const [, updatedCollection] = await prisma.$transaction([
prisma.collectionLike.delete({
where: {
userId_collectionId: {
userId: session.user.id,
collectionId: id,
},
},
}),
prisma.collection.update({
where: { id },
data: { likeCount: { decrement: 1 } },
}),
]);
return NextResponse.json({
success: true,
data: {
liked: false,
likeCount: Math.max(0, updatedCollection.likeCount),
},
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
});
} catch (error) {
console.error('[API Error] DELETE /api/collections/[id]/like:', error);
return NextResponse.json(
{
success: false,
error: { code: 'INTERNAL_ERROR', message: 'Failed to unlike collection' },
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
},
{ status: 500 }
);
}
}

View file

@ -199,14 +199,27 @@ export async function POST(request: NextRequest): Promise<NextResponse<ApiRespon
);
}
// Create collection
const collection = await prisma.collection.create({
data: {
userId: session.user.id,
name,
description: description || null,
isPublic,
},
// Create collection with auto-like (user likes their own collection)
const collection = await prisma.$transaction(async (tx) => {
const newCollection = await tx.collection.create({
data: {
userId: session.user.id,
name,
description: description || null,
isPublic,
likeCount: 1, // Start with 1 like (from owner)
},
});
// Auto-like the collection
await tx.collectionLike.create({
data: {
userId: session.user.id,
collectionId: newCollection.id,
},
});
return newCollection;
});
return NextResponse.json(

View file

@ -0,0 +1,166 @@
import { prisma } from '@tpmjs/db';
import { type NextRequest, NextResponse } from 'next/server';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
export const maxDuration = 60;
const API_VERSION = '1.0.0';
interface ApiResponse<T = unknown> {
success: boolean;
data?: T;
error?: {
code: string;
message: string;
details?: Record<string, unknown>;
};
meta: {
version: string;
timestamp: string;
requestId?: string;
};
}
interface RouteContext {
params: Promise<{ id: string }>;
}
/**
* GET /api/public/agents/[id]
* Get a single public agent with its tools
*/
export async function GET(
_request: NextRequest,
context: RouteContext
): Promise<NextResponse<ApiResponse>> {
const requestId = crypto.randomUUID();
const { id } = await context.params;
try {
const agent = await prisma.agent.findUnique({
where: { id },
include: {
user: {
select: {
id: true,
name: true,
image: true,
},
},
tools: {
include: {
tool: {
include: {
package: {
select: {
id: true,
npmPackageName: true,
category: true,
},
},
},
},
},
orderBy: { position: 'asc' },
},
collections: {
include: {
collection: {
select: {
id: true,
name: true,
description: true,
isPublic: true,
_count: { select: { tools: true } },
},
},
},
orderBy: { position: 'asc' },
},
_count: { select: { tools: true, collections: true } },
},
});
if (!agent) {
return NextResponse.json(
{
success: false,
error: { code: 'NOT_FOUND', message: 'Agent not found' },
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
},
{ status: 404 }
);
}
if (!agent.isPublic) {
return NextResponse.json(
{
success: false,
error: { code: 'FORBIDDEN', message: 'This agent is not public' },
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
},
{ status: 403 }
);
}
return NextResponse.json({
success: true,
data: {
id: agent.id,
uid: agent.uid,
name: agent.name,
description: agent.description,
provider: agent.provider,
modelId: agent.modelId,
systemPrompt: agent.systemPrompt,
temperature: agent.temperature,
maxToolCallsPerTurn: agent.maxToolCallsPerTurn,
likeCount: agent.likeCount,
toolCount: agent._count.tools,
collectionCount: agent._count.collections,
createdAt: agent.createdAt,
updatedAt: agent.updatedAt,
createdBy: agent.user,
tools: agent.tools.map((at) => ({
id: at.id,
toolId: at.toolId,
position: at.position,
addedAt: at.addedAt,
tool: {
id: at.tool.id,
name: at.tool.name,
description: at.tool.description,
likeCount: at.tool.likeCount,
package: at.tool.package,
},
})),
collections: agent.collections
.filter((ac) => ac.collection.isPublic)
.map((ac) => ({
id: ac.id,
collectionId: ac.collectionId,
position: ac.position,
addedAt: ac.addedAt,
collection: {
id: ac.collection.id,
name: ac.collection.name,
description: ac.collection.description,
toolCount: ac.collection._count.tools,
},
})),
},
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
});
} catch (error) {
console.error('[API Error] GET /api/public/agents/[id]:', error);
return NextResponse.json(
{
success: false,
error: { code: 'INTERNAL_ERROR', message: 'Failed to fetch agent' },
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
},
{ status: 500 }
);
}
}

View file

@ -0,0 +1,111 @@
import { prisma } from '@tpmjs/db';
import { type NextRequest, NextResponse } from 'next/server';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
export const maxDuration = 60;
const API_VERSION = '1.0.0';
interface ApiResponse<T = unknown> {
success: boolean;
data?: T;
error?: {
code: string;
message: string;
details?: Record<string, unknown>;
};
meta: {
version: string;
timestamp: string;
requestId?: string;
};
}
/**
* GET /api/public/agents
* Get public agents sorted by like count
*/
export async function GET(request: NextRequest): Promise<NextResponse<ApiResponse>> {
const requestId = crypto.randomUUID();
try {
const { searchParams } = new URL(request.url);
const limit = Math.min(Math.max(Number.parseInt(searchParams.get('limit') || '20', 10), 1), 50);
const offset = Math.max(Number.parseInt(searchParams.get('offset') || '0', 10), 0);
const search = searchParams.get('search') || '';
const sort = searchParams.get('sort') || 'likes'; // 'likes' | 'recent' | 'tools'
const where = {
isPublic: true,
...(search && {
OR: [
{ name: { contains: search, mode: 'insensitive' as const } },
{ description: { contains: search, mode: 'insensitive' as const } },
],
}),
};
const orderBy =
sort === 'recent'
? { createdAt: 'desc' as const }
: sort === 'tools'
? { tools: { _count: 'desc' as const } }
: { likeCount: 'desc' as const };
const agents = await prisma.agent.findMany({
where,
include: {
user: {
select: {
id: true,
name: true,
image: true,
},
},
_count: {
select: { tools: true, collections: true },
},
},
orderBy: [orderBy, { createdAt: 'desc' }],
take: limit + 1,
skip: offset,
});
const hasMore = agents.length > limit;
const data = hasMore ? agents.slice(0, limit) : agents;
return NextResponse.json({
success: true,
data: data.map((agent) => ({
id: agent.id,
uid: agent.uid,
name: agent.name,
description: agent.description,
provider: agent.provider,
modelId: agent.modelId,
likeCount: agent.likeCount,
toolCount: agent._count.tools,
collectionCount: agent._count.collections,
createdAt: agent.createdAt,
createdBy: agent.user,
})),
pagination: {
limit,
offset,
hasMore,
},
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
});
} catch (error) {
console.error('[API Error] GET /api/public/agents:', error);
return NextResponse.json(
{
success: false,
error: { code: 'INTERNAL_ERROR', message: 'Failed to fetch public agents' },
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
},
{ status: 500 }
);
}
}

View file

@ -0,0 +1,132 @@
import { prisma } from '@tpmjs/db';
import { type NextRequest, NextResponse } from 'next/server';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
export const maxDuration = 60;
const API_VERSION = '1.0.0';
interface ApiResponse<T = unknown> {
success: boolean;
data?: T;
error?: {
code: string;
message: string;
details?: Record<string, unknown>;
};
meta: {
version: string;
timestamp: string;
requestId?: string;
};
}
interface RouteContext {
params: Promise<{ id: string }>;
}
/**
* GET /api/public/collections/[id]
* Get a single public collection with its tools
*/
export async function GET(
_request: NextRequest,
context: RouteContext
): Promise<NextResponse<ApiResponse>> {
const requestId = crypto.randomUUID();
const { id } = await context.params;
try {
const collection = await prisma.collection.findUnique({
where: { id },
include: {
user: {
select: {
id: true,
name: true,
image: true,
},
},
tools: {
include: {
tool: {
include: {
package: {
select: {
id: true,
npmPackageName: true,
category: true,
},
},
},
},
},
orderBy: { position: 'asc' },
},
_count: { select: { tools: true } },
},
});
if (!collection) {
return NextResponse.json(
{
success: false,
error: { code: 'NOT_FOUND', message: 'Collection not found' },
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
},
{ status: 404 }
);
}
if (!collection.isPublic) {
return NextResponse.json(
{
success: false,
error: { code: 'FORBIDDEN', message: 'This collection is not public' },
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
},
{ status: 403 }
);
}
return NextResponse.json({
success: true,
data: {
id: collection.id,
name: collection.name,
description: collection.description,
likeCount: collection.likeCount,
toolCount: collection._count.tools,
createdAt: collection.createdAt,
updatedAt: collection.updatedAt,
createdBy: collection.user,
tools: collection.tools.map((ct) => ({
id: ct.id,
toolId: ct.toolId,
position: ct.position,
note: ct.note,
addedAt: ct.addedAt,
tool: {
id: ct.tool.id,
name: ct.tool.name,
description: ct.tool.description,
likeCount: ct.tool.likeCount,
package: ct.tool.package,
},
})),
},
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
});
} catch (error) {
console.error('[API Error] GET /api/public/collections/[id]:', error);
return NextResponse.json(
{
success: false,
error: { code: 'INTERNAL_ERROR', message: 'Failed to fetch collection' },
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
},
{ status: 500 }
);
}
}

View file

@ -0,0 +1,107 @@
import { prisma } from '@tpmjs/db';
import { type NextRequest, NextResponse } from 'next/server';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
export const maxDuration = 60;
const API_VERSION = '1.0.0';
interface ApiResponse<T = unknown> {
success: boolean;
data?: T;
error?: {
code: string;
message: string;
details?: Record<string, unknown>;
};
meta: {
version: string;
timestamp: string;
requestId?: string;
};
}
/**
* GET /api/public/collections
* Get public collections sorted by like count
*/
export async function GET(request: NextRequest): Promise<NextResponse<ApiResponse>> {
const requestId = crypto.randomUUID();
try {
const { searchParams } = new URL(request.url);
const limit = Math.min(Math.max(Number.parseInt(searchParams.get('limit') || '20', 10), 1), 50);
const offset = Math.max(Number.parseInt(searchParams.get('offset') || '0', 10), 0);
const search = searchParams.get('search') || '';
const sort = searchParams.get('sort') || 'likes'; // 'likes' | 'recent' | 'tools'
const where = {
isPublic: true,
...(search && {
OR: [
{ name: { contains: search, mode: 'insensitive' as const } },
{ description: { contains: search, mode: 'insensitive' as const } },
],
}),
};
const orderBy =
sort === 'recent'
? { createdAt: 'desc' as const }
: sort === 'tools'
? { tools: { _count: 'desc' as const } }
: { likeCount: 'desc' as const };
const collections = await prisma.collection.findMany({
where,
include: {
user: {
select: {
id: true,
name: true,
image: true,
},
},
_count: {
select: { tools: true },
},
},
orderBy: [orderBy, { createdAt: 'desc' }],
take: limit + 1,
skip: offset,
});
const hasMore = collections.length > limit;
const data = hasMore ? collections.slice(0, limit) : collections;
return NextResponse.json({
success: true,
data: data.map((collection) => ({
id: collection.id,
name: collection.name,
description: collection.description,
likeCount: collection.likeCount,
toolCount: collection._count.tools,
createdAt: collection.createdAt,
createdBy: collection.user,
})),
pagination: {
limit,
offset,
hasMore,
},
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
});
} catch (error) {
console.error('[API Error] GET /api/public/collections:', error);
return NextResponse.json(
{
success: false,
error: { code: 'INTERNAL_ERROR', message: 'Failed to fetch public collections' },
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
},
{ status: 500 }
);
}
}

View file

@ -0,0 +1,280 @@
import { prisma } from '@tpmjs/db';
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';
export const maxDuration = 60;
const API_VERSION = '1.0.0';
interface ApiResponse<T = unknown> {
success: boolean;
data?: T;
error?: {
code: string;
message: string;
details?: Record<string, unknown>;
};
meta: {
version: string;
timestamp: string;
requestId?: string;
};
}
interface RouteContext {
params: Promise<{ id: string }>;
}
/**
* GET /api/tools/[id]/like
* Check if the current user has liked this tool
*/
export async function GET(
_request: NextRequest,
context: RouteContext
): Promise<NextResponse<ApiResponse>> {
const requestId = crypto.randomUUID();
const { id } = await context.params;
try {
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session) {
return NextResponse.json(
{
success: false,
error: { code: 'UNAUTHORIZED', message: 'Authentication required' },
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
},
{ status: 401 }
);
}
const like = await prisma.toolLike.findUnique({
where: {
userId_toolId: {
userId: session.user.id,
toolId: id,
},
},
});
const tool = await prisma.tool.findUnique({
where: { id },
select: { likeCount: true },
});
return NextResponse.json({
success: true,
data: {
liked: !!like,
likeCount: tool?.likeCount ?? 0,
},
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
});
} catch (error) {
console.error('[API Error] GET /api/tools/[id]/like:', error);
return NextResponse.json(
{
success: false,
error: { code: 'INTERNAL_ERROR', message: 'Failed to check like status' },
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
},
{ status: 500 }
);
}
}
/**
* POST /api/tools/[id]/like
* Like a tool
*/
export async function POST(
_request: NextRequest,
context: RouteContext
): Promise<NextResponse<ApiResponse>> {
const requestId = crypto.randomUUID();
const { id } = await context.params;
try {
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session) {
return NextResponse.json(
{
success: false,
error: { code: 'UNAUTHORIZED', message: 'Authentication required' },
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
},
{ status: 401 }
);
}
// Check tool exists
const tool = await prisma.tool.findUnique({
where: { id },
});
if (!tool) {
return NextResponse.json(
{
success: false,
error: { code: 'NOT_FOUND', message: 'Tool not found' },
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
},
{ status: 404 }
);
}
// Check if already liked
const existingLike = await prisma.toolLike.findUnique({
where: {
userId_toolId: {
userId: session.user.id,
toolId: id,
},
},
});
if (existingLike) {
return NextResponse.json({
success: true,
data: {
liked: true,
likeCount: tool.likeCount,
},
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
});
}
// Create like and increment count atomically
const [, updatedTool] = await prisma.$transaction([
prisma.toolLike.create({
data: {
userId: session.user.id,
toolId: id,
},
}),
prisma.tool.update({
where: { id },
data: { likeCount: { increment: 1 } },
}),
]);
return NextResponse.json({
success: true,
data: {
liked: true,
likeCount: updatedTool.likeCount,
},
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
});
} catch (error) {
console.error('[API Error] POST /api/tools/[id]/like:', error);
return NextResponse.json(
{
success: false,
error: { code: 'INTERNAL_ERROR', message: 'Failed to like tool' },
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
},
{ status: 500 }
);
}
}
/**
* DELETE /api/tools/[id]/like
* Unlike a tool
*/
export async function DELETE(
_request: NextRequest,
context: RouteContext
): Promise<NextResponse<ApiResponse>> {
const requestId = crypto.randomUUID();
const { id } = await context.params;
try {
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session) {
return NextResponse.json(
{
success: false,
error: { code: 'UNAUTHORIZED', message: 'Authentication required' },
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
},
{ status: 401 }
);
}
// Check if liked
const existingLike = await prisma.toolLike.findUnique({
where: {
userId_toolId: {
userId: session.user.id,
toolId: id,
},
},
});
if (!existingLike) {
const tool = await prisma.tool.findUnique({
where: { id },
select: { likeCount: true },
});
return NextResponse.json({
success: true,
data: {
liked: false,
likeCount: tool?.likeCount ?? 0,
},
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
});
}
// Delete like and decrement count atomically
const [, updatedTool] = await prisma.$transaction([
prisma.toolLike.delete({
where: {
userId_toolId: {
userId: session.user.id,
toolId: id,
},
},
}),
prisma.tool.update({
where: { id },
data: { likeCount: { decrement: 1 } },
}),
]);
return NextResponse.json({
success: true,
data: {
liked: false,
likeCount: Math.max(0, updatedTool.likeCount),
},
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
});
} catch (error) {
console.error('[API Error] DELETE /api/tools/[id]/like:', error);
return NextResponse.json(
{
success: false,
error: { code: 'INTERNAL_ERROR', message: 'Failed to unlike tool' },
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
},
{ status: 500 }
);
}
}

View file

@ -0,0 +1,116 @@
import { prisma } from '@tpmjs/db';
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';
export const maxDuration = 60;
const API_VERSION = '1.0.0';
interface ApiResponse<T = unknown> {
success: boolean;
data?: T;
error?: {
code: string;
message: string;
details?: Record<string, unknown>;
};
meta: {
version: string;
timestamp: string;
requestId?: string;
};
}
/**
* GET /api/user/likes/agents
* Get agents liked by the current user
*/
export async function GET(request: NextRequest): Promise<NextResponse<ApiResponse>> {
const requestId = crypto.randomUUID();
try {
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session) {
return NextResponse.json(
{
success: false,
error: { code: 'UNAUTHORIZED', message: 'Authentication required' },
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
},
{ status: 401 }
);
}
const { searchParams } = new URL(request.url);
const limit = Math.min(Math.max(Number.parseInt(searchParams.get('limit') || '20', 10), 1), 50);
const offset = Math.max(Number.parseInt(searchParams.get('offset') || '0', 10), 0);
const likes = await prisma.agentLike.findMany({
where: { userId: session.user.id },
include: {
agent: {
include: {
user: {
select: {
id: true,
name: true,
},
},
_count: {
select: { tools: true, collections: true },
},
},
},
},
orderBy: { createdAt: 'desc' },
take: limit + 1,
skip: offset,
});
const hasMore = likes.length > limit;
const data = hasMore ? likes.slice(0, limit) : likes;
return NextResponse.json({
success: true,
data: data.map((like) => ({
id: like.id,
likedAt: like.createdAt,
agent: {
id: like.agent.id,
uid: like.agent.uid,
name: like.agent.name,
description: like.agent.description,
isPublic: like.agent.isPublic,
likeCount: like.agent.likeCount,
provider: like.agent.provider,
modelId: like.agent.modelId,
toolCount: like.agent._count.tools,
collectionCount: like.agent._count.collections,
createdBy: like.agent.user,
},
})),
pagination: {
limit,
offset,
hasMore,
},
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
});
} catch (error) {
console.error('[API Error] GET /api/user/likes/agents:', error);
return NextResponse.json(
{
success: false,
error: { code: 'INTERNAL_ERROR', message: 'Failed to fetch liked agents' },
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
},
{ status: 500 }
);
}
}

View file

@ -0,0 +1,112 @@
import { prisma } from '@tpmjs/db';
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';
export const maxDuration = 60;
const API_VERSION = '1.0.0';
interface ApiResponse<T = unknown> {
success: boolean;
data?: T;
error?: {
code: string;
message: string;
details?: Record<string, unknown>;
};
meta: {
version: string;
timestamp: string;
requestId?: string;
};
}
/**
* GET /api/user/likes/collections
* Get collections liked by the current user
*/
export async function GET(request: NextRequest): Promise<NextResponse<ApiResponse>> {
const requestId = crypto.randomUUID();
try {
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session) {
return NextResponse.json(
{
success: false,
error: { code: 'UNAUTHORIZED', message: 'Authentication required' },
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
},
{ status: 401 }
);
}
const { searchParams } = new URL(request.url);
const limit = Math.min(Math.max(Number.parseInt(searchParams.get('limit') || '20', 10), 1), 50);
const offset = Math.max(Number.parseInt(searchParams.get('offset') || '0', 10), 0);
const likes = await prisma.collectionLike.findMany({
where: { userId: session.user.id },
include: {
collection: {
include: {
user: {
select: {
id: true,
name: true,
},
},
_count: {
select: { tools: true },
},
},
},
},
orderBy: { createdAt: 'desc' },
take: limit + 1,
skip: offset,
});
const hasMore = likes.length > limit;
const data = hasMore ? likes.slice(0, limit) : likes;
return NextResponse.json({
success: true,
data: data.map((like) => ({
id: like.id,
likedAt: like.createdAt,
collection: {
id: like.collection.id,
name: like.collection.name,
description: like.collection.description,
isPublic: like.collection.isPublic,
likeCount: like.collection.likeCount,
toolCount: like.collection._count.tools,
createdBy: like.collection.user,
},
})),
pagination: {
limit,
offset,
hasMore,
},
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
});
} catch (error) {
console.error('[API Error] GET /api/user/likes/collections:', error);
return NextResponse.json(
{
success: false,
error: { code: 'INTERNAL_ERROR', message: 'Failed to fetch liked collections' },
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
},
{ status: 500 }
);
}
}

View file

@ -0,0 +1,108 @@
import { prisma } from '@tpmjs/db';
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';
export const maxDuration = 60;
const API_VERSION = '1.0.0';
interface ApiResponse<T = unknown> {
success: boolean;
data?: T;
error?: {
code: string;
message: string;
details?: Record<string, unknown>;
};
meta: {
version: string;
timestamp: string;
requestId?: string;
};
}
/**
* GET /api/user/likes/tools
* Get tools liked by the current user
*/
export async function GET(request: NextRequest): Promise<NextResponse<ApiResponse>> {
const requestId = crypto.randomUUID();
try {
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session) {
return NextResponse.json(
{
success: false,
error: { code: 'UNAUTHORIZED', message: 'Authentication required' },
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
},
{ status: 401 }
);
}
const { searchParams } = new URL(request.url);
const limit = Math.min(Math.max(Number.parseInt(searchParams.get('limit') || '20', 10), 1), 50);
const offset = Math.max(Number.parseInt(searchParams.get('offset') || '0', 10), 0);
const likes = await prisma.toolLike.findMany({
where: { userId: session.user.id },
include: {
tool: {
include: {
package: {
select: {
id: true,
npmPackageName: true,
category: true,
},
},
},
},
},
orderBy: { createdAt: 'desc' },
take: limit + 1,
skip: offset,
});
const hasMore = likes.length > limit;
const data = hasMore ? likes.slice(0, limit) : likes;
return NextResponse.json({
success: true,
data: data.map((like) => ({
id: like.id,
likedAt: like.createdAt,
tool: {
id: like.tool.id,
name: like.tool.name,
description: like.tool.description,
likeCount: like.tool.likeCount,
package: like.tool.package,
},
})),
pagination: {
limit,
offset,
hasMore,
},
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
});
} catch (error) {
console.error('[API Error] GET /api/user/likes/tools:', error);
return NextResponse.json(
{
success: false,
error: { code: 'INTERNAL_ERROR', message: 'Failed to fetch liked tools' },
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
},
{ status: 500 }
);
}
}

View file

@ -0,0 +1,358 @@
'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 { useParams } from 'next/navigation';
import { useCallback, useEffect, useState } from 'react';
import { AppHeader } from '~/components/AppHeader';
import { LikeButton } from '~/components/LikeButton';
interface CollectionTool {
id: string;
toolId: string;
position: number;
note: string | null;
addedAt: string;
tool: {
id: string;
name: string;
description: string;
likeCount: number;
package: {
id: string;
npmPackageName: string;
category: string;
};
};
}
interface PublicCollection {
id: string;
name: string;
description: string | null;
likeCount: number;
toolCount: number;
createdAt: string;
updatedAt: string;
createdBy: {
id: string;
name: string;
image: string | null;
};
tools: CollectionTool[];
}
function McpUrlSection({ collectionId }: { collectionId: string }) {
const [copiedUrl, setCopiedUrl] = useState<'http' | 'sse' | null>(null);
const [showConfig, setShowConfig] = useState(false);
const baseUrl = typeof window !== 'undefined' ? window.location.origin : 'https://tpmjs.com';
const httpUrl = `${baseUrl}/api/collections/${collectionId}/mcp/http`;
const sseUrl = `${baseUrl}/api/collections/${collectionId}/mcp/sse`;
const copyToClipboard = async (url: string, type: 'http' | 'sse') => {
await navigator.clipboard.writeText(url);
setCopiedUrl(type);
setTimeout(() => setCopiedUrl(null), 2000);
};
const configSnippet = `{
"mcpServers": {
"tpmjs-collection": {
"command": "npx",
"args": [
"mcp-remote",
"${httpUrl}"
]
}
}
}`;
return (
<div className="mb-8 p-4 bg-gradient-to-br from-primary/5 via-transparent to-primary/5 border border-primary/20 rounded-xl">
<div className="flex items-center gap-2 mb-4">
<div className="p-1.5 bg-primary/10 rounded-lg">
<Icon icon="link" size="sm" className="text-primary" />
</div>
<h3 className="font-semibold text-foreground">MCP Server URLs</h3>
</div>
<div className="space-y-3">
{/* HTTP Transport */}
<div className="group">
<div className="flex items-center gap-2 mb-1.5">
<span className="text-xs font-medium text-foreground-secondary uppercase tracking-wide">
HTTP Transport
</span>
<span className="text-xs text-foreground-tertiary">(recommended)</span>
</div>
<div className="flex items-center gap-2">
<div className="flex-1 px-3 py-2 bg-background border border-border rounded-lg font-mono text-sm text-foreground-secondary overflow-x-auto">
{httpUrl}
</div>
<Button
variant="secondary"
size="sm"
onClick={() => copyToClipboard(httpUrl, 'http')}
className="shrink-0"
>
<Icon icon={copiedUrl === 'http' ? 'check' : 'copy'} size="xs" className="mr-1" />
{copiedUrl === 'http' ? 'Copied!' : 'Copy'}
</Button>
</div>
</div>
{/* SSE Transport */}
<div className="group">
<div className="flex items-center gap-2 mb-1.5">
<span className="text-xs font-medium text-foreground-secondary uppercase tracking-wide">
SSE Transport
</span>
<span className="text-xs text-foreground-tertiary">(streaming)</span>
</div>
<div className="flex items-center gap-2">
<div className="flex-1 px-3 py-2 bg-background border border-border rounded-lg font-mono text-sm text-foreground-secondary overflow-x-auto">
{sseUrl}
</div>
<Button
variant="secondary"
size="sm"
onClick={() => copyToClipboard(sseUrl, 'sse')}
className="shrink-0"
>
<Icon icon={copiedUrl === 'sse' ? 'check' : 'copy'} size="xs" className="mr-1" />
{copiedUrl === 'sse' ? 'Copied!' : 'Copy'}
</Button>
</div>
</div>
</div>
{/* Config snippet toggle */}
<div className="mt-4 pt-4 border-t border-border/50">
<button
type="button"
onClick={() => setShowConfig(!showConfig)}
className="flex items-center gap-2 text-sm text-primary hover:text-primary/80 transition-colors"
>
<Icon icon={showConfig ? 'chevronDown' : 'chevronRight'} size="xs" />
<span>Show Claude Desktop config</span>
</button>
{showConfig && (
<div className="mt-3 relative">
<pre className="p-4 bg-background border border-border rounded-lg text-xs font-mono text-foreground-secondary overflow-x-auto">
{configSnippet}
</pre>
<Button
variant="ghost"
size="sm"
onClick={() => {
navigator.clipboard.writeText(configSnippet);
setCopiedUrl('http');
setTimeout(() => setCopiedUrl(null), 2000);
}}
className="absolute top-2 right-2"
>
<Icon icon="copy" size="xs" />
</Button>
</div>
)}
</div>
<p className="mt-3 text-xs text-foreground-tertiary">
Use these URLs with{' '}
<Link href="/docs/tutorials/mcp" className="text-primary hover:underline">
Claude Desktop, Cursor, or any MCP client
</Link>
</p>
</div>
);
}
export default function PublicCollectionDetailPage(): React.ReactElement {
const params = useParams();
const collectionId = params.id 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/collections/${collectionId}`);
const data = await response.json();
if (data.success) {
setCollection(data.data);
} else {
if (data.error?.code === 'NOT_FOUND' || data.error?.code === 'FORBIDDEN') {
setError('This collection is not available or is private');
} else {
setError(data.error?.message || 'Failed to fetch collection');
}
}
} catch (err) {
console.error('Failed to fetch collection:', err);
setError('Failed to fetch collection');
} finally {
setIsLoading(false);
}
}, [collectionId]);
useEffect(() => {
fetchCollection();
}, [fetchCollection]);
if (isLoading) {
return (
<div className="min-h-screen bg-background">
<AppHeader />
<main className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="animate-pulse">
<div className="h-8 bg-surface-secondary rounded w-1/2 mb-4" />
<div className="h-4 bg-surface-secondary rounded w-full mb-8" />
<div className="h-32 bg-surface-secondary rounded mb-8" />
<div className="space-y-4">
{[1, 2, 3].map((i) => (
<div key={i} className="h-24 bg-surface-secondary rounded" />
))}
</div>
</div>
</main>
</div>
);
}
if (error || !collection) {
return (
<div className="min-h-screen bg-background">
<AppHeader />
<main className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-16">
<div className="text-center">
<Icon icon="alertCircle" size="lg" className="mx-auto text-error mb-4" />
<h2 className="text-lg font-medium text-foreground mb-2">
{error || 'Collection not found'}
</h2>
<p className="text-foreground-secondary mb-4">
This collection may be private or no longer available.
</p>
<Link href="/collections">
<Button>Browse Collections</Button>
</Link>
</div>
</main>
</div>
);
}
return (
<div className="min-h-screen bg-background">
<AppHeader />
<main className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Back link */}
<Link
href="/collections"
className="inline-flex items-center gap-1 text-sm text-foreground-secondary hover:text-foreground mb-6"
>
<Icon icon="arrowLeft" size="xs" />
Back to Collections
</Link>
{/* Header */}
<div className="flex items-start justify-between mb-6">
<div>
<h1 className="text-3xl font-bold text-foreground mb-2">{collection.name}</h1>
{collection.description && (
<p className="text-foreground-secondary">{collection.description}</p>
)}
</div>
<LikeButton
entityType="collection"
entityId={collection.id}
initialCount={collection.likeCount}
showCount={true}
variant="outline"
/>
</div>
{/* Meta info */}
<div className="flex items-center gap-4 mb-8 text-sm text-foreground-tertiary">
<div className="flex items-center gap-2">
{collection.createdBy.image ? (
<img
src={collection.createdBy.image}
alt={collection.createdBy.name}
className="w-6 h-6 rounded-full"
/>
) : (
<div className="w-6 h-6 rounded-full bg-primary/10 flex items-center justify-center">
<Icon icon="user" size="xs" className="text-primary" />
</div>
)}
<span>Created by {collection.createdBy.name}</span>
</div>
<span></span>
<span>
{collection.toolCount} tool{collection.toolCount !== 1 ? 's' : ''}
</span>
</div>
{/* MCP URLs */}
<McpUrlSection collectionId={collection.id} />
{/* Tools */}
<div>
<h2 className="text-lg font-semibold text-foreground mb-4">Tools in this Collection</h2>
{collection.tools.length === 0 ? (
<div className="text-center py-12 bg-surface/50 border border-border rounded-lg">
<Icon icon="puzzle" size="lg" className="mx-auto text-foreground-tertiary mb-2" />
<p className="text-foreground-secondary">No tools in this collection yet</p>
</div>
) : (
<div className="space-y-3">
{collection.tools.map((ct) => (
<div
key={ct.id}
className="bg-background border border-border rounded-lg p-4 hover:border-foreground/20 transition-colors"
>
<div className="flex items-start justify-between mb-2">
<div>
<Link
href={`/tool/${ct.tool.package.npmPackageName}/${ct.tool.name}`}
className="font-medium text-foreground hover:text-primary transition-colors"
>
{ct.tool.name}
</Link>
<span className="text-sm text-foreground-tertiary ml-2">
from {ct.tool.package.npmPackageName}
</span>
</div>
<LikeButton
entityType="tool"
entityId={ct.tool.id}
initialCount={ct.tool.likeCount}
size="sm"
/>
</div>
<p className="text-sm text-foreground-secondary line-clamp-2 mb-2">
{ct.tool.description}
</p>
<Badge variant="secondary" size="sm">
{ct.tool.package.category}
</Badge>
{ct.note && (
<p className="mt-2 text-xs text-foreground-tertiary italic">Note: {ct.note}</p>
)}
</div>
))}
</div>
)}
</div>
</main>
</div>
);
}

View file

@ -0,0 +1,237 @@
'use client';
import { Button } from '@tpmjs/ui/Button/Button';
import { Icon } from '@tpmjs/ui/Icon/Icon';
import Link from 'next/link';
import { useCallback, useEffect, useState } from 'react';
import { AppHeader } from '~/components/AppHeader';
import { LikeButton } from '~/components/LikeButton';
interface PublicCollection {
id: string;
name: string;
description: string | null;
likeCount: number;
toolCount: number;
createdAt: string;
createdBy: {
id: string;
name: string;
image: string | null;
};
}
type SortOption = 'likes' | 'recent' | 'tools';
export default function PublicCollectionsPage(): React.ReactElement {
const [collections, setCollections] = useState<PublicCollection[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [hasMore, setHasMore] = useState(false);
const [offset, setOffset] = useState(0);
const [search, setSearch] = useState('');
const [sort, setSort] = useState<SortOption>('likes');
const limit = 20;
const fetchCollections = useCallback(
async (currentOffset: number, resetList = false) => {
try {
const params = new URLSearchParams({
limit: String(limit),
offset: String(currentOffset),
sort,
...(search && { search }),
});
const response = await fetch(`/api/public/collections?${params}`);
const data = await response.json();
if (data.success) {
if (resetList || currentOffset === 0) {
setCollections(data.data);
} else {
setCollections((prev) => [...prev, ...data.data]);
}
setHasMore(data.pagination.hasMore);
} else {
setError(data.error?.message || 'Failed to fetch collections');
}
} catch (err) {
console.error('Failed to fetch collections:', err);
setError('Failed to fetch collections');
} finally {
setIsLoading(false);
}
},
[sort, search]
);
useEffect(() => {
setOffset(0);
setIsLoading(true);
fetchCollections(0, true);
}, [fetchCollections]);
const loadMore = () => {
const newOffset = offset + limit;
setOffset(newOffset);
fetchCollections(newOffset);
};
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
setOffset(0);
setIsLoading(true);
fetchCollections(0, true);
};
return (
<div className="min-h-screen bg-background">
<AppHeader />
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Header */}
<div className="mb-8">
<h1 className="text-3xl font-bold text-foreground mb-2">Public Collections</h1>
<p className="text-foreground-secondary">
Discover curated tool collections shared by the community
</p>
</div>
{/* Filters */}
<div className="flex flex-col sm:flex-row gap-4 mb-6">
<form onSubmit={handleSearch} className="flex-1">
<div className="relative">
<Icon
icon="search"
size="sm"
className="absolute left-3 top-1/2 -translate-y-1/2 text-foreground-tertiary"
/>
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search collections..."
className="w-full pl-10 pr-4 py-2 bg-surface border border-border rounded-lg text-foreground placeholder:text-foreground-tertiary focus:outline-none focus:ring-2 focus:ring-primary/50"
/>
</div>
</form>
<div className="flex items-center gap-2">
<span className="text-sm text-foreground-secondary">Sort:</span>
<select
value={sort}
onChange={(e) => setSort(e.target.value as SortOption)}
className="px-3 py-2 bg-surface border border-border rounded-lg text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
>
<option value="likes">Most Liked</option>
<option value="recent">Most Recent</option>
<option value="tools">Most Tools</option>
</select>
</div>
</div>
{/* Content */}
{error ? (
<div className="text-center py-16">
<Icon icon="alertCircle" size="lg" className="mx-auto text-error mb-4" />
<h2 className="text-lg font-medium text-foreground mb-2">Error</h2>
<p className="text-foreground-secondary mb-4">{error}</p>
<Button onClick={() => fetchCollections(0, true)}>Try Again</Button>
</div>
) : isLoading ? (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{[1, 2, 3, 4, 5, 6].map((i) => (
<div
key={i}
className="bg-background border border-border rounded-lg p-6 animate-pulse"
>
<div className="h-6 bg-surface-secondary rounded w-3/4 mb-3" />
<div className="h-4 bg-surface-secondary rounded w-full mb-2" />
<div className="h-4 bg-surface-secondary rounded w-2/3 mb-4" />
<div className="h-4 bg-surface-secondary rounded w-1/3" />
</div>
))}
</div>
) : collections.length === 0 ? (
<div className="text-center py-16">
<div className="w-16 h-16 rounded-full bg-primary/10 flex items-center justify-center mx-auto mb-4">
<Icon icon="folder" size="lg" className="text-primary" />
</div>
<h2 className="text-lg font-medium text-foreground mb-2">No collections found</h2>
<p className="text-foreground-secondary">
{search
? 'Try adjusting your search terms'
: 'Be the first to share a public collection!'}
</p>
</div>
) : (
<>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{collections.map((collection) => (
<div
key={collection.id}
className="bg-background border border-border rounded-lg p-6 hover:border-foreground/20 transition-colors"
>
<div className="flex items-start justify-between mb-3">
<Link
href={`/collections/${collection.id}`}
className="text-lg font-medium text-foreground hover:text-primary transition-colors"
>
{collection.name}
</Link>
<LikeButton
entityType="collection"
entityId={collection.id}
initialCount={collection.likeCount}
size="sm"
/>
</div>
{collection.description && (
<p className="text-sm text-foreground-secondary line-clamp-2 mb-4">
{collection.description}
</p>
)}
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-3 text-foreground-tertiary">
<span className="flex items-center gap-1">
<Icon icon="puzzle" size="xs" />
{collection.toolCount} tool{collection.toolCount !== 1 ? 's' : ''}
</span>
</div>
<div className="flex items-center gap-2">
{collection.createdBy.image ? (
<img
src={collection.createdBy.image}
alt={collection.createdBy.name}
className="w-5 h-5 rounded-full"
/>
) : (
<div className="w-5 h-5 rounded-full bg-primary/10 flex items-center justify-center">
<Icon icon="user" size="xs" className="text-primary" />
</div>
)}
<span className="text-xs text-foreground-tertiary">
{collection.createdBy.name}
</span>
</div>
</div>
</div>
))}
</div>
{hasMore && (
<div className="mt-8 text-center">
<Button variant="outline" onClick={loadMore}>
Load More
</Button>
</div>
)}
</>
)}
</main>
</div>
);
}

View file

@ -0,0 +1,188 @@
'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 { useCallback, useEffect, useState } from 'react';
import { LikeButton } from '~/components/LikeButton';
import { DashboardLayout } from '~/components/dashboard/DashboardLayout';
interface LikedAgent {
id: string;
likedAt: string;
agent: {
id: string;
uid: string;
name: string;
description: string | null;
isPublic: boolean;
likeCount: number;
provider: string;
modelId: string;
toolCount: number;
collectionCount: number;
createdBy: {
id: string;
name: string;
};
};
}
export default function LikedAgentsPage(): React.ReactElement {
const [agents, setAgents] = useState<LikedAgent[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [hasMore, setHasMore] = useState(false);
const [offset, setOffset] = useState(0);
const limit = 20;
const fetchAgents = useCallback(async (currentOffset: number) => {
try {
const response = await fetch(`/api/user/likes/agents?limit=${limit}&offset=${currentOffset}`);
const data = await response.json();
if (data.success) {
if (currentOffset === 0) {
setAgents(data.data);
} else {
setAgents((prev) => [...prev, ...data.data]);
}
setHasMore(data.pagination.hasMore);
} else {
setError(data.error?.message || 'Failed to fetch liked agents');
}
} catch (err) {
console.error('Failed to fetch liked agents:', err);
setError('Failed to fetch liked agents');
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
fetchAgents(0);
}, [fetchAgents]);
const loadMore = () => {
const newOffset = offset + limit;
setOffset(newOffset);
fetchAgents(newOffset);
};
const handleUnlike = (agentId: string) => {
setAgents((prev) => prev.filter((a) => a.agent.id !== agentId));
};
if (error) {
return (
<DashboardLayout title="Liked Agents">
<div className="text-center py-16">
<Icon icon="alertCircle" size="lg" className="mx-auto text-error mb-4" />
<h2 className="text-lg font-medium text-foreground mb-2">Error</h2>
<p className="text-foreground-secondary mb-4">{error}</p>
<Button onClick={() => fetchAgents(0)}>Try Again</Button>
</div>
</DashboardLayout>
);
}
return (
<DashboardLayout
title="Liked Agents"
subtitle={!isLoading ? `${agents.length} agent${agents.length !== 1 ? 's' : ''}` : undefined}
>
{isLoading ? (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{[1, 2, 3, 4, 5, 6].map((i) => (
<div
key={i}
className="bg-background border border-border rounded-lg p-4 animate-pulse"
>
<div className="h-5 bg-surface-secondary rounded w-3/4 mb-2" />
<div className="h-4 bg-surface-secondary rounded w-full mb-1" />
<div className="h-4 bg-surface-secondary rounded w-2/3" />
</div>
))}
</div>
) : agents.length === 0 ? (
<div className="text-center py-16">
<div className="w-16 h-16 rounded-full bg-primary/10 flex items-center justify-center mx-auto mb-4">
<Icon icon="heart" size="lg" className="text-primary" />
</div>
<h2 className="text-lg font-medium text-foreground mb-2">No liked agents yet</h2>
<p className="text-foreground-secondary mb-4">
Browse public agents and click the heart icon to save your favorites
</p>
<Link href="/agents">
<Button>Browse Agents</Button>
</Link>
</div>
) : (
<>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{agents.map((item) => (
<div
key={item.id}
className="bg-background border border-border rounded-lg p-4 hover:border-foreground/20 transition-colors"
>
<div className="flex items-start justify-between mb-2">
<Link
href={`/agents/${item.agent.id}`}
className="font-medium text-foreground hover:text-primary transition-colors"
>
{item.agent.name}
</Link>
<LikeButton
entityType="agent"
entityId={item.agent.id}
initialLiked={true}
initialCount={item.agent.likeCount}
size="sm"
onLikeChange={(liked) => {
if (!liked) handleUnlike(item.agent.id);
}}
/>
</div>
{item.agent.description && (
<p className="text-sm text-foreground-secondary line-clamp-2 mb-3">
{item.agent.description}
</p>
)}
<div className="flex items-center gap-2 mb-2">
<Badge variant="secondary" size="sm">
{item.agent.provider}
</Badge>
<span className="text-xs text-foreground-tertiary">{item.agent.modelId}</span>
</div>
<div className="flex items-center gap-2 text-xs text-foreground-tertiary">
<span>
{item.agent.toolCount} tool{item.agent.toolCount !== 1 ? 's' : ''}
</span>
<span></span>
<span>by {item.agent.createdBy.name}</span>
{item.agent.isPublic && (
<>
<span></span>
<Badge variant="secondary" size="sm">
Public
</Badge>
</>
)}
</div>
</div>
))}
</div>
{hasMore && (
<div className="mt-6 text-center">
<Button variant="outline" onClick={loadMore}>
Load More
</Button>
</div>
)}
</>
)}
</DashboardLayout>
);
}

View file

@ -0,0 +1,184 @@
'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 { useCallback, useEffect, useState } from 'react';
import { LikeButton } from '~/components/LikeButton';
import { DashboardLayout } from '~/components/dashboard/DashboardLayout';
interface LikedCollection {
id: string;
likedAt: string;
collection: {
id: string;
name: string;
description: string | null;
isPublic: boolean;
likeCount: number;
toolCount: number;
createdBy: {
id: string;
name: string;
};
};
}
export default function LikedCollectionsPage(): React.ReactElement {
const [collections, setCollections] = useState<LikedCollection[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [hasMore, setHasMore] = useState(false);
const [offset, setOffset] = useState(0);
const limit = 20;
const fetchCollections = useCallback(async (currentOffset: number) => {
try {
const response = await fetch(
`/api/user/likes/collections?limit=${limit}&offset=${currentOffset}`
);
const data = await response.json();
if (data.success) {
if (currentOffset === 0) {
setCollections(data.data);
} else {
setCollections((prev) => [...prev, ...data.data]);
}
setHasMore(data.pagination.hasMore);
} else {
setError(data.error?.message || 'Failed to fetch liked collections');
}
} catch (err) {
console.error('Failed to fetch liked collections:', err);
setError('Failed to fetch liked collections');
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
fetchCollections(0);
}, [fetchCollections]);
const loadMore = () => {
const newOffset = offset + limit;
setOffset(newOffset);
fetchCollections(newOffset);
};
const handleUnlike = (collectionId: string) => {
setCollections((prev) => prev.filter((c) => c.collection.id !== collectionId));
};
if (error) {
return (
<DashboardLayout title="Liked Collections">
<div className="text-center py-16">
<Icon icon="alertCircle" size="lg" className="mx-auto text-error mb-4" />
<h2 className="text-lg font-medium text-foreground mb-2">Error</h2>
<p className="text-foreground-secondary mb-4">{error}</p>
<Button onClick={() => fetchCollections(0)}>Try Again</Button>
</div>
</DashboardLayout>
);
}
return (
<DashboardLayout
title="Liked Collections"
subtitle={
!isLoading
? `${collections.length} collection${collections.length !== 1 ? 's' : ''}`
: undefined
}
>
{isLoading ? (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{[1, 2, 3, 4, 5, 6].map((i) => (
<div
key={i}
className="bg-background border border-border rounded-lg p-4 animate-pulse"
>
<div className="h-5 bg-surface-secondary rounded w-3/4 mb-2" />
<div className="h-4 bg-surface-secondary rounded w-full mb-1" />
<div className="h-4 bg-surface-secondary rounded w-2/3" />
</div>
))}
</div>
) : collections.length === 0 ? (
<div className="text-center py-16">
<div className="w-16 h-16 rounded-full bg-primary/10 flex items-center justify-center mx-auto mb-4">
<Icon icon="heart" size="lg" className="text-primary" />
</div>
<h2 className="text-lg font-medium text-foreground mb-2">No liked collections yet</h2>
<p className="text-foreground-secondary mb-4">
Browse public collections and click the heart icon to save your favorites
</p>
<Link href="/collections">
<Button>Browse Collections</Button>
</Link>
</div>
) : (
<>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{collections.map((item) => (
<div
key={item.id}
className="bg-background border border-border rounded-lg p-4 hover:border-foreground/20 transition-colors"
>
<div className="flex items-start justify-between mb-2">
<Link
href={`/collections/${item.collection.id}`}
className="font-medium text-foreground hover:text-primary transition-colors"
>
{item.collection.name}
</Link>
<LikeButton
entityType="collection"
entityId={item.collection.id}
initialLiked={true}
initialCount={item.collection.likeCount}
size="sm"
onLikeChange={(liked) => {
if (!liked) handleUnlike(item.collection.id);
}}
/>
</div>
{item.collection.description && (
<p className="text-sm text-foreground-secondary line-clamp-2 mb-3">
{item.collection.description}
</p>
)}
<div className="flex items-center gap-2 text-xs text-foreground-tertiary">
<span>
{item.collection.toolCount} tool{item.collection.toolCount !== 1 ? 's' : ''}
</span>
<span></span>
<span>by {item.collection.createdBy.name}</span>
{item.collection.isPublic && (
<>
<span></span>
<Badge variant="secondary" size="sm">
Public
</Badge>
</>
)}
</div>
</div>
))}
</div>
{hasMore && (
<div className="mt-6 text-center">
<Button variant="outline" onClick={loadMore}>
Load More
</Button>
</div>
)}
</>
)}
</DashboardLayout>
);
}

View file

@ -0,0 +1,138 @@
'use client';
import { Icon } from '@tpmjs/ui/Icon/Icon';
import Link from 'next/link';
import { useCallback, useEffect, useState } from 'react';
import { DashboardLayout } from '~/components/dashboard/DashboardLayout';
interface LikeCounts {
tools: number;
collections: number;
agents: number;
toolsHasMore: boolean;
collectionsHasMore: boolean;
agentsHasMore: boolean;
}
export default function LikesOverviewPage(): React.ReactElement {
const [counts, setCounts] = useState<LikeCounts>({
tools: 0,
collections: 0,
agents: 0,
toolsHasMore: false,
collectionsHasMore: false,
agentsHasMore: false,
});
const [isLoading, setIsLoading] = useState(true);
const fetchCounts = useCallback(async () => {
try {
const [toolsRes, collectionsRes, agentsRes] = await Promise.all([
fetch('/api/user/likes/tools?limit=1'),
fetch('/api/user/likes/collections?limit=1'),
fetch('/api/user/likes/agents?limit=1'),
]);
const [toolsData, collectionsData, agentsData] = await Promise.all([
toolsRes.json(),
collectionsRes.json(),
agentsRes.json(),
]);
// Get counts from the data arrays
// Note: This is a rough count, ideally we'd have a count endpoint
setCounts({
tools: toolsData.success ? toolsData.data.length : 0,
collections: collectionsData.success ? collectionsData.data.length : 0,
agents: agentsData.success ? agentsData.data.length : 0,
toolsHasMore: toolsData.success ? toolsData.pagination.hasMore : false,
collectionsHasMore: collectionsData.success ? collectionsData.pagination.hasMore : false,
agentsHasMore: agentsData.success ? agentsData.pagination.hasMore : false,
});
} catch (err) {
console.error('Failed to fetch like counts:', err);
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
fetchCounts();
}, [fetchCounts]);
const sections = [
{
href: '/dashboard/likes/tools',
title: 'Liked Tools',
description: "Tools you've saved for quick access",
icon: 'puzzle' as const,
count: counts.tools,
hasMore: counts.toolsHasMore,
},
{
href: '/dashboard/likes/collections',
title: 'Liked Collections',
description: "Curated tool collections you've bookmarked",
icon: 'folder' as const,
count: counts.collections,
hasMore: counts.collectionsHasMore,
},
{
href: '/dashboard/likes/agents',
title: 'Liked Agents',
description: "AI agents you've found useful",
icon: 'terminal' as const,
count: counts.agents,
hasMore: counts.agentsHasMore,
},
];
return (
<DashboardLayout
title="Your Likes"
subtitle="Manage your favorite tools, collections, and agents"
>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{sections.map((section) => (
<Link key={section.href} href={section.href} className="block">
<div className="bg-background border border-border rounded-lg p-6 hover:border-foreground/20 transition-colors group h-full">
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-lg bg-primary/10 flex items-center justify-center group-hover:bg-primary/20 transition-colors">
<Icon icon={section.icon} size="md" className="text-primary" />
</div>
<div className="flex-1">
<div className="flex items-center gap-2">
<h2 className="text-lg font-medium text-foreground">{section.title}</h2>
{!isLoading && (
<span className="text-sm text-foreground-tertiary">
({section.count}
{section.hasMore ? '+' : ''})
</span>
)}
</div>
<p className="text-sm text-foreground-secondary">{section.description}</p>
</div>
</div>
</div>
</Link>
))}
</div>
<div className="mt-8 p-6 bg-surface/50 border border-border rounded-lg">
<div className="flex items-start gap-4">
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0">
<Icon icon="heart" size="sm" className="text-primary" />
</div>
<div>
<h3 className="font-medium text-foreground mb-1">How likes work</h3>
<p className="text-sm text-foreground-secondary">
Click the heart icon on any tool, collection, or agent to save it to your likes. Your
liked items appear here for quick access. Likes also help others discover popular
content on the platform.
</p>
</div>
</div>
</div>
</DashboardLayout>
);
}

View file

@ -0,0 +1,168 @@
'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 { useCallback, useEffect, useState } from 'react';
import { LikeButton } from '~/components/LikeButton';
import { DashboardLayout } from '~/components/dashboard/DashboardLayout';
interface LikedTool {
id: string;
likedAt: string;
tool: {
id: string;
name: string;
description: string;
likeCount: number;
package: {
id: string;
npmPackageName: string;
category: string;
};
};
}
export default function LikedToolsPage(): React.ReactElement {
const [tools, setTools] = useState<LikedTool[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [hasMore, setHasMore] = useState(false);
const [offset, setOffset] = useState(0);
const limit = 20;
const fetchTools = useCallback(async (currentOffset: number) => {
try {
const response = await fetch(`/api/user/likes/tools?limit=${limit}&offset=${currentOffset}`);
const data = await response.json();
if (data.success) {
if (currentOffset === 0) {
setTools(data.data);
} else {
setTools((prev) => [...prev, ...data.data]);
}
setHasMore(data.pagination.hasMore);
} else {
setError(data.error?.message || 'Failed to fetch liked tools');
}
} catch (err) {
console.error('Failed to fetch liked tools:', err);
setError('Failed to fetch liked tools');
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
fetchTools(0);
}, [fetchTools]);
const loadMore = () => {
const newOffset = offset + limit;
setOffset(newOffset);
fetchTools(newOffset);
};
const handleUnlike = (toolId: string) => {
setTools((prev) => prev.filter((t) => t.tool.id !== toolId));
};
if (error) {
return (
<DashboardLayout title="Liked Tools">
<div className="text-center py-16">
<Icon icon="alertCircle" size="lg" className="mx-auto text-error mb-4" />
<h2 className="text-lg font-medium text-foreground mb-2">Error</h2>
<p className="text-foreground-secondary mb-4">{error}</p>
<Button onClick={() => fetchTools(0)}>Try Again</Button>
</div>
</DashboardLayout>
);
}
return (
<DashboardLayout
title="Liked Tools"
subtitle={!isLoading ? `${tools.length} tool${tools.length !== 1 ? 's' : ''}` : undefined}
>
{isLoading ? (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{[1, 2, 3, 4, 5, 6].map((i) => (
<div
key={i}
className="bg-background border border-border rounded-lg p-4 animate-pulse"
>
<div className="h-5 bg-surface-secondary rounded w-3/4 mb-2" />
<div className="h-4 bg-surface-secondary rounded w-full mb-1" />
<div className="h-4 bg-surface-secondary rounded w-2/3" />
</div>
))}
</div>
) : tools.length === 0 ? (
<div className="text-center py-16">
<div className="w-16 h-16 rounded-full bg-primary/10 flex items-center justify-center mx-auto mb-4">
<Icon icon="heart" size="lg" className="text-primary" />
</div>
<h2 className="text-lg font-medium text-foreground mb-2">No liked tools yet</h2>
<p className="text-foreground-secondary mb-4">
Browse tools and click the heart icon to save your favorites
</p>
<Link href="/tool/tool-search">
<Button>Browse Tools</Button>
</Link>
</div>
) : (
<>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{tools.map((item) => (
<div
key={item.id}
className="bg-background border border-border rounded-lg p-4 hover:border-foreground/20 transition-colors"
>
<div className="flex items-start justify-between mb-2">
<Link
href={`/tool/${item.tool.package.npmPackageName}/${item.tool.name}`}
className="font-medium text-foreground hover:text-primary transition-colors"
>
{item.tool.name}
</Link>
<LikeButton
entityType="tool"
entityId={item.tool.id}
initialLiked={true}
initialCount={item.tool.likeCount}
size="sm"
onLikeChange={(liked) => {
if (!liked) handleUnlike(item.tool.id);
}}
/>
</div>
<p className="text-sm text-foreground-secondary line-clamp-2 mb-3">
{item.tool.description}
</p>
<div className="flex items-center gap-2">
<Badge variant="secondary" size="sm">
{item.tool.package.category}
</Badge>
<span className="text-xs text-foreground-tertiary">
{item.tool.package.npmPackageName}
</span>
</div>
</div>
))}
</div>
{hasMore && (
<div className="mt-6 text-center">
<Button variant="outline" onClick={loadMore}>
Load More
</Button>
</div>
)}
</>
)}
</DashboardLayout>
);
}

View file

@ -140,6 +140,16 @@ export function AppHeader(): React.ReactElement {
Tools
</Button>
</Link>
<Link href="/collections">
<Button variant="ghost" size="sm" className="text-foreground hover:text-foreground">
Collections
</Button>
</Link>
<Link href="/agents">
<Button variant="ghost" size="sm" className="text-foreground hover:text-foreground">
Agents
</Button>
</Link>
<a href="https://playground.tpmjs.com" target="_blank" rel="noopener noreferrer">
<Button variant="ghost" size="sm" className="text-foreground hover:text-foreground">
Playground

View file

@ -0,0 +1,147 @@
'use client';
import { useSession } from '@/lib/auth-client';
import { Button } from '@tpmjs/ui/Button/Button';
import { Icon } from '@tpmjs/ui/Icon/Icon';
import { useCallback, useEffect, useState } from 'react';
export type LikeEntityType = 'tool' | 'collection' | 'agent';
interface LikeButtonProps {
entityType: LikeEntityType;
entityId: string;
initialLiked?: boolean;
initialCount?: number;
showCount?: boolean;
size?: 'sm' | 'md';
variant?: 'ghost' | 'outline';
className?: string;
onLikeChange?: (liked: boolean, count: number) => void;
}
export function LikeButton({
entityType,
entityId,
initialLiked = false,
initialCount = 0,
showCount = true,
size = 'sm',
variant = 'ghost',
className,
onLikeChange,
}: LikeButtonProps): React.ReactElement {
const { data: session } = useSession();
const [liked, setLiked] = useState(initialLiked);
const [count, setCount] = useState(initialCount);
const [isLoading, setIsLoading] = useState(false);
const [hasFetched, setHasFetched] = useState(false);
// Fetch initial like status when user is logged in
useEffect(() => {
if (!session || hasFetched) return;
const fetchLikeStatus = async () => {
try {
const response = await fetch(`/api/${entityType}s/${entityId}/like`);
const data = await response.json();
if (data.success) {
setLiked(data.data.liked);
setCount(data.data.likeCount);
}
} catch (error) {
console.error('Failed to fetch like status:', error);
} finally {
setHasFetched(true);
}
};
fetchLikeStatus();
}, [session, entityType, entityId, hasFetched]);
// Update from props when they change
useEffect(() => {
if (!hasFetched) {
setLiked(initialLiked);
setCount(initialCount);
}
}, [initialLiked, initialCount, hasFetched]);
const handleClick = useCallback(async () => {
if (!session) {
// Redirect to sign in
window.location.href = '/sign-in';
return;
}
if (isLoading) return;
// Optimistic update
const newLiked = !liked;
const newCount = newLiked ? count + 1 : Math.max(0, count - 1);
setLiked(newLiked);
setCount(newCount);
setIsLoading(true);
try {
const response = await fetch(`/api/${entityType}s/${entityId}/like`, {
method: newLiked ? 'POST' : 'DELETE',
});
const data = await response.json();
if (data.success) {
// Update with server values
setLiked(data.data.liked);
setCount(data.data.likeCount);
onLikeChange?.(data.data.liked, data.data.likeCount);
} else {
// Revert on error
setLiked(!newLiked);
setCount(liked ? count : Math.max(0, count - 1));
}
} catch (error) {
console.error('Failed to toggle like:', error);
// Revert on error
setLiked(!newLiked);
setCount(liked ? count : Math.max(0, count - 1));
} finally {
setIsLoading(false);
}
}, [session, liked, count, isLoading, entityType, entityId, onLikeChange]);
return (
<Button
variant={variant}
size={size}
onClick={handleClick}
disabled={isLoading}
className={className}
aria-label={liked ? `Unlike this ${entityType}` : `Like this ${entityType}`}
>
<Icon
icon={liked ? 'heartFilled' : 'heart'}
size="sm"
className={liked ? 'text-red-500' : ''}
/>
{showCount && <span className="ml-1">{count}</span>}
</Button>
);
}
/**
* Display-only like count (no interaction)
*/
interface LikeCountProps {
count: number;
className?: string;
}
export function LikeCount({ count, className }: LikeCountProps): React.ReactElement {
return (
<span className={`inline-flex items-center gap-1 text-foreground-secondary ${className || ''}`}>
<Icon icon="heart" size="xs" />
<span className="text-sm">{count}</span>
</span>
);
}

View file

@ -22,6 +22,8 @@ const navSections: NavSection[] = [
title: 'Explore',
links: [
{ href: '/tool/tool-search', label: 'Tools', description: 'Browse all tools' },
{ href: '/collections', label: 'Collections', description: 'Discover curated tool sets' },
{ href: '/agents', label: 'Agents', description: 'AI agents with tools' },
{
href: 'https://playground.tpmjs.com',
label: 'Playground',

View file

@ -22,6 +22,12 @@ const navItems: NavItem[] = [
{ href: '/dashboard/settings/api-keys', label: 'API Keys', icon: 'key' },
];
const likesNavItems: NavItem[] = [
{ href: '/dashboard/likes/tools', label: 'Tools', icon: 'puzzle' },
{ href: '/dashboard/likes/collections', label: 'Collections', icon: 'folder' },
{ href: '/dashboard/likes/agents', label: 'Agents', icon: 'terminal' },
];
interface DashboardLayoutProps {
children: React.ReactNode;
/** Title displayed in the header */
@ -51,6 +57,25 @@ export function DashboardLayout({
const router = useRouter();
const { data: session, isPending } = useSession();
const [sidebarOpen, setSidebarOpen] = useState(false);
const [likesExpanded, setLikesExpanded] = useState(() => {
if (typeof window !== 'undefined') {
const stored = localStorage.getItem('dashboard-likes-expanded');
return stored === 'true';
}
return false;
});
// Persist likes expanded state
useEffect(() => {
localStorage.setItem('dashboard-likes-expanded', String(likesExpanded));
}, [likesExpanded]);
// Auto-expand if on a likes page
useEffect(() => {
if (pathname.startsWith('/dashboard/likes')) {
setLikesExpanded(true);
}
}, [pathname]);
// Redirect to sign-in if not authenticated
useEffect(() => {
@ -72,6 +97,8 @@ export function DashboardLayout({
return pathname.startsWith(href);
};
const isLikesActive = pathname.startsWith('/dashboard/likes');
const getBackUrl = () => {
if (backUrl) return backUrl;
// Get parent route
@ -142,6 +169,52 @@ export function DashboardLayout({
)}
</Link>
))}
{/* Likes Section - Collapsible */}
<div className="pt-2 mt-2 border-t border-border">
<button
type="button"
onClick={() => setLikesExpanded(!likesExpanded)}
className={`
w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors
${
isLikesActive
? 'bg-primary/10 text-primary'
: 'text-foreground-secondary hover:text-foreground hover:bg-surface'
}
`}
>
<Icon icon="heart" size="sm" />
<span>Likes</span>
<Icon
icon={likesExpanded ? 'chevronDown' : 'chevronRight'}
size="xs"
className="ml-auto"
/>
</button>
{likesExpanded && (
<div className="ml-4 mt-1 space-y-1">
{likesNavItems.map((item) => (
<Link
key={item.href}
href={item.href}
className={`
flex items-center gap-3 px-3 py-1.5 rounded-lg text-sm transition-colors
${
isActive(item.href)
? 'bg-primary/10 text-primary font-medium'
: 'text-foreground-secondary hover:text-foreground hover:bg-surface'
}
`}
>
<Icon icon={item.icon} size="xs" />
<span>{item.label}</span>
</Link>
))}
</div>
)}
</div>
</nav>
{/* User section at bottom */}

View file

@ -81,6 +81,7 @@ model Tool {
// Tool Metrics
qualityScore Decimal? @map("quality_score") @db.Decimal(3, 2) // 0.00 to 1.00
likeCount Int @default(0) @map("like_count")
// Health Status Fields
importHealth HealthStatus? @default(UNKNOWN) @map("import_health")
@ -97,9 +98,11 @@ model Tool {
healthChecks HealthCheck[]
collections CollectionTool[]
agents AgentTool[]
likes ToolLike[]
@@unique([packageId, name])
@@index([qualityScore])
@@index([likeCount])
@@index([importHealth])
@@index([executionHealth])
@@index([lastHealthCheck])
@ -331,11 +334,14 @@ model User {
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
sessions Session[]
accounts Account[]
collections Collection[]
agents Agent[]
apiKeys UserApiKey[]
sessions Session[]
accounts Account[]
collections Collection[]
agents Agent[]
apiKeys UserApiKey[]
toolLikes ToolLike[]
collectionLikes CollectionLike[]
agentLikes AgentLike[]
@@map("users")
}
@ -408,6 +414,7 @@ model Collection {
name String @db.VarChar(100)
description String? @db.VarChar(500)
isPublic Boolean @default(false) @map("is_public")
likeCount Int @default(0) @map("like_count")
// Timestamps
createdAt DateTime @default(now()) @map("created_at")
@ -416,11 +423,13 @@ model Collection {
// Relations
tools CollectionTool[]
agents AgentCollection[]
likes CollectionLike[]
// Unique constraint: user can't have duplicate collection names
@@unique([userId, name])
@@index([userId])
@@index([isPublic])
@@index([likeCount])
@@index([createdAt])
@@map("collections")
}
@ -495,6 +504,7 @@ model Agent {
// Visibility
isPublic Boolean @default(false) @map("is_public")
likeCount Int @default(0) @map("like_count")
// Timestamps
createdAt DateTime @default(now()) @map("created_at")
@ -504,11 +514,13 @@ model Agent {
collections AgentCollection[]
tools AgentTool[]
conversations Conversation[]
likes AgentLike[]
@@unique([userId, name])
@@index([userId])
@@index([uid])
@@index([isPublic])
@@index([likeCount])
@@index([createdAt])
@@map("agents")
}
@ -640,3 +652,64 @@ model Message {
@@index([createdAt])
@@map("messages")
}
// ============================================================================
// Like Models
// ============================================================================
/// ToolLike - tracks users who liked a tool
model ToolLike {
id String @id @default(cuid())
// Relationships
userId String @map("user_id")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
toolId String @map("tool_id")
tool Tool @relation(fields: [toolId], references: [id], onDelete: Cascade)
// Timestamps
createdAt DateTime @default(now()) @map("created_at")
@@unique([userId, toolId])
@@index([toolId])
@@index([userId])
@@map("tool_likes")
}
/// CollectionLike - tracks users who liked a collection
model CollectionLike {
id String @id @default(cuid())
// Relationships
userId String @map("user_id")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
collectionId String @map("collection_id")
collection Collection @relation(fields: [collectionId], references: [id], onDelete: Cascade)
// Timestamps
createdAt DateTime @default(now()) @map("created_at")
@@unique([userId, collectionId])
@@index([collectionId])
@@index([userId])
@@map("collection_likes")
}
/// AgentLike - tracks users who liked an agent
model AgentLike {
id String @id @default(cuid())
// Relationships
userId String @map("user_id")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
agentId String @map("agent_id")
agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade)
// Timestamps
createdAt DateTime @default(now()) @map("created_at")
@@unique([userId, agentId])
@@index([agentId])
@@index([userId])
@@map("agent_likes")
}

View file

@ -132,6 +132,14 @@ export const icons = {
viewBox: '0 0 24 24',
path: 'M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z',
},
heart: {
viewBox: '0 0 24 24',
path: 'M16.5 3c-1.74 0-3.41.81-4.5 2.09C10.91 3.81 9.24 3 7.5 3 4.42 3 2 5.42 2 8.5c0 3.78 3.4 6.86 8.55 11.54L12 21.35l1.45-1.32C18.6 15.36 22 12.28 22 8.5 22 5.42 19.58 3 16.5 3zm-4.4 15.55l-.1.1-.1-.1C7.14 14.24 4 11.39 4 8.5 4 6.5 5.5 5 7.5 5c1.54 0 3.04.99 3.57 2.36h1.87C13.46 5.99 14.96 5 16.5 5c2 0 3.5 1.5 3.5 3.5 0 2.89-3.14 5.74-7.9 10.05z',
},
heartFilled: {
viewBox: '0 0 24 24',
path: 'M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z',
},
} as const;
export type IconName = keyof typeof icons;