diff --git a/apps/web/src/app/api/tools/[id]/rate/route.ts b/apps/web/src/app/api/tools/[id]/rate/route.ts new file mode 100644 index 0000000..52eb63a --- /dev/null +++ b/apps/web/src/app/api/tools/[id]/rate/route.ts @@ -0,0 +1,341 @@ +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]/rate + * Get the current user's rating for this tool and aggregate stats + */ +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(), + }); + + // Get tool rating stats (always available) + const tool = await prisma.tool.findUnique({ + where: { id }, + select: { + averageRating: true, + ratingCount: true, + }, + }); + + 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 } + ); + } + + // Get user's rating if logged in + let userRating: number | null = null; + if (session) { + const rating = await prisma.toolRating.findUnique({ + where: { + userId_toolId: { + userId: session.user.id, + toolId: id, + }, + }, + }); + userRating = rating?.rating ?? null; + } + + return NextResponse.json({ + success: true, + data: { + userRating, + averageRating: tool.averageRating ? Number(tool.averageRating) : null, + ratingCount: tool.ratingCount, + }, + meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId }, + }); + } catch (error) { + console.error('[API Error] GET /api/tools/[id]/rate:', error); + return NextResponse.json( + { + success: false, + error: { code: 'INTERNAL_ERROR', message: 'Failed to get rating' }, + meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId }, + }, + { status: 500 } + ); + } +} + +/** + * POST /api/tools/[id]/rate + * Rate a tool (1-5 stars) + */ +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 } + ); + } + + // Parse request body + const body = await request.json(); + const rating = body.rating; + + // Validate rating + if (typeof rating !== 'number' || rating < 1 || rating > 5 || !Number.isInteger(rating)) { + return NextResponse.json( + { + success: false, + error: { code: 'INVALID_RATING', message: 'Rating must be an integer between 1 and 5' }, + meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId }, + }, + { status: 400 } + ); + } + + // 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 } + ); + } + + // Upsert rating and recalculate aggregates + await prisma.$transaction(async (tx) => { + // Upsert user's rating + await tx.toolRating.upsert({ + where: { + userId_toolId: { + userId: session.user.id, + toolId: id, + }, + }, + create: { + userId: session.user.id, + toolId: id, + rating, + }, + update: { + rating, + }, + }); + + // Recalculate average rating + const aggregates = await tx.toolRating.aggregate({ + where: { toolId: id }, + _avg: { rating: true }, + _count: { rating: true }, + }); + + // Update tool with new aggregates + await tx.tool.update({ + where: { id }, + data: { + averageRating: aggregates._avg.rating, + ratingCount: aggregates._count.rating, + }, + }); + }); + + // Get updated stats + const updatedTool = await prisma.tool.findUnique({ + where: { id }, + select: { + averageRating: true, + ratingCount: true, + }, + }); + + return NextResponse.json({ + success: true, + data: { + userRating: rating, + averageRating: updatedTool?.averageRating ? Number(updatedTool.averageRating) : null, + ratingCount: updatedTool?.ratingCount ?? 0, + }, + meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId }, + }); + } catch (error) { + console.error('[API Error] POST /api/tools/[id]/rate:', error); + return NextResponse.json( + { + success: false, + error: { code: 'INTERNAL_ERROR', message: 'Failed to rate tool' }, + meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId }, + }, + { status: 500 } + ); + } +} + +/** + * DELETE /api/tools/[id]/rate + * Remove user's rating for 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 rating exists + const existingRating = await prisma.toolRating.findUnique({ + where: { + userId_toolId: { + userId: session.user.id, + toolId: id, + }, + }, + }); + + if (!existingRating) { + const tool = await prisma.tool.findUnique({ + where: { id }, + select: { averageRating: true, ratingCount: true }, + }); + + return NextResponse.json({ + success: true, + data: { + userRating: null, + averageRating: tool?.averageRating ? Number(tool.averageRating) : null, + ratingCount: tool?.ratingCount ?? 0, + }, + meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId }, + }); + } + + // Delete rating and recalculate aggregates + await prisma.$transaction(async (tx) => { + await tx.toolRating.delete({ + where: { + userId_toolId: { + userId: session.user.id, + toolId: id, + }, + }, + }); + + // Recalculate average rating + const aggregates = await tx.toolRating.aggregate({ + where: { toolId: id }, + _avg: { rating: true }, + _count: { rating: true }, + }); + + // Update tool with new aggregates + await tx.tool.update({ + where: { id }, + data: { + averageRating: aggregates._avg.rating, + ratingCount: aggregates._count.rating, + }, + }); + }); + + // Get updated stats + const updatedTool = await prisma.tool.findUnique({ + where: { id }, + select: { + averageRating: true, + ratingCount: true, + }, + }); + + return NextResponse.json({ + success: true, + data: { + userRating: null, + averageRating: updatedTool?.averageRating ? Number(updatedTool.averageRating) : null, + ratingCount: updatedTool?.ratingCount ?? 0, + }, + meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId }, + }); + } catch (error) { + console.error('[API Error] DELETE /api/tools/[id]/rate:', error); + return NextResponse.json( + { + success: false, + error: { code: 'INTERNAL_ERROR', message: 'Failed to remove rating' }, + meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId }, + }, + { status: 500 } + ); + } +} diff --git a/apps/web/src/app/api/tools/[id]/reviews/route.ts b/apps/web/src/app/api/tools/[id]/reviews/route.ts new file mode 100644 index 0000000..7fdea32 --- /dev/null +++ b/apps/web/src/app/api/tools/[id]/reviews/route.ts @@ -0,0 +1,469 @@ +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; + }; + pagination?: { + limit: number; + offset: number; + hasMore: boolean; + }; +} + +interface RouteContext { + params: Promise<{ id: string }>; +} + +/** + * GET /api/tools/[id]/reviews + * Get reviews for a tool + */ +export async function GET( + request: NextRequest, + context: RouteContext +): Promise> { + const requestId = crypto.randomUUID(); + const { id } = await context.params; + + try { + const { searchParams } = new URL(request.url); + const limit = Math.min(50, Math.max(1, Number(searchParams.get('limit')) || 20)); + const offset = Math.max(0, Number(searchParams.get('offset')) || 0); + const sort = searchParams.get('sort') || 'recent'; // 'recent', 'helpful', 'highest', 'lowest' + + // Check tool exists + const tool = await prisma.tool.findUnique({ + where: { id }, + select: { id: true, reviewCount: true }, + }); + + 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 } + ); + } + + // Build order by clause + let orderBy: Record[]; + switch (sort) { + case 'helpful': + orderBy = [{ helpfulCount: 'desc' }, { createdAt: 'desc' }]; + break; + case 'highest': + orderBy = [{ rating: 'desc' }, { createdAt: 'desc' }]; + break; + case 'lowest': + orderBy = [{ rating: 'asc' }, { createdAt: 'desc' }]; + break; + case 'recent': + default: + orderBy = [{ createdAt: 'desc' }]; + } + + // Fetch reviews with user info + const reviews = await prisma.toolReview.findMany({ + where: { + toolId: id, + isApproved: true, + isHidden: false, + }, + include: { + user: { + select: { + id: true, + name: true, + image: true, + username: true, + }, + }, + }, + orderBy, + take: limit + 1, + skip: offset, + }); + + const hasMore = reviews.length > limit; + const actualReviews = hasMore ? reviews.slice(0, limit) : reviews; + + return NextResponse.json({ + success: true, + data: actualReviews.map((review) => ({ + id: review.id, + title: review.title, + content: review.content, + rating: review.rating, + helpfulCount: review.helpfulCount, + createdAt: review.createdAt.toISOString(), + updatedAt: review.updatedAt.toISOString(), + user: { + id: review.user.id, + name: review.user.name, + image: review.user.image, + username: review.user.username, + }, + })), + meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId }, + pagination: { + limit, + offset, + hasMore, + }, + }); + } catch (error) { + console.error('[API Error] GET /api/tools/[id]/reviews:', error); + return NextResponse.json( + { + success: false, + error: { code: 'INTERNAL_ERROR', message: 'Failed to get reviews' }, + meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId }, + }, + { status: 500 } + ); + } +} + +/** + * POST /api/tools/[id]/reviews + * Create or update a review for 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 } + ); + } + + // Parse request body + const body = await request.json(); + const { title, content, rating } = body; + + // Validate content + if (typeof content !== 'string' || content.trim().length < 10) { + return NextResponse.json( + { + success: false, + error: { + code: 'INVALID_CONTENT', + message: 'Review content must be at least 10 characters', + }, + meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId }, + }, + { status: 400 } + ); + } + + if (content.length > 5000) { + return NextResponse.json( + { + success: false, + error: { + code: 'CONTENT_TOO_LONG', + message: 'Review content must be less than 5000 characters', + }, + meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId }, + }, + { status: 400 } + ); + } + + // Validate title if provided + if (title !== undefined && title !== null) { + if (typeof title !== 'string') { + return NextResponse.json( + { + success: false, + error: { code: 'INVALID_TITLE', message: 'Title must be a string' }, + meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId }, + }, + { status: 400 } + ); + } + if (title.length > 200) { + return NextResponse.json( + { + success: false, + error: { code: 'TITLE_TOO_LONG', message: 'Title must be less than 200 characters' }, + meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId }, + }, + { status: 400 } + ); + } + } + + // Validate rating + if (typeof rating !== 'number' || rating < 1 || rating > 5 || !Number.isInteger(rating)) { + return NextResponse.json( + { + success: false, + error: { code: 'INVALID_RATING', message: 'Rating must be an integer between 1 and 5' }, + meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId }, + }, + { status: 400 } + ); + } + + // 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 user already has a review + const existingReview = await prisma.toolReview.findUnique({ + where: { + userId_toolId: { + userId: session.user.id, + toolId: id, + }, + }, + }); + + let review: { + id: string; + title: string | null; + content: string; + rating: number; + helpfulCount: number; + createdAt: Date; + updatedAt: Date; + user: { id: string; name: string | null; image: string | null; username: string | null }; + } | null = null; + await prisma.$transaction(async (tx) => { + if (existingReview) { + // Update existing review + review = await tx.toolReview.update({ + where: { id: existingReview.id }, + data: { + title: title?.trim() || null, + content: content.trim(), + rating, + }, + include: { + user: { + select: { + id: true, + name: true, + image: true, + username: true, + }, + }, + }, + }); + } else { + // Create new review + review = await tx.toolReview.create({ + data: { + userId: session.user.id, + toolId: id, + title: title?.trim() || null, + content: content.trim(), + rating, + }, + include: { + user: { + select: { + id: true, + name: true, + image: true, + username: true, + }, + }, + }, + }); + + // Increment review count + await tx.tool.update({ + where: { id }, + data: { reviewCount: { increment: 1 } }, + }); + } + + // Also update rating aggregate (upsert the rating) + await tx.toolRating.upsert({ + where: { + userId_toolId: { + userId: session.user.id, + toolId: id, + }, + }, + create: { + userId: session.user.id, + toolId: id, + rating, + }, + update: { + rating, + }, + }); + + // Recalculate average rating + const aggregates = await tx.toolRating.aggregate({ + where: { toolId: id }, + _avg: { rating: true }, + _count: { rating: true }, + }); + + await tx.tool.update({ + where: { id }, + data: { + averageRating: aggregates._avg.rating, + ratingCount: aggregates._count.rating, + }, + }); + }); + + return NextResponse.json({ + success: true, + data: { + id: review!.id, + title: review!.title, + content: review!.content, + rating: review!.rating, + helpfulCount: review!.helpfulCount, + createdAt: review!.createdAt.toISOString(), + updatedAt: review!.updatedAt.toISOString(), + user: { + id: review!.user.id, + name: review!.user.name, + image: review!.user.image, + username: review!.user.username, + }, + }, + meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId }, + }); + } catch (error) { + console.error('[API Error] POST /api/tools/[id]/reviews:', error); + return NextResponse.json( + { + success: false, + error: { code: 'INTERNAL_ERROR', message: 'Failed to create review' }, + meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId }, + }, + { status: 500 } + ); + } +} + +/** + * DELETE /api/tools/[id]/reviews + * Delete user's review for 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 review exists + const existingReview = await prisma.toolReview.findUnique({ + where: { + userId_toolId: { + userId: session.user.id, + toolId: id, + }, + }, + }); + + if (!existingReview) { + return NextResponse.json({ + success: true, + data: { deleted: false, message: 'No review to delete' }, + meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId }, + }); + } + + // Delete review and update count + await prisma.$transaction([ + prisma.toolReview.delete({ + where: { id: existingReview.id }, + }), + prisma.tool.update({ + where: { id }, + data: { reviewCount: { decrement: 1 } }, + }), + ]); + + return NextResponse.json({ + success: true, + data: { deleted: true }, + meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId }, + }); + } catch (error) { + console.error('[API Error] DELETE /api/tools/[id]/reviews:', error); + return NextResponse.json( + { + success: false, + error: { code: 'INTERNAL_ERROR', message: 'Failed to delete review' }, + meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId }, + }, + { status: 500 } + ); + } +} diff --git a/apps/web/src/app/api/tools/trending/route.ts b/apps/web/src/app/api/tools/trending/route.ts new file mode 100644 index 0000000..60611bb --- /dev/null +++ b/apps/web/src/app/api/tools/trending/route.ts @@ -0,0 +1,181 @@ +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; + }; + pagination?: { + limit: number; + offset: number; + hasMore: boolean; + }; +} + +/** + * GET /api/tools/trending + * Get trending tools based on a composite score of downloads, ratings, and likes + * + * Trending score = (normalized_downloads * 0.4) + (average_rating * 0.3) + (like_count * 0.2) + (recency * 0.1) + */ +export async function GET(request: NextRequest): Promise> { + const requestId = crypto.randomUUID(); + + try { + const { searchParams } = new URL(request.url); + const limit = Math.min(50, Math.max(1, Number(searchParams.get('limit')) || 20)); + const offset = Math.max(0, Number(searchParams.get('offset')) || 0); + const category = searchParams.get('category'); + const period = searchParams.get('period') || 'week'; // 'day', 'week', 'month', 'all' + + // Build where clause + const where: Record = { + importHealth: 'HEALTHY', + }; + + if (category) { + where.package = { category }; + } + + // Get date cutoff for recency boost + const now = new Date(); + let recencyPeriodMs: number; + switch (period) { + case 'day': + recencyPeriodMs = 24 * 60 * 60 * 1000; + break; + case 'week': + recencyPeriodMs = 7 * 24 * 60 * 60 * 1000; + break; + case 'month': + recencyPeriodMs = 30 * 24 * 60 * 60 * 1000; + break; + case 'all': + default: + recencyPeriodMs = 365 * 24 * 60 * 60 * 1000; // 1 year + } + + // Fetch tools with package data + // For trending, we order by a combination of factors + const tools = await prisma.tool.findMany({ + where, + include: { + package: { + select: { + id: true, + npmPackageName: true, + npmVersion: true, + npmDescription: true, + category: true, + isOfficial: true, + npmDownloadsLastMonth: true, + githubStars: true, + tier: true, + }, + }, + }, + orderBy: [ + // Primary: quality score (includes downloads) + { qualityScore: 'desc' }, + // Secondary: average rating + { averageRating: 'desc' }, + // Tertiary: like count + { likeCount: 'desc' }, + // Finally: recency + { createdAt: 'desc' }, + ], + take: limit + 1, + skip: offset, + }); + + const hasMore = tools.length > limit; + const actualTools = hasMore ? tools.slice(0, limit) : tools; + + // Calculate trending scores for display + const maxDownloads = Math.max( + ...actualTools.map((t) => t.package.npmDownloadsLastMonth || 0), + 1 + ); + const maxLikes = Math.max(...actualTools.map((t) => t.likeCount), 1); + + const toolsWithScores = actualTools.map((tool) => { + const downloads = tool.package.npmDownloadsLastMonth || 0; + const rating = tool.averageRating ? Number(tool.averageRating) : 0; + const likes = tool.likeCount; + const ageMs = now.getTime() - tool.createdAt.getTime(); + const recencyScore = Math.max(0, 1 - ageMs / recencyPeriodMs); // 0-1, higher for newer + + // Trending score calculation + const trendingScore = + (downloads / maxDownloads) * 0.4 + + (rating / 5) * 0.3 + + (likes / maxLikes) * 0.2 + + recencyScore * 0.1; + + return { + id: tool.id, + name: tool.name, + description: tool.description, + qualityScore: tool.qualityScore ? Number(tool.qualityScore) : null, + averageRating: tool.averageRating ? Number(tool.averageRating) : null, + ratingCount: tool.ratingCount, + reviewCount: tool.reviewCount, + likeCount: tool.likeCount, + importHealth: tool.importHealth, + executionHealth: tool.executionHealth, + trendingScore: Math.round(trendingScore * 100) / 100, + createdAt: tool.createdAt.toISOString(), + package: { + id: tool.package.id, + npmPackageName: tool.package.npmPackageName, + npmVersion: tool.package.npmVersion, + npmDescription: tool.package.npmDescription, + category: tool.package.category, + isOfficial: tool.package.isOfficial, + npmDownloadsLastMonth: tool.package.npmDownloadsLastMonth, + githubStars: tool.package.githubStars, + tier: tool.package.tier, + }, + }; + }); + + // Sort by trending score + toolsWithScores.sort((a, b) => b.trendingScore - a.trendingScore); + + return NextResponse.json({ + success: true, + data: toolsWithScores, + meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId }, + pagination: { + limit, + offset, + hasMore, + }, + }); + } catch (error) { + console.error('[API Error] GET /api/tools/trending:', error); + return NextResponse.json( + { + success: false, + error: { code: 'INTERNAL_ERROR', message: 'Failed to get trending tools' }, + meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId }, + }, + { status: 500 } + ); + } +} diff --git a/apps/web/src/app/docs/api/agents/page.tsx b/apps/web/src/app/docs/api/agents/page.tsx new file mode 100644 index 0000000..7efe8a6 --- /dev/null +++ b/apps/web/src/app/docs/api/agents/page.tsx @@ -0,0 +1,204 @@ +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@tpmjs/ui/Card/Card'; +import { CodeBlock } from '@tpmjs/ui/CodeBlock/CodeBlock'; +import type { Metadata } from 'next'; + +export const metadata: Metadata = { + title: 'Agents API | TPMJS Docs', + description: 'API reference for TPMJS agents endpoints', +}; + +export default function AgentsApiPage(): React.ReactElement { + return ( +
+
+

Agents API

+

+ The Agents API allows you to manage and interact with AI agents that can use TPMJS tools. +

+
+ + {/* List User Agents */} + + + List Agents + GET /api/agents + + +

+ Retrieve a list of your agents. Requires authentication. +

+ +

Example Request

+ + +

Example Response

+ +
+
+ + {/* Create Agent */} + + + Create Agent + POST /api/agents + + +

+ Create a new AI agent with specified configuration. +

+ +

Request Body

+ + +

TypeScript Example

+ +
+
+ + {/* Chat with Agent */} + + + Chat with Agent + POST /api/agents/:uid/chat + + +

+ Send a message to an agent and receive a streaming response. The agent can use any tools + attached to it. +

+ +

Request Body

+ + +

TypeScript Example

+ +
+
+ + {/* Add Tool to Agent */} + + + Add Tool to Agent + POST /api/agents/:uid/tools + + +

+ Add a tool to an agent's available tools. +

+ +

Request Body

+ +
+
+ + {/* Add Collection to Agent */} + + + Add Collection to Agent + POST /api/agents/:uid/collections + + +

Add all tools from a collection to an agent.

+ +

Request Body

+ +
+
+
+ ); +} diff --git a/apps/web/src/app/docs/api/authentication/page.tsx b/apps/web/src/app/docs/api/authentication/page.tsx new file mode 100644 index 0000000..69a8239 --- /dev/null +++ b/apps/web/src/app/docs/api/authentication/page.tsx @@ -0,0 +1,316 @@ +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@tpmjs/ui/Card/Card'; +import { CodeBlock } from '@tpmjs/ui/CodeBlock/CodeBlock'; +import type { Metadata } from 'next'; + +export const metadata: Metadata = { + title: 'Authentication | TPMJS Docs', + description: 'Authentication guide for the TPMJS API', +}; + +export default function AuthenticationPage(): React.ReactElement { + return ( +
+
+

Authentication

+

+ Learn how to authenticate with the TPMJS API to access protected endpoints. +

+
+ + {/* Overview */} + + + Overview + Understanding TPMJS authentication + + +

+ TPMJS uses API keys for authentication. Some endpoints are public and don't require + authentication, while others require a valid API key to access. +

+ +

Public Endpoints (No Auth Required)

+
    +
  • + GET /api/tools - List and search tools +
  • +
  • + GET /api/tools/:id - Get tool details +
  • +
  • + GET /api/tools/trending - Get trending tools +
  • +
  • + GET /api/collections - List public collections +
  • +
  • + GET /api/collections/:uid - Get collection details +
  • +
+ +

Protected Endpoints (Auth Required)

+
    +
  • + POST /api/tools/:id/rate - Rate a tool +
  • +
  • + POST /api/tools/:id/reviews - Write a review +
  • +
  • + POST /api/collections - Create a collection +
  • +
  • + POST /api/agents - Create an agent +
  • +
  • + GET /api/agents - List your agents +
  • +
+
+
+ + {/* Getting an API Key */} + + + Getting an API Key + How to obtain your API key + + +

+ To get an API key, you need to create a TPMJS account and generate a key from your + dashboard. +

+ +
    +
  1. Sign up or log in at tpmjs.com
  2. +
  3. Navigate to Settings → API Keys
  4. +
  5. Click "Generate New Key"
  6. +
  7. Copy your key and store it securely
  8. +
+ +
+

Important

+

+ Your API key is displayed only once when created. Make sure to copy and store it + securely. If you lose it, you'll need to generate a new one. +

+
+
+
+ + {/* Using Your API Key */} + + + Using Your API Key + How to authenticate requests + + +

+ Include your API key in the Authorization header using the Bearer scheme. +

+ +

Header Format

+ + +

cURL Example

+ + +

JavaScript/TypeScript Example

+ + +

Python Example

+ +
+
+ + {/* Error Responses */} + + + Authentication Errors + Common authentication error responses + + +

401 Unauthorized

+

+ Returned when no API key is provided or the key is invalid. +

+ + +

403 Forbidden

+

+ Returned when the API key is valid but lacks permission for the requested action. +

+ + +

429 Rate Limited

+

+ Returned when you've exceeded the rate limit for your API key. +

+ +
+
+ + {/* Rate Limits */} + + + Rate Limits + API request limits + + +

+ TPMJS enforces rate limits to ensure fair usage and protect the API from abuse. +

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
TierRequests/minRequests/day
Free601,000
Pro30010,000
EnterpriseCustomCustom
+
+ +

Rate Limit Headers

+

+ Every response includes rate limit information in headers: +

+ +
+
+ + {/* Best Practices */} + + + Security Best Practices + Keep your API key secure + + +
    +
  • + Never commit API keys to version control. Use environment variables + instead. +
  • +
  • + Rotate keys regularly. Generate new keys periodically and revoke old + ones. +
  • +
  • + Use separate keys for development and production. This limits the + impact if a key is compromised. +
  • +
  • + Monitor usage. Check your API usage in the dashboard to detect + unusual activity. +
  • +
  • + Revoke compromised keys immediately. If you suspect a key has been + exposed, revoke it and generate a new one. +
  • +
+ +

Environment Variables Example

+ +
+
+
+ ); +} diff --git a/apps/web/src/app/docs/api/collections/page.tsx b/apps/web/src/app/docs/api/collections/page.tsx new file mode 100644 index 0000000..e6913c6 --- /dev/null +++ b/apps/web/src/app/docs/api/collections/page.tsx @@ -0,0 +1,359 @@ +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@tpmjs/ui/Card/Card'; +import { CodeBlock } from '@tpmjs/ui/CodeBlock/CodeBlock'; +import type { Metadata } from 'next'; + +export const metadata: Metadata = { + title: 'Collections API | TPMJS Docs', + description: 'API reference for TPMJS collections endpoints', +}; + +export default function CollectionsApiPage(): React.ReactElement { + return ( +
+
+

Collections API

+

+ The Collections API allows you to create and manage curated sets of tools for specific use + cases. +

+
+ + {/* List Collections */} + + + List Collections + GET /api/collections + + +

+ Retrieve a paginated list of public collections with optional filtering. +

+ +

Query Parameters

+
    +
  • + limit - Number of results (default: 20, max: 50) +
  • +
  • + offset - Pagination offset +
  • +
  • + q - Search query +
  • +
+ +

Example Request

+ + +

Example Response

+ +
+
+ + {/* Get Collection */} + + + Get Collection + GET /api/collections/:uid + + +

+ Retrieve detailed information about a specific collection, including its tools. +

+ +

Path Parameters

+
    +
  • + uid - Collection unique identifier or slug +
  • +
+ +

Example Request

+ + +

Example Response

+ +
+
+ + {/* Create Collection */} + + + Create Collection + POST /api/collections + + +

+ Create a new collection. Requires authentication. +

+ +

Request Body

+ + +

TypeScript Example

+ +
+
+ + {/* Update Collection */} + + + Update Collection + PATCH /api/collections/:uid + + +

+ Update an existing collection. Requires authentication and ownership. +

+ +

Request Body

+ + +

Example Request

+ +
+
+ + {/* Add Tool to Collection */} + + + Add Tool to Collection + POST /api/collections/:uid/tools + + +

+ Add a tool to a collection. Requires authentication and ownership. +

+ +

Request Body

+ + +

TypeScript Example

+ +
+
+ + {/* Remove Tool from Collection */} + + + Remove Tool from Collection + DELETE /api/collections/:uid/tools/:toolId + + +

+ Remove a tool from a collection. Requires authentication and ownership. +

+ +

Example Request

+ + +

Example Response

+ +
+
+ + {/* Like Collection */} + + + Like Collection + POST /api/collections/:uid/like + + +

+ Like or unlike a collection. Requires authentication. +

+ +

TypeScript Example

+ +
+
+ + {/* Delete Collection */} + + + Delete Collection + DELETE /api/collections/:uid + + +

+ Delete a collection. Requires authentication and ownership. This action cannot be + undone. +

+ +

Example Request

+ + +

Example Response

+ +
+
+
+ ); +} diff --git a/apps/web/src/app/docs/api/tools/page.tsx b/apps/web/src/app/docs/api/tools/page.tsx new file mode 100644 index 0000000..d1378e3 --- /dev/null +++ b/apps/web/src/app/docs/api/tools/page.tsx @@ -0,0 +1,220 @@ +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@tpmjs/ui/Card/Card'; +import { CodeBlock } from '@tpmjs/ui/CodeBlock/CodeBlock'; +import type { Metadata } from 'next'; + +export const metadata: Metadata = { + title: 'Tools API | TPMJS Docs', + description: 'API reference for TPMJS tools endpoints', +}; + +export default function ToolsApiPage(): React.ReactElement { + return ( +
+
+

Tools API

+

+ The Tools API allows you to search, discover, and execute TPMJS tools programmatically. +

+
+ + {/* List Tools */} + + + List Tools + GET /api/tools + + +

+ Retrieve a paginated list of tools with optional filtering. +

+ +

Query Parameters

+
    +
  • + limit - Number of results (default: 20, max: 50) +
  • +
  • + offset - Pagination offset +
  • +
  • + category - Filter by category +
  • +
  • + q - Search query +
  • +
+ +

Example Request

+ + +

Example Response

+ +
+
+ + {/* Get Tool by ID */} + + + Get Tool + GET /api/tools/:id + + +

+ Retrieve detailed information about a specific tool. +

+ +

Path Parameters

+
    +
  • + id - Tool ID or package/tool slug +
  • +
+ +

Example Request

+ +
+
+ + {/* Execute Tool */} + + + Execute Tool + POST /api/tools/execute/:slug + + +

+ Execute a tool with an AI agent. Returns streaming SSE response. +

+ +

Request Body

+ + +

TypeScript Example

+ console.log(text), +}); + +console.log(result.output);`} + language="typescript" + showCopy={true} + /> +
+
+ + {/* Trending Tools */} + + + Trending Tools + GET /api/tools/trending + + +

+ Get trending tools based on downloads, ratings, and activity. +

+ +

Query Parameters

+
    +
  • + period - Time period: day, week, month, all (default: week) +
  • +
  • + category - Filter by category +
  • +
  • + limit - Number of results (default: 20) +
  • +
+ +

Example Request

+ +
+
+ + {/* Rate Tool */} + + + Rate Tool + POST /api/tools/:id/rate + + +

+ Rate a tool from 1-5 stars. Requires authentication. +

+ +

Request Body

+ + +

Example Response

+ +
+
+
+ ); +} diff --git a/apps/web/src/app/docs/quickstart/page.tsx b/apps/web/src/app/docs/quickstart/page.tsx new file mode 100644 index 0000000..a839222 --- /dev/null +++ b/apps/web/src/app/docs/quickstart/page.tsx @@ -0,0 +1,417 @@ +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@tpmjs/ui/Card/Card'; +import { CodeBlock } from '@tpmjs/ui/CodeBlock/CodeBlock'; +import type { Metadata } from 'next'; +import Link from 'next/link'; + +export const metadata: Metadata = { + title: 'Quickstart Guide | TPMJS Docs', + description: 'Get started with TPMJS in under 5 minutes', +}; + +export default function QuickstartPage(): React.ReactElement { + return ( +
+
+

Quickstart Guide

+

+ Get your AI agent access to thousands of tools in under 5 minutes. +

+
+ + {/* Prerequisites */} + + + Prerequisites + What you need before starting + + +
    +
  • Node.js 18 or later
  • +
  • npm, pnpm, or yarn
  • +
  • + An AI provider API key (OpenAI, Anthropic, or{' '} + + any AI SDK provider + + ) +
  • +
+
+
+ + {/* Step 1: Installation */} + + + Step 1: Install the SDK + Add TPMJS packages to your project + + +

+ Install the TPMJS SDK packages alongside the Vercel AI SDK: +

+ +

npm

+ + +

pnpm

+ + +

yarn

+ + +

+ Also install your preferred AI provider SDK: +

+ +
+
+ + {/* Step 2: Basic Usage */} + + + Step 2: Add Tools to Your Agent + Give your agent access to the TPMJS registry + + +

+ Import the TPMJS tools and add them to your AI agent: +

+ + +
+
+ + {/* Step 3: Understanding the Tools */} + + + Step 3: Understanding the Tools + How registrySearch and registryExecute work + + +
+

registrySearchTool

+

+ Searches the TPMJS registry to find tools for any task. Returns metadata including the + toolId needed for execution. +

+ +
+ +
+

registryExecuteTool

+

+ Executes any tool from the registry by its toolId. Tools run in a secure sandbox—no + local installation required. +

+ +
+
+
+ + {/* Step 4: Passing API Keys */} + + + Step 4: Configure API Keys + Pre-configure API keys for tools that need them + + +

+ Many tools require API keys (e.g., Firecrawl, Exa, Tavily). Create a wrapper to + auto-inject your keys: +

+ +

Create tools.ts

+ = { + FIRECRAWL_API_KEY: process.env.FIRECRAWL_API_KEY!, + EXA_API_KEY: process.env.EXA_API_KEY!, + TAVILY_API_KEY: process.env.TAVILY_API_KEY!, +}; + +// Create a wrapped version that auto-injects keys +export const registryExecute = tool({ + description: registryExecuteTool.description, + parameters: registryExecuteTool.parameters, + execute: async ({ toolId, params }) => { + return registryExecuteTool.execute({ toolId, params, env: API_KEYS }); + }, +});`} + language="typescript" + showCopy={true} + /> + +

Use the wrapped tool

+ +
+
+ + {/* Step 5: Real-World Example */} + + + Step 5: Complete Example + A full working example you can copy + + +

agent.ts

+ = { + FIRECRAWL_API_KEY: process.env.FIRECRAWL_API_KEY || '', + EXA_API_KEY: process.env.EXA_API_KEY || '', +}; + +// Create the wrapped execute tool +const registryExecute = { + ...registryExecuteTool, + execute: async (args: Parameters[0]) => { + return registryExecuteTool.execute({ + ...args, + env: { ...API_KEYS, ...args.env }, + }); + }, +}; + +// Main agent function +async function runAgent(prompt: string) { + const result = await generateText({ + model: anthropic('claude-sonnet-4-20250514'), + tools: { + registrySearch: registrySearchTool, + registryExecute, + }, + maxSteps: 5, // Allow multiple tool calls + system: \`You are a helpful assistant with access to thousands of tools. + +Available tool workflow: +1. Use registrySearch to find tools for your task +2. Use registryExecute to run the tools you find +3. Synthesize the results into a helpful response + +Always search for tools first before attempting to execute them.\`, + prompt, + }); + + return result.text; +} + +// Example usage +const response = await runAgent( + 'Search for web scraping tools, then scrape https://example.com and summarize it' +); +console.log(response);`} + language="typescript" + showCopy={true} + /> +
+
+ + {/* Next Steps */} + + + Next Steps + Continue learning about TPMJS + + +
    +
  • + + SDK Documentation → + +

    + Detailed reference for all SDK functions and options +

    +
  • +
  • + + Tools API Reference → + +

    + REST API endpoints for searching and executing tools +

    +
  • +
  • + + Agents API Reference → + +

    + Create and manage AI agents with TPMJS tools +

    +
  • +
  • + + Publishing Guide → + +

    + Learn how to publish your own tools to TPMJS +

    +
  • +
  • + + Browse Tool Registry → + +

    + Explore available tools and find ones for your use case +

    +
  • +
+
+
+ + {/* Troubleshooting */} + + + Common Issues + Solutions to frequently encountered problems + + +
+
+

+ Tool execution fails with "missing API key" +

+

+ Check that you're passing the required environment variables in the{' '} + env parameter of registryExecute. +

+ +
+ +
+

Tool not found in search results

+

+ Try broader search terms or check the tool category. You can also browse the full + registry at{' '} + + tpmjs.com + + . +

+
+ +
+

Agent not using the tools

+

+ Make sure your system prompt clearly instructs the agent to use registrySearch + first, then registryExecute. Also ensure{' '} + maxSteps is set high enough (default is 1). +

+ +
+
+
+
+
+ ); +} diff --git a/apps/web/src/app/docs/sdk/page.tsx b/apps/web/src/app/docs/sdk/page.tsx new file mode 100644 index 0000000..6278d07 --- /dev/null +++ b/apps/web/src/app/docs/sdk/page.tsx @@ -0,0 +1,651 @@ +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@tpmjs/ui/Card/Card'; +import { CodeBlock } from '@tpmjs/ui/CodeBlock/CodeBlock'; +import type { Metadata } from 'next'; +import Link from 'next/link'; + +export const metadata: Metadata = { + title: 'SDK Reference | TPMJS Docs', + description: 'Complete reference for the TPMJS SDK packages', +}; + +export default function SdkPage(): React.ReactElement { + return ( +
+
+

SDK Reference

+

+ Complete reference for the TPMJS SDK packages: @tpmjs/registry-search and + @tpmjs/registry-execute. +

+
+ + {/* Overview */} + + + Overview + TPMJS SDK packages + + +

+ The TPMJS SDK consists of two packages that give your AI agent access to the tool + registry: +

+ +
+
+

@tpmjs/registry-search

+

+ Search the TPMJS registry to find tools for any task +

+
+
+

@tpmjs/registry-execute

+

+ Execute any tool from the registry in a secure sandbox +

+
+
+ +

Installation

+ + +

Peer Dependencies

+

+ Both packages require ai (Vercel AI SDK) and{' '} + zod as peer dependencies. +

+ +
+
+ + {/* registrySearchTool */} + + + registrySearchTool + @tpmjs/registry-search + + +

+ An AI SDK tool that searches the TPMJS registry for tools matching a query. +

+ +

Import

+ + +

Basic Usage

+ + +

Parameters

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ParameterTypeRequiredDescription
querystringYes + Search query (keywords, tool names, descriptions) +
categorystringNoFilter by category
limitnumberNo + Max results (1-20, default 5) +
+
+ +

Return Value

+ ; +}`} + language="typescript" + showCopy={true} + /> + +

Available Categories

+

+ Filter results by category to narrow down your search: +

+
+ {[ + 'web-scraping', + 'search-engines', + 'ai-models', + 'data-processing', + 'communication', + 'file-management', + 'code-execution', + 'database', + 'calendar', + 'e-commerce', + 'finance', + 'social-media', + 'weather', + 'maps', + 'translation', + 'image-processing', + 'audio-processing', + 'video-processing', + 'utilities', + 'other', + ].map((cat) => ( + + {cat} + + ))} +
+
+
+ + {/* registryExecuteTool */} + + + registryExecuteTool + @tpmjs/registry-execute + + +

+ An AI SDK tool that executes any tool from the TPMJS registry in a secure sandbox. +

+ +

Import

+ + +

Basic Usage

+ + +

Parameters

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ParameterTypeRequiredDescription
toolIdstringYes + Tool identifier in format: package::name +
paramsobjectYes + Parameters to pass to the tool +
envobjectNo + Environment variables (API keys) to pass to the tool +
+
+ +

Return Value

+ + +

Passing API Keys

+ +
+
+ + {/* Creating a Wrapped Execute Tool */} + + + Creating a Wrapped Execute Tool + Pre-configure API keys for seamless execution + + +

+ Create a wrapper around registryExecuteTool to automatically inject your API keys: +

+ + = { + FIRECRAWL_API_KEY: process.env.FIRECRAWL_API_KEY!, + EXA_API_KEY: process.env.EXA_API_KEY!, + TAVILY_API_KEY: process.env.TAVILY_API_KEY!, + OPENAI_API_KEY: process.env.OPENAI_API_KEY!, +}; + +// Wrapped tool with pre-configured keys +export const registryExecute = tool({ + description: registryExecuteTool.description, + parameters: registryExecuteTool.parameters, + execute: async ({ toolId, params }) => { + return registryExecuteTool.execute({ + toolId, + params, + env: API_KEYS, + }); + }, +});`} + language="typescript" + showCopy={true} + /> +
+
+ + {/* Direct API Usage */} + + + Direct API Usage + Use the SDK without AI agent integration + + +

+ Both tools can also be used directly without an AI agent: +

+ +

Direct Search

+ + +

Direct Execution

+ +
+
+ + {/* Environment Variables */} + + + Environment Variables + Configure SDK behavior + + +

+ The SDK supports the following environment variables: +

+ +
+ + + + + + + + + + + + + + + + + + + + +
VariableDefaultDescription
TPMJS_API_URL + https://tpmjs.com + + Base URL for the TPMJS registry API +
TPMJS_EXECUTOR_URL + https://executor.tpmjs.com + + URL for the sandbox executor service +
+
+ +

Self-Hosting

+

+ If you're running your own TPMJS registry, set these environment variables: +

+ +
+
+ + {/* TypeScript Types */} + + + TypeScript Types + Type definitions exported by the SDK + + +

@tpmjs/registry-search

+ + +

@tpmjs/registry-execute

+ ; + env?: Record; +} + +// ExecuteToolResult +interface ExecuteToolResult { + toolId: string; + executionTimeMs: number; + output: unknown; +}`} + language="typescript" + showCopy={true} + /> +
+
+ + {/* Error Handling */} + + + Error Handling + Handle errors from the SDK + + +

+ Both SDK tools throw errors that can be caught and handled: +

+ + +
+
+ + {/* Complete Example */} + + + Complete Example + Full agent implementation with both tools + + + = { + FIRECRAWL_API_KEY: process.env.FIRECRAWL_API_KEY || '', + EXA_API_KEY: process.env.EXA_API_KEY || '', +}; + +// Wrapped execute tool with pre-configured keys +const registryExecute = tool({ + description: registryExecuteTool.description, + parameters: registryExecuteTool.parameters, + execute: async ({ toolId, params }) => { + return registryExecuteTool.execute({ toolId, params, env: API_KEYS }); + }, +}); + +// System prompt that guides the agent +const systemPrompt = \`You are a helpful assistant with access to thousands of tools. + +To complete tasks, follow this workflow: +1. Use registrySearch to find relevant tools +2. Review the search results and pick the best tool +3. Use registryExecute to run the tool with appropriate parameters +4. Synthesize the results into a helpful response + +Always explain what you're doing and why.\`; + +// Main function +async function runAgent(userPrompt: string) { + const result = await streamText({ + model: anthropic('claude-sonnet-4-20250514'), + tools: { + registrySearch: registrySearchTool, + registryExecute, + }, + maxSteps: 10, + system: systemPrompt, + prompt: userPrompt, + }); + + // Stream the response + for await (const chunk of result.textStream) { + process.stdout.write(chunk); + } + + console.log('\\n---'); + console.log('Tool calls:', (await result.toolCalls).length); +} + +// Usage +await runAgent('Find a web scraping tool and scrape https://example.com');`} + language="typescript" + showCopy={true} + /> + + + + {/* Related Resources */} + + + Related Resources + + +
    +
  • + + Quickstart Guide → + +

    Get started in 5 minutes

    +
  • +
  • + + Tools API Reference → + +

    REST API for the registry

    +
  • +
  • + + Authentication → + +

    API key management

    +
  • +
  • + + GitHub Repository → + +

    Source code and issues

    +
  • +
+
+
+
+ ); +} diff --git a/apps/web/src/app/tool/[...slug]/ToolDetailClient.tsx b/apps/web/src/app/tool/[...slug]/ToolDetailClient.tsx index 29af85a..c864762 100644 --- a/apps/web/src/app/tool/[...slug]/ToolDetailClient.tsx +++ b/apps/web/src/app/tool/[...slug]/ToolDetailClient.tsx @@ -13,7 +13,9 @@ import { useState } from 'react'; import { AppHeader } from '~/components/AppHeader'; import { BundleSize } from '~/components/BundleSize'; import { DownloadSparkline } from '~/components/DownloadSparkline'; +import { LikeButton } from '~/components/LikeButton'; import { Markdown } from '~/components/Markdown'; +import { Rating } from '~/components/Rating'; import { ToolPlayground } from '~/components/ToolPlayground'; interface Package { @@ -67,6 +69,10 @@ export interface Tool { executionHealth?: 'HEALTHY' | 'BROKEN' | 'UNKNOWN'; healthCheckError?: string | null; lastHealthCheck?: string | null; + likeCount?: number; + averageRating?: string | null; + ratingCount?: number; + reviewCount?: number; package: Package; createdAt: string; updatedAt: string; @@ -224,11 +230,30 @@ export function ToolDetailClient({ tool, slug }: ToolDetailClientProps): React.R

)} - {pkg.isOfficial && ( - - Official - - )} +
+ {pkg.isOfficial && ( + + Official + + )} +
+ + +
+
{pkg.category} diff --git a/apps/web/src/app/tool/[...slug]/page.tsx b/apps/web/src/app/tool/[...slug]/page.tsx index 0937f29..cee4e3d 100644 --- a/apps/web/src/app/tool/[...slug]/page.tsx +++ b/apps/web/src/app/tool/[...slug]/page.tsx @@ -78,6 +78,10 @@ async function getTool(slug: string[]): Promise { executionHealth: tool.executionHealth ?? undefined, healthCheckError: tool.healthCheckError ?? null, lastHealthCheck: tool.lastHealthCheck?.toISOString() ?? null, + likeCount: tool.likeCount, + averageRating: tool.averageRating?.toString() ?? null, + ratingCount: tool.ratingCount, + reviewCount: tool.reviewCount, createdAt: tool.createdAt.toISOString(), updatedAt: tool.updatedAt.toISOString(), package: { diff --git a/apps/web/src/components/Rating.tsx b/apps/web/src/components/Rating.tsx new file mode 100644 index 0000000..d7ea6fc --- /dev/null +++ b/apps/web/src/components/Rating.tsx @@ -0,0 +1,275 @@ +'use client'; + +import { Icon } from '@tpmjs/ui/Icon/Icon'; +import { useCallback, useEffect, useState } from 'react'; +import { useSession } from '@/lib/auth-client'; + +interface RatingProps { + toolId: string; + initialRating?: number | null; + initialAverageRating?: number | null; + initialRatingCount?: number; + size?: 'sm' | 'md' | 'lg'; + showAverage?: boolean; + showCount?: boolean; + interactive?: boolean; + onRatingChange?: ( + rating: number | null, + averageRating: number | null, + ratingCount: number + ) => void; +} + +export function Rating({ + toolId, + initialRating = null, + initialAverageRating = null, + initialRatingCount = 0, + size = 'md', + showAverage = true, + showCount = true, + interactive = true, + onRatingChange, +}: RatingProps): React.ReactElement { + const { data: session } = useSession(); + const [userRating, setUserRating] = useState(initialRating); + const [averageRating, setAverageRating] = useState(initialAverageRating); + const [ratingCount, setRatingCount] = useState(initialRatingCount); + const [hoverRating, setHoverRating] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [hasFetched, setHasFetched] = useState(false); + const [showTooltip, setShowTooltip] = useState(false); + + // Fetch initial rating status when user is logged in + useEffect(() => { + if (hasFetched) return; + + const fetchRatingStatus = async () => { + try { + const response = await fetch(`/api/tools/${toolId}/rate`); + const data = await response.json(); + if (data.success) { + setUserRating(data.data.userRating); + setAverageRating(data.data.averageRating); + setRatingCount(data.data.ratingCount); + } + } catch (error) { + console.error('Failed to fetch rating status:', error); + } finally { + setHasFetched(true); + } + }; + + fetchRatingStatus(); + }, [toolId, hasFetched]); + + // Update from props when they change + useEffect(() => { + if (!hasFetched) { + setUserRating(initialRating); + setAverageRating(initialAverageRating); + setRatingCount(initialRatingCount); + } + }, [initialRating, initialAverageRating, initialRatingCount, hasFetched]); + + const submitRating = useCallback( + async (newRating: number | null, previousRating: number | null) => { + const response = await fetch(`/api/tools/${toolId}/rate`, { + method: newRating ? 'POST' : 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: newRating ? JSON.stringify({ rating: newRating }) : undefined, + }); + + const data = await response.json(); + + if (data.success) { + setUserRating(data.data.userRating); + setAverageRating(data.data.averageRating); + setRatingCount(data.data.ratingCount); + onRatingChange?.(data.data.userRating, data.data.averageRating, data.data.ratingCount); + } else { + setUserRating(previousRating); + } + }, + [toolId, onRatingChange] + ); + + const handleRate = useCallback( + async (rating: number) => { + if (!session) { + setShowTooltip(true); + setTimeout(() => setShowTooltip(false), 2000); + return; + } + + if (isLoading || !interactive) return; + + const newRating = userRating === rating ? null : rating; + const previousRating = userRating; + + setUserRating(newRating); + setIsLoading(true); + + try { + await submitRating(newRating, previousRating); + } catch (error) { + console.error('Failed to rate:', error); + setUserRating(previousRating); + } finally { + setIsLoading(false); + } + }, + [session, userRating, isLoading, interactive, submitRating] + ); + + const sizeClasses = { + sm: 'w-4 h-4', + md: 'w-5 h-5', + lg: 'w-6 h-6', + }; + + const starSize = sizeClasses[size]; + + // Determine which rating to display (hover > user > average) + const displayRating = hoverRating ?? userRating ?? averageRating ?? 0; + + return ( +
+
+ {[1, 2, 3, 4, 5].map((star) => { + const isFilled = star <= displayRating; + const isHalfFilled = !isFilled && star - 0.5 <= displayRating; + + return ( + + ); + })} +
+ + {(showAverage || showCount) && ( +
+ {showAverage && averageRating !== null && ( + {averageRating.toFixed(1)} + )} + {showCount && ratingCount > 0 && ( + + ({ratingCount} {ratingCount === 1 ? 'rating' : 'ratings'}) + + )} +
+ )} + + {showTooltip && ( +
+ + Sign in + {' '} + to rate +
+
+
+
+ )} +
+ ); +} + +/** + * Display-only rating (non-interactive) + */ +interface RatingDisplayProps { + averageRating: number | null; + ratingCount?: number; + size?: 'sm' | 'md' | 'lg'; + showCount?: boolean; + className?: string; +} + +export function RatingDisplay({ + averageRating, + ratingCount = 0, + size = 'sm', + showCount = true, + className = '', +}: RatingDisplayProps): React.ReactElement { + const sizeClasses = { + sm: 'w-3 h-3', + md: 'w-4 h-4', + lg: 'w-5 h-5', + }; + + const starSize = sizeClasses[size]; + const rating = averageRating ?? 0; + + return ( +
+
+ {[1, 2, 3, 4, 5].map((star) => { + const isFilled = star <= rating; + const isHalfFilled = !isFilled && star - 0.5 <= rating; + + return ( +
+ {isFilled ? ( + + ) : isHalfFilled ? ( +
+ +
+ +
+
+ ) : ( + + )} +
+ ); + })} +
+ + {averageRating !== null && ( + + {averageRating.toFixed(1)} + + )} + + {showCount && ratingCount > 0 && ( + ({ratingCount}) + )} +
+ ); +} diff --git a/apps/web/src/components/ReviewCard.tsx b/apps/web/src/components/ReviewCard.tsx new file mode 100644 index 0000000..3f032e5 --- /dev/null +++ b/apps/web/src/components/ReviewCard.tsx @@ -0,0 +1,190 @@ +'use client'; + +import { Card, CardContent } from '@tpmjs/ui/Card/Card'; +import { Icon } from '@tpmjs/ui/Icon/Icon'; +import Image from 'next/image'; +import Link from 'next/link'; +import { RatingDisplay } from './Rating'; + +interface ReviewUser { + id: string; + name: string; + image: string | null; + username: string | null; +} + +export interface Review { + id: string; + title: string | null; + content: string; + rating: number; + helpfulCount: number; + createdAt: string; + updatedAt: string; + user: ReviewUser; +} + +interface ReviewCardProps { + review: Review; + className?: string; +} + +function formatDate(dateString: string): string { + const date = new Date(dateString); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); + + if (diffDays === 0) { + return 'Today'; + } else if (diffDays === 1) { + return 'Yesterday'; + } else if (diffDays < 7) { + return `${diffDays} days ago`; + } else if (diffDays < 30) { + const weeks = Math.floor(diffDays / 7); + return `${weeks} ${weeks === 1 ? 'week' : 'weeks'} ago`; + } else if (diffDays < 365) { + const months = Math.floor(diffDays / 30); + return `${months} ${months === 1 ? 'month' : 'months'} ago`; + } else { + return date.toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + }); + } +} + +function getInitials(name: string): string { + return name + .split(' ') + .map((n) => n[0]) + .join('') + .toUpperCase() + .slice(0, 2); +} + +function UserAvatar({ user }: { user: ReviewUser }): React.ReactElement { + const initials = getInitials(user.name); + + if (user.image) { + return ( + {user.name} + ); + } + + return ( +
+ {initials} +
+ ); +} + +export function ReviewCard({ review, className = '' }: ReviewCardProps): React.ReactElement { + const userLink = review.user.username ? `/@${review.user.username}` : null; + + return ( + + + {/* Header */} +
+ {/* Avatar */} + {userLink ? ( + + + + ) : ( +
+ +
+ )} + + {/* User info and rating */} +
+
+ {userLink ? ( + + {review.user.name} + + ) : ( + {review.user.name} + )} + + {formatDate(review.createdAt)} + +
+ +
+
+ + {/* Title */} + {review.title &&

{review.title}

} + + {/* Content */} +

{review.content}

+ + {/* Helpful count */} + {review.helpfulCount > 0 && ( +
+ + + {review.helpfulCount} {review.helpfulCount === 1 ? 'person' : 'people'} found this + helpful + +
+ )} +
+
+ ); +} + +/** + * Reviews section with list of reviews and write review form + */ +interface ReviewsSectionProps { + initialReviews?: Review[]; + className?: string; +} + +export function ReviewsSection({ + initialReviews = [], + className = '', +}: ReviewsSectionProps): React.ReactElement { + // For now, just display the reviews statically + // Interactive features (write review, load more) can be added later + + if (initialReviews.length === 0) { + return ( +
+ +

No reviews yet

+

+ Be the first to share your experience with this tool +

+
+ ); + } + + return ( +
+ {initialReviews.map((review) => ( + + ))} +
+ ); +} diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 97a6857..7d6390f 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -95,16 +95,26 @@ model Tool { createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") + // Rating aggregates + averageRating Decimal? @map("average_rating") @db.Decimal(2, 1) // 1.0 to 5.0 + ratingCount Int @default(0) @map("rating_count") + reviewCount Int @default(0) @map("review_count") + // Relations simulations Simulation[] healthChecks HealthCheck[] collections CollectionTool[] agents AgentTool[] likes ToolLike[] + ratings ToolRating[] + reviews ToolReview[] @@unique([packageId, name]) @@index([qualityScore]) @@index([likeCount]) + @@index([averageRating]) + @@index([ratingCount]) + @@index([reviewCount]) @@index([importHealth]) @@index([executionHealth]) @@index([lastHealthCheck]) @@ -346,6 +356,8 @@ model User { agents Agent[] apiKeys UserApiKey[] toolLikes ToolLike[] + toolRatings ToolRating[] + toolReviews ToolReview[] collectionLikes CollectionLike[] agentLikes AgentLike[] activities UserActivity[] @@ -761,6 +773,65 @@ model AgentLike { @@map("agent_likes") } +/// ToolRating - user ratings for tools (1-5 stars) +model ToolRating { + 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) + + // Rating value (1-5) + rating Int @db.SmallInt + + // Timestamps + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@unique([userId, toolId]) + @@index([toolId]) + @@index([userId]) + @@index([rating]) + @@map("tool_ratings") +} + +/// ToolReview - user reviews for tools +model ToolReview { + 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) + + // Review content + title String? @db.VarChar(200) + content String @db.Text + rating Int @db.SmallInt // 1-5, denormalized for convenience + + // Moderation + isApproved Boolean @default(true) @map("is_approved") + isHidden Boolean @default(false) @map("is_hidden") + + // Helpful votes + helpfulCount Int @default(0) @map("helpful_count") + + // Timestamps + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@unique([userId, toolId]) + @@index([toolId]) + @@index([userId]) + @@index([rating]) + @@index([isApproved, isHidden]) + @@index([createdAt]) + @@map("tool_reviews") +} + // ============================================================================ // Activity Stream Models // ============================================================================ diff --git a/packages/ui/src/Icon/icons.ts b/packages/ui/src/Icon/icons.ts index 0d3aafd..f2a14a1 100644 --- a/packages/ui/src/Icon/icons.ts +++ b/packages/ui/src/Icon/icons.ts @@ -148,6 +148,14 @@ export const icons = { viewBox: '0 0 24 24', path: 'M1 21h22L12 2 1 21zm12-3h-2v-2h2v2zm0-4h-2v-4h2v4z', }, + star: { + viewBox: '0 0 24 24', + path: 'M22 9.24l-7.19-.62L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21 12 17.27 18.18 21l-1.63-7.03L22 9.24zM12 15.4l-3.76 2.27 1-4.28-3.32-2.88 4.38-.38L12 6.1l1.71 4.04 4.38.38-3.32 2.88 1 4.28L12 15.4z', + }, + starFilled: { + viewBox: '0 0 24 24', + path: 'M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z', + }, } as const; export type IconName = keyof typeof icons;