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:
parent
b8b4e44b50
commit
54dedc3056
26 changed files with 3919 additions and 62 deletions
305
apps/web/src/app/agents/[id]/page.tsx
Normal file
305
apps/web/src/app/agents/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
247
apps/web/src/app/agents/page.tsx
Normal file
247
apps/web/src/app/agents/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
280
apps/web/src/app/api/agents/[id]/like/route.ts
Normal file
280
apps/web/src/app/api/agents/[id]/like/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
280
apps/web/src/app/api/collections/[id]/like/route.ts
Normal file
280
apps/web/src/app/api/collections/[id]/like/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
166
apps/web/src/app/api/public/agents/[id]/route.ts
Normal file
166
apps/web/src/app/api/public/agents/[id]/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
111
apps/web/src/app/api/public/agents/route.ts
Normal file
111
apps/web/src/app/api/public/agents/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
132
apps/web/src/app/api/public/collections/[id]/route.ts
Normal file
132
apps/web/src/app/api/public/collections/[id]/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
107
apps/web/src/app/api/public/collections/route.ts
Normal file
107
apps/web/src/app/api/public/collections/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
280
apps/web/src/app/api/tools/[id]/like/route.ts
Normal file
280
apps/web/src/app/api/tools/[id]/like/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
116
apps/web/src/app/api/user/likes/agents/route.ts
Normal file
116
apps/web/src/app/api/user/likes/agents/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
112
apps/web/src/app/api/user/likes/collections/route.ts
Normal file
112
apps/web/src/app/api/user/likes/collections/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
108
apps/web/src/app/api/user/likes/tools/route.ts
Normal file
108
apps/web/src/app/api/user/likes/tools/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
358
apps/web/src/app/collections/[id]/page.tsx
Normal file
358
apps/web/src/app/collections/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
237
apps/web/src/app/collections/page.tsx
Normal file
237
apps/web/src/app/collections/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
188
apps/web/src/app/dashboard/likes/agents/page.tsx
Normal file
188
apps/web/src/app/dashboard/likes/agents/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
184
apps/web/src/app/dashboard/likes/collections/page.tsx
Normal file
184
apps/web/src/app/dashboard/likes/collections/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
138
apps/web/src/app/dashboard/likes/page.tsx
Normal file
138
apps/web/src/app/dashboard/likes/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
168
apps/web/src/app/dashboard/likes/tools/page.tsx
Normal file
168
apps/web/src/app/dashboard/likes/tools/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
147
apps/web/src/components/LikeButton.tsx
Normal file
147
apps/web/src/components/LikeButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue