From 54dedc3056a9f02c3bd2a1fb4ac8b498cd9278ab Mon Sep 17 00:00:00 2001 From: Ajax Davis Date: Wed, 7 Jan 2026 21:04:31 +1000 Subject: [PATCH] feat: add like/love system for tools, collections, and agents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- apps/web/src/app/agents/[id]/page.tsx | 305 +++++++++++++++ apps/web/src/app/agents/page.tsx | 247 ++++++++++++ .../web/src/app/api/agents/[id]/like/route.ts | 280 ++++++++++++++ apps/web/src/app/api/agents/route.ts | 112 +++--- .../app/api/collections/[id]/like/route.ts | 280 ++++++++++++++ apps/web/src/app/api/collections/route.ts | 29 +- .../src/app/api/public/agents/[id]/route.ts | 166 ++++++++ apps/web/src/app/api/public/agents/route.ts | 111 ++++++ .../app/api/public/collections/[id]/route.ts | 132 +++++++ .../src/app/api/public/collections/route.ts | 107 ++++++ apps/web/src/app/api/tools/[id]/like/route.ts | 280 ++++++++++++++ .../src/app/api/user/likes/agents/route.ts | 116 ++++++ .../app/api/user/likes/collections/route.ts | 112 ++++++ .../web/src/app/api/user/likes/tools/route.ts | 108 ++++++ apps/web/src/app/collections/[id]/page.tsx | 358 ++++++++++++++++++ apps/web/src/app/collections/page.tsx | 237 ++++++++++++ .../src/app/dashboard/likes/agents/page.tsx | 188 +++++++++ .../app/dashboard/likes/collections/page.tsx | 184 +++++++++ apps/web/src/app/dashboard/likes/page.tsx | 138 +++++++ .../src/app/dashboard/likes/tools/page.tsx | 168 ++++++++ apps/web/src/components/AppHeader.tsx | 10 + apps/web/src/components/LikeButton.tsx | 147 +++++++ apps/web/src/components/MobileMenu.tsx | 2 + .../components/dashboard/DashboardLayout.tsx | 73 ++++ packages/db/prisma/schema.prisma | 83 +++- packages/ui/src/Icon/icons.ts | 8 + 26 files changed, 3919 insertions(+), 62 deletions(-) create mode 100644 apps/web/src/app/agents/[id]/page.tsx create mode 100644 apps/web/src/app/agents/page.tsx create mode 100644 apps/web/src/app/api/agents/[id]/like/route.ts create mode 100644 apps/web/src/app/api/collections/[id]/like/route.ts create mode 100644 apps/web/src/app/api/public/agents/[id]/route.ts create mode 100644 apps/web/src/app/api/public/agents/route.ts create mode 100644 apps/web/src/app/api/public/collections/[id]/route.ts create mode 100644 apps/web/src/app/api/public/collections/route.ts create mode 100644 apps/web/src/app/api/tools/[id]/like/route.ts create mode 100644 apps/web/src/app/api/user/likes/agents/route.ts create mode 100644 apps/web/src/app/api/user/likes/collections/route.ts create mode 100644 apps/web/src/app/api/user/likes/tools/route.ts create mode 100644 apps/web/src/app/collections/[id]/page.tsx create mode 100644 apps/web/src/app/collections/page.tsx create mode 100644 apps/web/src/app/dashboard/likes/agents/page.tsx create mode 100644 apps/web/src/app/dashboard/likes/collections/page.tsx create mode 100644 apps/web/src/app/dashboard/likes/page.tsx create mode 100644 apps/web/src/app/dashboard/likes/tools/page.tsx create mode 100644 apps/web/src/components/LikeButton.tsx diff --git a/apps/web/src/app/agents/[id]/page.tsx b/apps/web/src/app/agents/[id]/page.tsx new file mode 100644 index 0000000..c695c31 --- /dev/null +++ b/apps/web/src/app/agents/[id]/page.tsx @@ -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(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(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 ( +
+ +
+
+
+
+
+
+ {[1, 2, 3].map((i) => ( +
+ ))} +
+
+
+
+ ); + } + + if (error || !agent) { + return ( +
+ +
+
+ +

+ {error || 'Agent not found'} +

+

+ This agent may be private or no longer available. +

+ + + +
+
+
+ ); + } + + return ( +
+ + +
+ {/* Back link */} + + + Back to Agents + + + {/* Header */} +
+
+

{agent.name}

+ {agent.description &&

{agent.description}

} +
+ +
+ + {/* Meta info */} +
+
+ {agent.createdBy.image ? ( + {agent.createdBy.name} + ) : ( +
+ +
+ )} + Created by {agent.createdBy.name} +
+ • + + {agent.toolCount} tool{agent.toolCount !== 1 ? 's' : ''} + +
+ + {/* Configuration */} +
+

Configuration

+
+
+ Provider +

{agent.provider}

+
+
+ Model +

{agent.modelId}

+
+
+ Temperature +

{agent.temperature}

+
+
+ Max Tool Calls +

{agent.maxToolCallsPerTurn}

+
+
+ + {agent.systemPrompt && ( +
+ System Prompt +
+                {agent.systemPrompt}
+              
+
+ )} +
+ + {/* Tools */} +
+

Tools

+ + {agent.tools.length === 0 ? ( +
+ +

No tools configured for this agent

+
+ ) : ( +
+ {agent.tools.map((at) => ( +
+
+
+ + {at.tool.name} + + + from {at.tool.package.npmPackageName} + +
+ +
+

+ {at.tool.description} +

+ + {at.tool.package.category} + +
+ ))} +
+ )} +
+ + {/* Collections */} + {agent.collections.length > 0 && ( +
+

Collections

+
+ {agent.collections.map((ac) => ( +
+ + {ac.collection.name} + + {ac.collection.description && ( +

+ {ac.collection.description} +

+ )} +

+ {ac.collection.toolCount} tool{ac.collection.toolCount !== 1 ? 's' : ''} +

+
+ ))} +
+
+ )} +
+
+ ); +} diff --git a/apps/web/src/app/agents/page.tsx b/apps/web/src/app/agents/page.tsx new file mode 100644 index 0000000..48bb312 --- /dev/null +++ b/apps/web/src/app/agents/page.tsx @@ -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([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [hasMore, setHasMore] = useState(false); + const [offset, setOffset] = useState(0); + const [search, setSearch] = useState(''); + const [sort, setSort] = useState('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 ( +
+ + +
+ {/* Header */} +
+

Public Agents

+

+ Discover AI agents created and shared by the community +

+
+ + {/* Filters */} +
+
+
+ + 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" + /> +
+
+ +
+ Sort: + +
+
+ + {/* Content */} + {error ? ( +
+ +

Error

+

{error}

+ +
+ ) : isLoading ? ( +
+ {[1, 2, 3, 4, 5, 6].map((i) => ( +
+
+
+
+
+
+ ))} +
+ ) : agents.length === 0 ? ( +
+
+ +
+

No agents found

+

+ {search ? 'Try adjusting your search terms' : 'Be the first to share a public agent!'} +

+
+ ) : ( + <> +
+ {agents.map((agent) => ( +
+
+ + {agent.name} + + +
+ + {agent.description && ( +

+ {agent.description} +

+ )} + +
+ + {agent.provider} + + {agent.modelId} +
+ +
+
+ + + {agent.toolCount} tool{agent.toolCount !== 1 ? 's' : ''} + +
+
+ {agent.createdBy.image ? ( + {agent.createdBy.name} + ) : ( +
+ +
+ )} + + {agent.createdBy.name} + +
+
+
+ ))} +
+ + {hasMore && ( +
+ +
+ )} + + )} +
+
+ ); +} diff --git a/apps/web/src/app/api/agents/[id]/like/route.ts b/apps/web/src/app/api/agents/[id]/like/route.ts new file mode 100644 index 0000000..a542ef0 --- /dev/null +++ b/apps/web/src/app/api/agents/[id]/like/route.ts @@ -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 { + success: boolean; + data?: T; + error?: { + code: string; + message: string; + details?: Record; + }; + 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> { + 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> { + 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> { + 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 } + ); + } +} diff --git a/apps/web/src/app/api/agents/route.ts b/apps/web/src/app/api/agents/route.ts index 88be484..7100849 100644 --- a/apps/web/src/app/api/agents/route.ts +++ b/apps/web/src/app/api/agents/route.ts @@ -154,57 +154,71 @@ export async function POST(request: NextRequest): Promise { ); } - const agent = await prisma.agent.create({ - data: { - userId: session.user.id, - uid: finalUid, - name, - description, - provider, - modelId, - systemPrompt, - temperature, - maxToolCallsPerTurn, - maxMessagesInContext, - isPublic, - collections: collectionIds?.length - ? { - create: collectionIds.map((collectionId, index) => ({ - collectionId, - position: index, - })), - } - : undefined, - tools: toolIds?.length - ? { - create: toolIds.map((toolId, index) => ({ - toolId, - position: index, - })), - } - : undefined, - }, - select: { - id: true, - uid: true, - name: true, - description: true, - provider: true, - modelId: true, - systemPrompt: true, - temperature: true, - maxToolCallsPerTurn: true, - maxMessagesInContext: true, - isPublic: true, - createdAt: true, - updatedAt: true, - _count: { - select: { - tools: true, - collections: true, + // Create agent with auto-like (user likes their own agent) + const agent = await prisma.$transaction(async (tx) => { + const newAgent = await tx.agent.create({ + data: { + userId: session.user.id, + uid: finalUid, + name, + description, + provider, + modelId, + systemPrompt, + temperature, + maxToolCallsPerTurn, + maxMessagesInContext, + isPublic, + likeCount: 1, // Start with 1 like (from owner) + collections: collectionIds?.length + ? { + create: collectionIds.map((collectionId, index) => ({ + collectionId, + position: index, + })), + } + : undefined, + tools: toolIds?.length + ? { + create: toolIds.map((toolId, index) => ({ + toolId, + position: index, + })), + } + : undefined, + }, + select: { + id: true, + uid: true, + name: true, + description: true, + provider: true, + modelId: true, + systemPrompt: true, + temperature: true, + maxToolCallsPerTurn: true, + maxMessagesInContext: true, + isPublic: true, + createdAt: true, + updatedAt: true, + _count: { + select: { + tools: true, + collections: true, + }, }, }, - }, + }); + + // Auto-like the agent + await tx.agentLike.create({ + data: { + userId: session.user.id, + agentId: newAgent.id, + }, + }); + + return newAgent; }); return NextResponse.json( diff --git a/apps/web/src/app/api/collections/[id]/like/route.ts b/apps/web/src/app/api/collections/[id]/like/route.ts new file mode 100644 index 0000000..72053f9 --- /dev/null +++ b/apps/web/src/app/api/collections/[id]/like/route.ts @@ -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 { + success: boolean; + data?: T; + error?: { + code: string; + message: string; + details?: Record; + }; + 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> { + 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> { + 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> { + 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 } + ); + } +} diff --git a/apps/web/src/app/api/collections/route.ts b/apps/web/src/app/api/collections/route.ts index 64e19e3..d35dd0a 100644 --- a/apps/web/src/app/api/collections/route.ts +++ b/apps/web/src/app/api/collections/route.ts @@ -199,14 +199,27 @@ export async function POST(request: NextRequest): Promise { + const newCollection = await tx.collection.create({ + data: { + userId: session.user.id, + name, + description: description || null, + isPublic, + likeCount: 1, // Start with 1 like (from owner) + }, + }); + + // Auto-like the collection + await tx.collectionLike.create({ + data: { + userId: session.user.id, + collectionId: newCollection.id, + }, + }); + + return newCollection; }); return NextResponse.json( diff --git a/apps/web/src/app/api/public/agents/[id]/route.ts b/apps/web/src/app/api/public/agents/[id]/route.ts new file mode 100644 index 0000000..b7d68d0 --- /dev/null +++ b/apps/web/src/app/api/public/agents/[id]/route.ts @@ -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 { + success: boolean; + data?: T; + error?: { + code: string; + message: string; + details?: Record; + }; + 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> { + 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 } + ); + } +} diff --git a/apps/web/src/app/api/public/agents/route.ts b/apps/web/src/app/api/public/agents/route.ts new file mode 100644 index 0000000..d9ba8af --- /dev/null +++ b/apps/web/src/app/api/public/agents/route.ts @@ -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 { + success: boolean; + data?: T; + error?: { + code: string; + message: string; + details?: Record; + }; + 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> { + 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 } + ); + } +} diff --git a/apps/web/src/app/api/public/collections/[id]/route.ts b/apps/web/src/app/api/public/collections/[id]/route.ts new file mode 100644 index 0000000..f2e24eb --- /dev/null +++ b/apps/web/src/app/api/public/collections/[id]/route.ts @@ -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 { + success: boolean; + data?: T; + error?: { + code: string; + message: string; + details?: Record; + }; + 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> { + 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 } + ); + } +} diff --git a/apps/web/src/app/api/public/collections/route.ts b/apps/web/src/app/api/public/collections/route.ts new file mode 100644 index 0000000..c67df88 --- /dev/null +++ b/apps/web/src/app/api/public/collections/route.ts @@ -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 { + success: boolean; + data?: T; + error?: { + code: string; + message: string; + details?: Record; + }; + 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> { + 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 } + ); + } +} diff --git a/apps/web/src/app/api/tools/[id]/like/route.ts b/apps/web/src/app/api/tools/[id]/like/route.ts new file mode 100644 index 0000000..c9717a3 --- /dev/null +++ b/apps/web/src/app/api/tools/[id]/like/route.ts @@ -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 { + success: boolean; + data?: T; + error?: { + code: string; + message: string; + details?: Record; + }; + 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> { + 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> { + 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> { + 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 } + ); + } +} diff --git a/apps/web/src/app/api/user/likes/agents/route.ts b/apps/web/src/app/api/user/likes/agents/route.ts new file mode 100644 index 0000000..f4a83a8 --- /dev/null +++ b/apps/web/src/app/api/user/likes/agents/route.ts @@ -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 { + success: boolean; + data?: T; + error?: { + code: string; + message: string; + details?: Record; + }; + 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> { + 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 } + ); + } +} diff --git a/apps/web/src/app/api/user/likes/collections/route.ts b/apps/web/src/app/api/user/likes/collections/route.ts new file mode 100644 index 0000000..f1a85aa --- /dev/null +++ b/apps/web/src/app/api/user/likes/collections/route.ts @@ -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 { + success: boolean; + data?: T; + error?: { + code: string; + message: string; + details?: Record; + }; + 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> { + 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 } + ); + } +} diff --git a/apps/web/src/app/api/user/likes/tools/route.ts b/apps/web/src/app/api/user/likes/tools/route.ts new file mode 100644 index 0000000..d0541d2 --- /dev/null +++ b/apps/web/src/app/api/user/likes/tools/route.ts @@ -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 { + success: boolean; + data?: T; + error?: { + code: string; + message: string; + details?: Record; + }; + 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> { + 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 } + ); + } +} diff --git a/apps/web/src/app/collections/[id]/page.tsx b/apps/web/src/app/collections/[id]/page.tsx new file mode 100644 index 0000000..9c24726 --- /dev/null +++ b/apps/web/src/app/collections/[id]/page.tsx @@ -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 ( +
+
+
+ +
+

MCP Server URLs

+
+ +
+ {/* HTTP Transport */} +
+
+ + HTTP Transport + + (recommended) +
+
+
+ {httpUrl} +
+ +
+
+ + {/* SSE Transport */} +
+
+ + SSE Transport + + (streaming) +
+
+
+ {sseUrl} +
+ +
+
+
+ + {/* Config snippet toggle */} +
+ + + {showConfig && ( +
+
+              {configSnippet}
+            
+ +
+ )} +
+ +

+ Use these URLs with{' '} + + Claude Desktop, Cursor, or any MCP client + +

+
+ ); +} + +export default function PublicCollectionDetailPage(): React.ReactElement { + const params = useParams(); + const collectionId = params.id as string; + + const [collection, setCollection] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(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 ( +
+ +
+
+
+
+
+
+ {[1, 2, 3].map((i) => ( +
+ ))} +
+
+
+
+ ); + } + + if (error || !collection) { + return ( +
+ +
+
+ +

+ {error || 'Collection not found'} +

+

+ This collection may be private or no longer available. +

+ + + +
+
+
+ ); + } + + return ( +
+ + +
+ {/* Back link */} + + + Back to Collections + + + {/* Header */} +
+
+

{collection.name}

+ {collection.description && ( +

{collection.description}

+ )} +
+ +
+ + {/* Meta info */} +
+
+ {collection.createdBy.image ? ( + {collection.createdBy.name} + ) : ( +
+ +
+ )} + Created by {collection.createdBy.name} +
+ • + + {collection.toolCount} tool{collection.toolCount !== 1 ? 's' : ''} + +
+ + {/* MCP URLs */} + + + {/* Tools */} +
+

Tools in this Collection

+ + {collection.tools.length === 0 ? ( +
+ +

No tools in this collection yet

+
+ ) : ( +
+ {collection.tools.map((ct) => ( +
+
+
+ + {ct.tool.name} + + + from {ct.tool.package.npmPackageName} + +
+ +
+

+ {ct.tool.description} +

+ + {ct.tool.package.category} + + {ct.note && ( +

Note: {ct.note}

+ )} +
+ ))} +
+ )} +
+
+
+ ); +} diff --git a/apps/web/src/app/collections/page.tsx b/apps/web/src/app/collections/page.tsx new file mode 100644 index 0000000..5c7c3b7 --- /dev/null +++ b/apps/web/src/app/collections/page.tsx @@ -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([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [hasMore, setHasMore] = useState(false); + const [offset, setOffset] = useState(0); + const [search, setSearch] = useState(''); + const [sort, setSort] = useState('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 ( +
+ + +
+ {/* Header */} +
+

Public Collections

+

+ Discover curated tool collections shared by the community +

+
+ + {/* Filters */} +
+
+
+ + 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" + /> +
+
+ +
+ Sort: + +
+
+ + {/* Content */} + {error ? ( +
+ +

Error

+

{error}

+ +
+ ) : isLoading ? ( +
+ {[1, 2, 3, 4, 5, 6].map((i) => ( +
+
+
+
+
+
+ ))} +
+ ) : collections.length === 0 ? ( +
+
+ +
+

No collections found

+

+ {search + ? 'Try adjusting your search terms' + : 'Be the first to share a public collection!'} +

+
+ ) : ( + <> +
+ {collections.map((collection) => ( +
+
+ + {collection.name} + + +
+ + {collection.description && ( +

+ {collection.description} +

+ )} + +
+
+ + + {collection.toolCount} tool{collection.toolCount !== 1 ? 's' : ''} + +
+
+ {collection.createdBy.image ? ( + {collection.createdBy.name} + ) : ( +
+ +
+ )} + + {collection.createdBy.name} + +
+
+
+ ))} +
+ + {hasMore && ( +
+ +
+ )} + + )} +
+
+ ); +} diff --git a/apps/web/src/app/dashboard/likes/agents/page.tsx b/apps/web/src/app/dashboard/likes/agents/page.tsx new file mode 100644 index 0000000..235003c --- /dev/null +++ b/apps/web/src/app/dashboard/likes/agents/page.tsx @@ -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([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(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 ( + +
+ +

Error

+

{error}

+ +
+
+ ); + } + + return ( + + {isLoading ? ( +
+ {[1, 2, 3, 4, 5, 6].map((i) => ( +
+
+
+
+
+ ))} +
+ ) : agents.length === 0 ? ( +
+
+ +
+

No liked agents yet

+

+ Browse public agents and click the heart icon to save your favorites +

+ + + +
+ ) : ( + <> +
+ {agents.map((item) => ( +
+
+ + {item.agent.name} + + { + if (!liked) handleUnlike(item.agent.id); + }} + /> +
+ {item.agent.description && ( +

+ {item.agent.description} +

+ )} +
+ + {item.agent.provider} + + {item.agent.modelId} +
+
+ + {item.agent.toolCount} tool{item.agent.toolCount !== 1 ? 's' : ''} + + • + by {item.agent.createdBy.name} + {item.agent.isPublic && ( + <> + • + + Public + + + )} +
+
+ ))} +
+ + {hasMore && ( +
+ +
+ )} + + )} + + ); +} diff --git a/apps/web/src/app/dashboard/likes/collections/page.tsx b/apps/web/src/app/dashboard/likes/collections/page.tsx new file mode 100644 index 0000000..7342701 --- /dev/null +++ b/apps/web/src/app/dashboard/likes/collections/page.tsx @@ -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([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(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 ( + +
+ +

Error

+

{error}

+ +
+
+ ); + } + + return ( + + {isLoading ? ( +
+ {[1, 2, 3, 4, 5, 6].map((i) => ( +
+
+
+
+
+ ))} +
+ ) : collections.length === 0 ? ( +
+
+ +
+

No liked collections yet

+

+ Browse public collections and click the heart icon to save your favorites +

+ + + +
+ ) : ( + <> +
+ {collections.map((item) => ( +
+
+ + {item.collection.name} + + { + if (!liked) handleUnlike(item.collection.id); + }} + /> +
+ {item.collection.description && ( +

+ {item.collection.description} +

+ )} +
+ + {item.collection.toolCount} tool{item.collection.toolCount !== 1 ? 's' : ''} + + • + by {item.collection.createdBy.name} + {item.collection.isPublic && ( + <> + • + + Public + + + )} +
+
+ ))} +
+ + {hasMore && ( +
+ +
+ )} + + )} + + ); +} diff --git a/apps/web/src/app/dashboard/likes/page.tsx b/apps/web/src/app/dashboard/likes/page.tsx new file mode 100644 index 0000000..99e0d61 --- /dev/null +++ b/apps/web/src/app/dashboard/likes/page.tsx @@ -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({ + 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 ( + +
+ {sections.map((section) => ( + +
+
+
+ +
+
+
+

{section.title}

+ {!isLoading && ( + + ({section.count} + {section.hasMore ? '+' : ''}) + + )} +
+

{section.description}

+
+
+
+ + ))} +
+ +
+
+
+ +
+
+

How likes work

+

+ 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. +

+
+
+
+
+ ); +} diff --git a/apps/web/src/app/dashboard/likes/tools/page.tsx b/apps/web/src/app/dashboard/likes/tools/page.tsx new file mode 100644 index 0000000..82023ba --- /dev/null +++ b/apps/web/src/app/dashboard/likes/tools/page.tsx @@ -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([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(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 ( + +
+ +

Error

+

{error}

+ +
+
+ ); + } + + return ( + + {isLoading ? ( +
+ {[1, 2, 3, 4, 5, 6].map((i) => ( +
+
+
+
+
+ ))} +
+ ) : tools.length === 0 ? ( +
+
+ +
+

No liked tools yet

+

+ Browse tools and click the heart icon to save your favorites +

+ + + +
+ ) : ( + <> +
+ {tools.map((item) => ( +
+
+ + {item.tool.name} + + { + if (!liked) handleUnlike(item.tool.id); + }} + /> +
+

+ {item.tool.description} +

+
+ + {item.tool.package.category} + + + {item.tool.package.npmPackageName} + +
+
+ ))} +
+ + {hasMore && ( +
+ +
+ )} + + )} + + ); +} diff --git a/apps/web/src/components/AppHeader.tsx b/apps/web/src/components/AppHeader.tsx index 2e5990c..a943473 100644 --- a/apps/web/src/components/AppHeader.tsx +++ b/apps/web/src/components/AppHeader.tsx @@ -140,6 +140,16 @@ export function AppHeader(): React.ReactElement { Tools + + + + + + + ); +} + +/** + * Display-only like count (no interaction) + */ +interface LikeCountProps { + count: number; + className?: string; +} + +export function LikeCount({ count, className }: LikeCountProps): React.ReactElement { + return ( + + + {count} + + ); +} diff --git a/apps/web/src/components/MobileMenu.tsx b/apps/web/src/components/MobileMenu.tsx index 00c31ac..e5e1946 100644 --- a/apps/web/src/components/MobileMenu.tsx +++ b/apps/web/src/components/MobileMenu.tsx @@ -22,6 +22,8 @@ const navSections: NavSection[] = [ title: 'Explore', links: [ { href: '/tool/tool-search', label: 'Tools', description: 'Browse all tools' }, + { href: '/collections', label: 'Collections', description: 'Discover curated tool sets' }, + { href: '/agents', label: 'Agents', description: 'AI agents with tools' }, { href: 'https://playground.tpmjs.com', label: 'Playground', diff --git a/apps/web/src/components/dashboard/DashboardLayout.tsx b/apps/web/src/components/dashboard/DashboardLayout.tsx index 988aec5..a08e322 100644 --- a/apps/web/src/components/dashboard/DashboardLayout.tsx +++ b/apps/web/src/components/dashboard/DashboardLayout.tsx @@ -22,6 +22,12 @@ const navItems: NavItem[] = [ { href: '/dashboard/settings/api-keys', label: 'API Keys', icon: 'key' }, ]; +const likesNavItems: NavItem[] = [ + { href: '/dashboard/likes/tools', label: 'Tools', icon: 'puzzle' }, + { href: '/dashboard/likes/collections', label: 'Collections', icon: 'folder' }, + { href: '/dashboard/likes/agents', label: 'Agents', icon: 'terminal' }, +]; + interface DashboardLayoutProps { children: React.ReactNode; /** Title displayed in the header */ @@ -51,6 +57,25 @@ export function DashboardLayout({ const router = useRouter(); const { data: session, isPending } = useSession(); const [sidebarOpen, setSidebarOpen] = useState(false); + const [likesExpanded, setLikesExpanded] = useState(() => { + if (typeof window !== 'undefined') { + const stored = localStorage.getItem('dashboard-likes-expanded'); + return stored === 'true'; + } + return false; + }); + + // Persist likes expanded state + useEffect(() => { + localStorage.setItem('dashboard-likes-expanded', String(likesExpanded)); + }, [likesExpanded]); + + // Auto-expand if on a likes page + useEffect(() => { + if (pathname.startsWith('/dashboard/likes')) { + setLikesExpanded(true); + } + }, [pathname]); // Redirect to sign-in if not authenticated useEffect(() => { @@ -72,6 +97,8 @@ export function DashboardLayout({ return pathname.startsWith(href); }; + const isLikesActive = pathname.startsWith('/dashboard/likes'); + const getBackUrl = () => { if (backUrl) return backUrl; // Get parent route @@ -142,6 +169,52 @@ export function DashboardLayout({ )} ))} + + {/* Likes Section - Collapsible */} +
+ + + {likesExpanded && ( +
+ {likesNavItems.map((item) => ( + + + {item.label} + + ))} +
+ )} +
{/* User section at bottom */} diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 9ce3a66..4c0fce1 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -81,6 +81,7 @@ model Tool { // Tool Metrics qualityScore Decimal? @map("quality_score") @db.Decimal(3, 2) // 0.00 to 1.00 + likeCount Int @default(0) @map("like_count") // Health Status Fields importHealth HealthStatus? @default(UNKNOWN) @map("import_health") @@ -97,9 +98,11 @@ model Tool { healthChecks HealthCheck[] collections CollectionTool[] agents AgentTool[] + likes ToolLike[] @@unique([packageId, name]) @@index([qualityScore]) + @@index([likeCount]) @@index([importHealth]) @@index([executionHealth]) @@index([lastHealthCheck]) @@ -331,11 +334,14 @@ model User { createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") - sessions Session[] - accounts Account[] - collections Collection[] - agents Agent[] - apiKeys UserApiKey[] + sessions Session[] + accounts Account[] + collections Collection[] + agents Agent[] + apiKeys UserApiKey[] + toolLikes ToolLike[] + collectionLikes CollectionLike[] + agentLikes AgentLike[] @@map("users") } @@ -408,6 +414,7 @@ model Collection { name String @db.VarChar(100) description String? @db.VarChar(500) isPublic Boolean @default(false) @map("is_public") + likeCount Int @default(0) @map("like_count") // Timestamps createdAt DateTime @default(now()) @map("created_at") @@ -416,11 +423,13 @@ model Collection { // Relations tools CollectionTool[] agents AgentCollection[] + likes CollectionLike[] // Unique constraint: user can't have duplicate collection names @@unique([userId, name]) @@index([userId]) @@index([isPublic]) + @@index([likeCount]) @@index([createdAt]) @@map("collections") } @@ -495,6 +504,7 @@ model Agent { // Visibility isPublic Boolean @default(false) @map("is_public") + likeCount Int @default(0) @map("like_count") // Timestamps createdAt DateTime @default(now()) @map("created_at") @@ -504,11 +514,13 @@ model Agent { collections AgentCollection[] tools AgentTool[] conversations Conversation[] + likes AgentLike[] @@unique([userId, name]) @@index([userId]) @@index([uid]) @@index([isPublic]) + @@index([likeCount]) @@index([createdAt]) @@map("agents") } @@ -640,3 +652,64 @@ model Message { @@index([createdAt]) @@map("messages") } + +// ============================================================================ +// Like Models +// ============================================================================ + +/// ToolLike - tracks users who liked a tool +model ToolLike { + id String @id @default(cuid()) + + // Relationships + userId String @map("user_id") + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + toolId String @map("tool_id") + tool Tool @relation(fields: [toolId], references: [id], onDelete: Cascade) + + // Timestamps + createdAt DateTime @default(now()) @map("created_at") + + @@unique([userId, toolId]) + @@index([toolId]) + @@index([userId]) + @@map("tool_likes") +} + +/// CollectionLike - tracks users who liked a collection +model CollectionLike { + id String @id @default(cuid()) + + // Relationships + userId String @map("user_id") + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + collectionId String @map("collection_id") + collection Collection @relation(fields: [collectionId], references: [id], onDelete: Cascade) + + // Timestamps + createdAt DateTime @default(now()) @map("created_at") + + @@unique([userId, collectionId]) + @@index([collectionId]) + @@index([userId]) + @@map("collection_likes") +} + +/// AgentLike - tracks users who liked an agent +model AgentLike { + id String @id @default(cuid()) + + // Relationships + userId String @map("user_id") + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + agentId String @map("agent_id") + agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade) + + // Timestamps + createdAt DateTime @default(now()) @map("created_at") + + @@unique([userId, agentId]) + @@index([agentId]) + @@index([userId]) + @@map("agent_likes") +} diff --git a/packages/ui/src/Icon/icons.ts b/packages/ui/src/Icon/icons.ts index 9ca81e6..91cca25 100644 --- a/packages/ui/src/Icon/icons.ts +++ b/packages/ui/src/Icon/icons.ts @@ -132,6 +132,14 @@ export const icons = { viewBox: '0 0 24 24', path: 'M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z', }, + heart: { + viewBox: '0 0 24 24', + path: 'M16.5 3c-1.74 0-3.41.81-4.5 2.09C10.91 3.81 9.24 3 7.5 3 4.42 3 2 5.42 2 8.5c0 3.78 3.4 6.86 8.55 11.54L12 21.35l1.45-1.32C18.6 15.36 22 12.28 22 8.5 22 5.42 19.58 3 16.5 3zm-4.4 15.55l-.1.1-.1-.1C7.14 14.24 4 11.39 4 8.5 4 6.5 5.5 5 7.5 5c1.54 0 3.04.99 3.57 2.36h1.87C13.46 5.99 14.96 5 16.5 5c2 0 3.5 1.5 3.5 3.5 0 2.89-3.14 5.74-7.9 10.05z', + }, + heartFilled: { + viewBox: '0 0 24 24', + path: 'M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z', + }, } as const; export type IconName = keyof typeof icons;