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({
|
// Create agent with auto-like (user likes their own agent)
|
||||||
data: {
|
const agent = await prisma.$transaction(async (tx) => {
|
||||||
userId: session.user.id,
|
const newAgent = await tx.agent.create({
|
||||||
uid: finalUid,
|
data: {
|
||||||
name,
|
userId: session.user.id,
|
||||||
description,
|
uid: finalUid,
|
||||||
provider,
|
name,
|
||||||
modelId,
|
description,
|
||||||
systemPrompt,
|
provider,
|
||||||
temperature,
|
modelId,
|
||||||
maxToolCallsPerTurn,
|
systemPrompt,
|
||||||
maxMessagesInContext,
|
temperature,
|
||||||
isPublic,
|
maxToolCallsPerTurn,
|
||||||
collections: collectionIds?.length
|
maxMessagesInContext,
|
||||||
? {
|
isPublic,
|
||||||
create: collectionIds.map((collectionId, index) => ({
|
likeCount: 1, // Start with 1 like (from owner)
|
||||||
collectionId,
|
collections: collectionIds?.length
|
||||||
position: index,
|
? {
|
||||||
})),
|
create: collectionIds.map((collectionId, index) => ({
|
||||||
}
|
collectionId,
|
||||||
: undefined,
|
position: index,
|
||||||
tools: toolIds?.length
|
})),
|
||||||
? {
|
}
|
||||||
create: toolIds.map((toolId, index) => ({
|
: undefined,
|
||||||
toolId,
|
tools: toolIds?.length
|
||||||
position: index,
|
? {
|
||||||
})),
|
create: toolIds.map((toolId, index) => ({
|
||||||
}
|
toolId,
|
||||||
: undefined,
|
position: index,
|
||||||
},
|
})),
|
||||||
select: {
|
}
|
||||||
id: true,
|
: undefined,
|
||||||
uid: true,
|
},
|
||||||
name: true,
|
select: {
|
||||||
description: true,
|
id: true,
|
||||||
provider: true,
|
uid: true,
|
||||||
modelId: true,
|
name: true,
|
||||||
systemPrompt: true,
|
description: true,
|
||||||
temperature: true,
|
provider: true,
|
||||||
maxToolCallsPerTurn: true,
|
modelId: true,
|
||||||
maxMessagesInContext: true,
|
systemPrompt: true,
|
||||||
isPublic: true,
|
temperature: true,
|
||||||
createdAt: true,
|
maxToolCallsPerTurn: true,
|
||||||
updatedAt: true,
|
maxMessagesInContext: true,
|
||||||
_count: {
|
isPublic: true,
|
||||||
select: {
|
createdAt: true,
|
||||||
tools: true,
|
updatedAt: true,
|
||||||
collections: 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(
|
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
|
// Create collection with auto-like (user likes their own collection)
|
||||||
const collection = await prisma.collection.create({
|
const collection = await prisma.$transaction(async (tx) => {
|
||||||
data: {
|
const newCollection = await tx.collection.create({
|
||||||
userId: session.user.id,
|
data: {
|
||||||
name,
|
userId: session.user.id,
|
||||||
description: description || null,
|
name,
|
||||||
isPublic,
|
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(
|
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
|
Tools
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</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">
|
<a href="https://playground.tpmjs.com" target="_blank" rel="noopener noreferrer">
|
||||||
<Button variant="ghost" size="sm" className="text-foreground hover:text-foreground">
|
<Button variant="ghost" size="sm" className="text-foreground hover:text-foreground">
|
||||||
Playground
|
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',
|
title: 'Explore',
|
||||||
links: [
|
links: [
|
||||||
{ href: '/tool/tool-search', label: 'Tools', description: 'Browse all tools' },
|
{ 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',
|
href: 'https://playground.tpmjs.com',
|
||||||
label: 'Playground',
|
label: 'Playground',
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,12 @@ const navItems: NavItem[] = [
|
||||||
{ href: '/dashboard/settings/api-keys', label: 'API Keys', icon: 'key' },
|
{ 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 {
|
interface DashboardLayoutProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
/** Title displayed in the header */
|
/** Title displayed in the header */
|
||||||
|
|
@ -51,6 +57,25 @@ export function DashboardLayout({
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { data: session, isPending } = useSession();
|
const { data: session, isPending } = useSession();
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
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
|
// Redirect to sign-in if not authenticated
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -72,6 +97,8 @@ export function DashboardLayout({
|
||||||
return pathname.startsWith(href);
|
return pathname.startsWith(href);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isLikesActive = pathname.startsWith('/dashboard/likes');
|
||||||
|
|
||||||
const getBackUrl = () => {
|
const getBackUrl = () => {
|
||||||
if (backUrl) return backUrl;
|
if (backUrl) return backUrl;
|
||||||
// Get parent route
|
// Get parent route
|
||||||
|
|
@ -142,6 +169,52 @@ export function DashboardLayout({
|
||||||
)}
|
)}
|
||||||
</Link>
|
</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>
|
</nav>
|
||||||
|
|
||||||
{/* User section at bottom */}
|
{/* User section at bottom */}
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,7 @@ model Tool {
|
||||||
|
|
||||||
// Tool Metrics
|
// Tool Metrics
|
||||||
qualityScore Decimal? @map("quality_score") @db.Decimal(3, 2) // 0.00 to 1.00
|
qualityScore Decimal? @map("quality_score") @db.Decimal(3, 2) // 0.00 to 1.00
|
||||||
|
likeCount Int @default(0) @map("like_count")
|
||||||
|
|
||||||
// Health Status Fields
|
// Health Status Fields
|
||||||
importHealth HealthStatus? @default(UNKNOWN) @map("import_health")
|
importHealth HealthStatus? @default(UNKNOWN) @map("import_health")
|
||||||
|
|
@ -97,9 +98,11 @@ model Tool {
|
||||||
healthChecks HealthCheck[]
|
healthChecks HealthCheck[]
|
||||||
collections CollectionTool[]
|
collections CollectionTool[]
|
||||||
agents AgentTool[]
|
agents AgentTool[]
|
||||||
|
likes ToolLike[]
|
||||||
|
|
||||||
@@unique([packageId, name])
|
@@unique([packageId, name])
|
||||||
@@index([qualityScore])
|
@@index([qualityScore])
|
||||||
|
@@index([likeCount])
|
||||||
@@index([importHealth])
|
@@index([importHealth])
|
||||||
@@index([executionHealth])
|
@@index([executionHealth])
|
||||||
@@index([lastHealthCheck])
|
@@index([lastHealthCheck])
|
||||||
|
|
@ -331,11 +334,14 @@ model User {
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
sessions Session[]
|
sessions Session[]
|
||||||
accounts Account[]
|
accounts Account[]
|
||||||
collections Collection[]
|
collections Collection[]
|
||||||
agents Agent[]
|
agents Agent[]
|
||||||
apiKeys UserApiKey[]
|
apiKeys UserApiKey[]
|
||||||
|
toolLikes ToolLike[]
|
||||||
|
collectionLikes CollectionLike[]
|
||||||
|
agentLikes AgentLike[]
|
||||||
|
|
||||||
@@map("users")
|
@@map("users")
|
||||||
}
|
}
|
||||||
|
|
@ -408,6 +414,7 @@ model Collection {
|
||||||
name String @db.VarChar(100)
|
name String @db.VarChar(100)
|
||||||
description String? @db.VarChar(500)
|
description String? @db.VarChar(500)
|
||||||
isPublic Boolean @default(false) @map("is_public")
|
isPublic Boolean @default(false) @map("is_public")
|
||||||
|
likeCount Int @default(0) @map("like_count")
|
||||||
|
|
||||||
// Timestamps
|
// Timestamps
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
@ -416,11 +423,13 @@ model Collection {
|
||||||
// Relations
|
// Relations
|
||||||
tools CollectionTool[]
|
tools CollectionTool[]
|
||||||
agents AgentCollection[]
|
agents AgentCollection[]
|
||||||
|
likes CollectionLike[]
|
||||||
|
|
||||||
// Unique constraint: user can't have duplicate collection names
|
// Unique constraint: user can't have duplicate collection names
|
||||||
@@unique([userId, name])
|
@@unique([userId, name])
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@index([isPublic])
|
@@index([isPublic])
|
||||||
|
@@index([likeCount])
|
||||||
@@index([createdAt])
|
@@index([createdAt])
|
||||||
@@map("collections")
|
@@map("collections")
|
||||||
}
|
}
|
||||||
|
|
@ -495,6 +504,7 @@ model Agent {
|
||||||
|
|
||||||
// Visibility
|
// Visibility
|
||||||
isPublic Boolean @default(false) @map("is_public")
|
isPublic Boolean @default(false) @map("is_public")
|
||||||
|
likeCount Int @default(0) @map("like_count")
|
||||||
|
|
||||||
// Timestamps
|
// Timestamps
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
@ -504,11 +514,13 @@ model Agent {
|
||||||
collections AgentCollection[]
|
collections AgentCollection[]
|
||||||
tools AgentTool[]
|
tools AgentTool[]
|
||||||
conversations Conversation[]
|
conversations Conversation[]
|
||||||
|
likes AgentLike[]
|
||||||
|
|
||||||
@@unique([userId, name])
|
@@unique([userId, name])
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@index([uid])
|
@@index([uid])
|
||||||
@@index([isPublic])
|
@@index([isPublic])
|
||||||
|
@@index([likeCount])
|
||||||
@@index([createdAt])
|
@@index([createdAt])
|
||||||
@@map("agents")
|
@@map("agents")
|
||||||
}
|
}
|
||||||
|
|
@ -640,3 +652,64 @@ model Message {
|
||||||
@@index([createdAt])
|
@@index([createdAt])
|
||||||
@@map("messages")
|
@@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',
|
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',
|
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;
|
} as const;
|
||||||
|
|
||||||
export type IconName = keyof typeof icons;
|
export type IconName = keyof typeof icons;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue