feat: add social proof features and comprehensive documentation

Priority 2 - Social Proof:
- Add ToolRating and ToolReview models to Prisma schema
- Add rating aggregates (averageRating, ratingCount, reviewCount) to Tool model
- Create /api/tools/[id]/rate endpoint for tool ratings
- Create /api/tools/[id]/reviews endpoint for tool reviews
- Create /api/tools/trending endpoint for trending tools
- Add Rating component with interactive star rating
- Add ReviewCard component with user avatars and review display
- Add star and starFilled icons to UI package
- Update ToolDetailClient to show ratings

Priority 3 - Documentation:
- Create /docs/api/tools API documentation page
- Create /docs/api/agents API documentation page
- Create /docs/api/collections API documentation page
- Create /docs/api/authentication documentation page
- Create /docs/quickstart getting started tutorial
- Create /docs/sdk SDK reference documentation
This commit is contained in:
Ajax Davis 2026-01-14 12:11:47 +10:00
parent d22d0e6f59
commit b1dd3371cd
15 changed files with 3736 additions and 5 deletions

View file

@ -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<T = unknown> {
success: boolean;
data?: T;
error?: {
code: string;
message: string;
details?: Record<string, unknown>;
};
meta: {
version: string;
timestamp: string;
requestId?: string;
};
}
interface RouteContext {
params: Promise<{ id: string }>;
}
/**
* GET /api/tools/[id]/rate
* Get the current user's rating for this tool and aggregate stats
*/
export async function GET(
_request: NextRequest,
context: RouteContext
): Promise<NextResponse<ApiResponse>> {
const requestId = crypto.randomUUID();
const { id } = await context.params;
try {
const session = await auth.api.getSession({
headers: await headers(),
});
// 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<NextResponse<ApiResponse>> {
const requestId = crypto.randomUUID();
const { id } = await context.params;
try {
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session) {
return NextResponse.json(
{
success: false,
error: { code: 'UNAUTHORIZED', message: 'Authentication required' },
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
},
{ status: 401 }
);
}
// 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<NextResponse<ApiResponse>> {
const requestId = crypto.randomUUID();
const { id } = await context.params;
try {
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session) {
return NextResponse.json(
{
success: false,
error: { code: 'UNAUTHORIZED', message: 'Authentication required' },
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
},
{ status: 401 }
);
}
// Check if 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 }
);
}
}

View file

@ -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<T = unknown> {
success: boolean;
data?: T;
error?: {
code: string;
message: string;
details?: Record<string, unknown>;
};
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<NextResponse<ApiResponse>> {
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<string, 'asc' | 'desc'>[];
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<NextResponse<ApiResponse>> {
const requestId = crypto.randomUUID();
const { id } = await context.params;
try {
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session) {
return NextResponse.json(
{
success: false,
error: { code: 'UNAUTHORIZED', message: 'Authentication required' },
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
},
{ status: 401 }
);
}
// 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<NextResponse<ApiResponse>> {
const requestId = crypto.randomUUID();
const { id } = await context.params;
try {
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session) {
return NextResponse.json(
{
success: false,
error: { code: 'UNAUTHORIZED', message: 'Authentication required' },
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
},
{ status: 401 }
);
}
// Check if 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 }
);
}
}

View file

@ -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<T = unknown> {
success: boolean;
data?: T;
error?: {
code: string;
message: string;
details?: Record<string, unknown>;
};
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<NextResponse<ApiResponse>> {
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<string, unknown> = {
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 }
);
}
}

View file

@ -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 (
<div className="space-y-8">
<div>
<h1 className="text-3xl font-bold mb-4">Agents API</h1>
<p className="text-foreground-secondary text-lg">
The Agents API allows you to manage and interact with AI agents that can use TPMJS tools.
</p>
</div>
{/* List User Agents */}
<Card>
<CardHeader>
<CardTitle>List Agents</CardTitle>
<CardDescription>GET /api/agents</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-foreground-secondary">
Retrieve a list of your agents. Requires authentication.
</p>
<h4 className="font-semibold">Example Request</h4>
<CodeBlock
code={`curl -H "Authorization: Bearer YOUR_API_KEY" \\
"https://tpmjs.com/api/agents"`}
language="bash"
showCopy={true}
/>
<h4 className="font-semibold">Example Response</h4>
<CodeBlock
code={`{
"success": true,
"data": [
{
"id": "clx123abc",
"uid": "my-assistant",
"name": "My Assistant",
"description": "A helpful AI assistant",
"provider": "ANTHROPIC",
"modelId": "claude-3-5-sonnet-20241022",
"isPublic": true,
"likeCount": 15
}
]
}`}
language="json"
showCopy={true}
/>
</CardContent>
</Card>
{/* Create Agent */}
<Card>
<CardHeader>
<CardTitle>Create Agent</CardTitle>
<CardDescription>POST /api/agents</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-foreground-secondary">
Create a new AI agent with specified configuration.
</p>
<h4 className="font-semibold">Request Body</h4>
<CodeBlock
code={`{
"name": "My Assistant",
"description": "A helpful AI assistant",
"uid": "my-assistant",
"provider": "ANTHROPIC",
"modelId": "claude-3-5-sonnet-20241022",
"systemPrompt": "You are a helpful assistant...",
"temperature": 0.7,
"isPublic": true
}`}
language="json"
showCopy={true}
/>
<h4 className="font-semibold">TypeScript Example</h4>
<CodeBlock
code={`const response = await fetch('/api/agents', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer YOUR_API_KEY'
},
body: JSON.stringify({
name: 'My Assistant',
uid: 'my-assistant',
provider: 'ANTHROPIC',
modelId: 'claude-3-5-sonnet-20241022'
})
});
const { data: agent } = await response.json();`}
language="typescript"
showCopy={true}
/>
</CardContent>
</Card>
{/* Chat with Agent */}
<Card>
<CardHeader>
<CardTitle>Chat with Agent</CardTitle>
<CardDescription>POST /api/agents/:uid/chat</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-foreground-secondary">
Send a message to an agent and receive a streaming response. The agent can use any tools
attached to it.
</p>
<h4 className="font-semibold">Request Body</h4>
<CodeBlock
code={`{
"message": "What's the weather in San Francisco?",
"conversationId": "conv-123" // optional
}`}
language="json"
showCopy={true}
/>
<h4 className="font-semibold">TypeScript Example</h4>
<CodeBlock
code={`const response = await fetch('/api/agents/my-assistant/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
message: 'What is the weather in San Francisco?'
})
});
// Handle SSE stream
const reader = response.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const text = decoder.decode(value);
console.log(text);
}`}
language="typescript"
showCopy={true}
/>
</CardContent>
</Card>
{/* Add Tool to Agent */}
<Card>
<CardHeader>
<CardTitle>Add Tool to Agent</CardTitle>
<CardDescription>POST /api/agents/:uid/tools</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-foreground-secondary">
Add a tool to an agent&apos;s available tools.
</p>
<h4 className="font-semibold">Request Body</h4>
<CodeBlock
code={`{
"toolId": "clx123abc"
}`}
language="json"
showCopy={true}
/>
</CardContent>
</Card>
{/* Add Collection to Agent */}
<Card>
<CardHeader>
<CardTitle>Add Collection to Agent</CardTitle>
<CardDescription>POST /api/agents/:uid/collections</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-foreground-secondary">Add all tools from a collection to an agent.</p>
<h4 className="font-semibold">Request Body</h4>
<CodeBlock
code={`{
"collectionId": "clx456def"
}`}
language="json"
showCopy={true}
/>
</CardContent>
</Card>
</div>
);
}

View file

@ -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 (
<div className="space-y-8">
<div>
<h1 className="text-3xl font-bold mb-4">Authentication</h1>
<p className="text-foreground-secondary text-lg">
Learn how to authenticate with the TPMJS API to access protected endpoints.
</p>
</div>
{/* Overview */}
<Card>
<CardHeader>
<CardTitle>Overview</CardTitle>
<CardDescription>Understanding TPMJS authentication</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-foreground-secondary">
TPMJS uses API keys for authentication. Some endpoints are public and don&apos;t require
authentication, while others require a valid API key to access.
</p>
<h4 className="font-semibold">Public Endpoints (No Auth Required)</h4>
<ul className="list-disc list-inside space-y-1 text-foreground-secondary">
<li>
<code>GET /api/tools</code> - List and search tools
</li>
<li>
<code>GET /api/tools/:id</code> - Get tool details
</li>
<li>
<code>GET /api/tools/trending</code> - Get trending tools
</li>
<li>
<code>GET /api/collections</code> - List public collections
</li>
<li>
<code>GET /api/collections/:uid</code> - Get collection details
</li>
</ul>
<h4 className="font-semibold">Protected Endpoints (Auth Required)</h4>
<ul className="list-disc list-inside space-y-1 text-foreground-secondary">
<li>
<code>POST /api/tools/:id/rate</code> - Rate a tool
</li>
<li>
<code>POST /api/tools/:id/reviews</code> - Write a review
</li>
<li>
<code>POST /api/collections</code> - Create a collection
</li>
<li>
<code>POST /api/agents</code> - Create an agent
</li>
<li>
<code>GET /api/agents</code> - List your agents
</li>
</ul>
</CardContent>
</Card>
{/* Getting an API Key */}
<Card>
<CardHeader>
<CardTitle>Getting an API Key</CardTitle>
<CardDescription>How to obtain your API key</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-foreground-secondary">
To get an API key, you need to create a TPMJS account and generate a key from your
dashboard.
</p>
<ol className="list-decimal list-inside space-y-2 text-foreground-secondary">
<li>Sign up or log in at tpmjs.com</li>
<li>Navigate to Settings API Keys</li>
<li>Click &quot;Generate New Key&quot;</li>
<li>Copy your key and store it securely</li>
</ol>
<div className="bg-amber-500/10 border border-amber-500/20 rounded-lg p-4">
<p className="text-amber-600 dark:text-amber-400 font-medium">Important</p>
<p className="text-foreground-secondary text-sm mt-1">
Your API key is displayed only once when created. Make sure to copy and store it
securely. If you lose it, you&apos;ll need to generate a new one.
</p>
</div>
</CardContent>
</Card>
{/* Using Your API Key */}
<Card>
<CardHeader>
<CardTitle>Using Your API Key</CardTitle>
<CardDescription>How to authenticate requests</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-foreground-secondary">
Include your API key in the <code>Authorization</code> header using the Bearer scheme.
</p>
<h4 className="font-semibold">Header Format</h4>
<CodeBlock code={`Authorization: Bearer YOUR_API_KEY`} language="text" showCopy={true} />
<h4 className="font-semibold">cURL Example</h4>
<CodeBlock
code={`curl -H "Authorization: Bearer YOUR_API_KEY" \\
"https://tpmjs.com/api/agents"`}
language="bash"
showCopy={true}
/>
<h4 className="font-semibold">JavaScript/TypeScript Example</h4>
<CodeBlock
code={`const API_KEY = process.env.TPMJS_API_KEY;
const response = await fetch('https://tpmjs.com/api/agents', {
headers: {
'Authorization': \`Bearer \${API_KEY}\`,
'Content-Type': 'application/json'
}
});
const data = await response.json();`}
language="typescript"
showCopy={true}
/>
<h4 className="font-semibold">Python Example</h4>
<CodeBlock
code={`import os
import requests
API_KEY = os.environ.get('TPMJS_API_KEY')
response = requests.get(
'https://tpmjs.com/api/agents',
headers={
'Authorization': f'Bearer {API_KEY}',
'Content-Type': 'application/json'
}
)
data = response.json()`}
language="python"
showCopy={true}
/>
</CardContent>
</Card>
{/* Error Responses */}
<Card>
<CardHeader>
<CardTitle>Authentication Errors</CardTitle>
<CardDescription>Common authentication error responses</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<h4 className="font-semibold">401 Unauthorized</h4>
<p className="text-foreground-secondary text-sm mb-2">
Returned when no API key is provided or the key is invalid.
</p>
<CodeBlock
code={`{
"success": false,
"error": {
"code": "UNAUTHORIZED",
"message": "Authentication required. Please provide a valid API key."
}
}`}
language="json"
showCopy={true}
/>
<h4 className="font-semibold">403 Forbidden</h4>
<p className="text-foreground-secondary text-sm mb-2">
Returned when the API key is valid but lacks permission for the requested action.
</p>
<CodeBlock
code={`{
"success": false,
"error": {
"code": "FORBIDDEN",
"message": "You don't have permission to access this resource."
}
}`}
language="json"
showCopy={true}
/>
<h4 className="font-semibold">429 Rate Limited</h4>
<p className="text-foreground-secondary text-sm mb-2">
Returned when you&apos;ve exceeded the rate limit for your API key.
</p>
<CodeBlock
code={`{
"success": false,
"error": {
"code": "RATE_LIMITED",
"message": "Too many requests. Please try again in 60 seconds."
}
}`}
language="json"
showCopy={true}
/>
</CardContent>
</Card>
{/* Rate Limits */}
<Card>
<CardHeader>
<CardTitle>Rate Limits</CardTitle>
<CardDescription>API request limits</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-foreground-secondary">
TPMJS enforces rate limits to ensure fair usage and protect the API from abuse.
</p>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b">
<th className="text-left py-2 pr-4">Tier</th>
<th className="text-left py-2 pr-4">Requests/min</th>
<th className="text-left py-2">Requests/day</th>
</tr>
</thead>
<tbody>
<tr className="border-b">
<td className="py-2 pr-4">Free</td>
<td className="py-2 pr-4">60</td>
<td className="py-2">1,000</td>
</tr>
<tr className="border-b">
<td className="py-2 pr-4">Pro</td>
<td className="py-2 pr-4">300</td>
<td className="py-2">10,000</td>
</tr>
<tr>
<td className="py-2 pr-4">Enterprise</td>
<td className="py-2 pr-4">Custom</td>
<td className="py-2">Custom</td>
</tr>
</tbody>
</table>
</div>
<h4 className="font-semibold">Rate Limit Headers</h4>
<p className="text-foreground-secondary text-sm mb-2">
Every response includes rate limit information in headers:
</p>
<CodeBlock
code={`X-RateLimit-Limit: 60
X-RateLimit-Remaining: 45
X-RateLimit-Reset: 1704067200`}
language="text"
showCopy={true}
/>
</CardContent>
</Card>
{/* Best Practices */}
<Card>
<CardHeader>
<CardTitle>Security Best Practices</CardTitle>
<CardDescription>Keep your API key secure</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<ul className="list-disc list-inside space-y-2 text-foreground-secondary">
<li>
<strong>Never commit API keys to version control.</strong> Use environment variables
instead.
</li>
<li>
<strong>Rotate keys regularly.</strong> Generate new keys periodically and revoke old
ones.
</li>
<li>
<strong>Use separate keys for development and production.</strong> This limits the
impact if a key is compromised.
</li>
<li>
<strong>Monitor usage.</strong> Check your API usage in the dashboard to detect
unusual activity.
</li>
<li>
<strong>Revoke compromised keys immediately.</strong> If you suspect a key has been
exposed, revoke it and generate a new one.
</li>
</ul>
<h4 className="font-semibold">Environment Variables Example</h4>
<CodeBlock
code={`# .env.local (add to .gitignore)
TPMJS_API_KEY=your_api_key_here
# In your code
const apiKey = process.env.TPMJS_API_KEY;`}
language="bash"
showCopy={true}
/>
</CardContent>
</Card>
</div>
);
}

View file

@ -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 (
<div className="space-y-8">
<div>
<h1 className="text-3xl font-bold mb-4">Collections API</h1>
<p className="text-foreground-secondary text-lg">
The Collections API allows you to create and manage curated sets of tools for specific use
cases.
</p>
</div>
{/* List Collections */}
<Card>
<CardHeader>
<CardTitle>List Collections</CardTitle>
<CardDescription>GET /api/collections</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-foreground-secondary">
Retrieve a paginated list of public collections with optional filtering.
</p>
<h4 className="font-semibold">Query Parameters</h4>
<ul className="list-disc list-inside space-y-1 text-foreground-secondary">
<li>
<code>limit</code> - Number of results (default: 20, max: 50)
</li>
<li>
<code>offset</code> - Pagination offset
</li>
<li>
<code>q</code> - Search query
</li>
</ul>
<h4 className="font-semibold">Example Request</h4>
<CodeBlock
code={`curl "https://tpmjs.com/api/collections?limit=10"`}
language="bash"
showCopy={true}
/>
<h4 className="font-semibold">Example Response</h4>
<CodeBlock
code={`{
"success": true,
"data": [
{
"id": "clx123abc",
"uid": "web-scraping-toolkit",
"name": "Web Scraping Toolkit",
"description": "Essential tools for web scraping and data extraction",
"isPublic": true,
"toolCount": 5,
"likeCount": 42,
"owner": {
"id": "user123",
"name": "John Doe",
"username": "johndoe"
}
}
],
"pagination": {
"limit": 10,
"offset": 0,
"hasMore": true
}
}`}
language="json"
showCopy={true}
/>
</CardContent>
</Card>
{/* Get Collection */}
<Card>
<CardHeader>
<CardTitle>Get Collection</CardTitle>
<CardDescription>GET /api/collections/:uid</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-foreground-secondary">
Retrieve detailed information about a specific collection, including its tools.
</p>
<h4 className="font-semibold">Path Parameters</h4>
<ul className="list-disc list-inside space-y-1 text-foreground-secondary">
<li>
<code>uid</code> - Collection unique identifier or slug
</li>
</ul>
<h4 className="font-semibold">Example Request</h4>
<CodeBlock
code={`curl "https://tpmjs.com/api/collections/web-scraping-toolkit"`}
language="bash"
showCopy={true}
/>
<h4 className="font-semibold">Example Response</h4>
<CodeBlock
code={`{
"success": true,
"data": {
"id": "clx123abc",
"uid": "web-scraping-toolkit",
"name": "Web Scraping Toolkit",
"description": "Essential tools for web scraping and data extraction",
"isPublic": true,
"toolCount": 5,
"likeCount": 42,
"createdAt": "2024-01-15T10:30:00Z",
"updatedAt": "2024-01-20T14:45:00Z",
"tools": [
{
"id": "tool123",
"name": "webScraperTool",
"description": "Scrape content from web pages"
}
],
"owner": {
"id": "user123",
"name": "John Doe",
"username": "johndoe"
}
}
}`}
language="json"
showCopy={true}
/>
</CardContent>
</Card>
{/* Create Collection */}
<Card>
<CardHeader>
<CardTitle>Create Collection</CardTitle>
<CardDescription>POST /api/collections</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-foreground-secondary">
Create a new collection. Requires authentication.
</p>
<h4 className="font-semibold">Request Body</h4>
<CodeBlock
code={`{
"name": "My Data Tools",
"uid": "my-data-tools",
"description": "A collection of data processing tools",
"isPublic": true
}`}
language="json"
showCopy={true}
/>
<h4 className="font-semibold">TypeScript Example</h4>
<CodeBlock
code={`const response = await fetch('/api/collections', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer YOUR_API_KEY'
},
body: JSON.stringify({
name: 'My Data Tools',
uid: 'my-data-tools',
description: 'A collection of data processing tools',
isPublic: true
})
});
const { data: collection } = await response.json();
console.log('Created collection:', collection.id);`}
language="typescript"
showCopy={true}
/>
</CardContent>
</Card>
{/* Update Collection */}
<Card>
<CardHeader>
<CardTitle>Update Collection</CardTitle>
<CardDescription>PATCH /api/collections/:uid</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-foreground-secondary">
Update an existing collection. Requires authentication and ownership.
</p>
<h4 className="font-semibold">Request Body</h4>
<CodeBlock
code={`{
"name": "Updated Collection Name",
"description": "Updated description",
"isPublic": false
}`}
language="json"
showCopy={true}
/>
<h4 className="font-semibold">Example Request</h4>
<CodeBlock
code={`curl -X PATCH "https://tpmjs.com/api/collections/my-data-tools" \\
-H "Authorization: Bearer YOUR_API_KEY" \\
-H "Content-Type: application/json" \\
-d '{"name": "Updated Collection Name"}'`}
language="bash"
showCopy={true}
/>
</CardContent>
</Card>
{/* Add Tool to Collection */}
<Card>
<CardHeader>
<CardTitle>Add Tool to Collection</CardTitle>
<CardDescription>POST /api/collections/:uid/tools</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-foreground-secondary">
Add a tool to a collection. Requires authentication and ownership.
</p>
<h4 className="font-semibold">Request Body</h4>
<CodeBlock
code={`{
"toolId": "clx456def"
}`}
language="json"
showCopy={true}
/>
<h4 className="font-semibold">TypeScript Example</h4>
<CodeBlock
code={`const response = await fetch('/api/collections/my-data-tools/tools', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer YOUR_API_KEY'
},
body: JSON.stringify({
toolId: 'clx456def'
})
});
if (response.ok) {
console.log('Tool added to collection');
}`}
language="typescript"
showCopy={true}
/>
</CardContent>
</Card>
{/* Remove Tool from Collection */}
<Card>
<CardHeader>
<CardTitle>Remove Tool from Collection</CardTitle>
<CardDescription>DELETE /api/collections/:uid/tools/:toolId</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-foreground-secondary">
Remove a tool from a collection. Requires authentication and ownership.
</p>
<h4 className="font-semibold">Example Request</h4>
<CodeBlock
code={`curl -X DELETE "https://tpmjs.com/api/collections/my-data-tools/tools/clx456def" \\
-H "Authorization: Bearer YOUR_API_KEY"`}
language="bash"
showCopy={true}
/>
<h4 className="font-semibold">Example Response</h4>
<CodeBlock
code={`{
"success": true,
"message": "Tool removed from collection"
}`}
language="json"
showCopy={true}
/>
</CardContent>
</Card>
{/* Like Collection */}
<Card>
<CardHeader>
<CardTitle>Like Collection</CardTitle>
<CardDescription>POST /api/collections/:uid/like</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-foreground-secondary">
Like or unlike a collection. Requires authentication.
</p>
<h4 className="font-semibold">TypeScript Example</h4>
<CodeBlock
code={`// Like a collection
const response = await fetch('/api/collections/web-scraping-toolkit/like', {
method: 'POST',
headers: {
'Authorization': 'Bearer YOUR_API_KEY'
}
});
const { data } = await response.json();
console.log('Liked:', data.liked); // true if now liked, false if unliked`}
language="typescript"
showCopy={true}
/>
</CardContent>
</Card>
{/* Delete Collection */}
<Card>
<CardHeader>
<CardTitle>Delete Collection</CardTitle>
<CardDescription>DELETE /api/collections/:uid</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-foreground-secondary">
Delete a collection. Requires authentication and ownership. This action cannot be
undone.
</p>
<h4 className="font-semibold">Example Request</h4>
<CodeBlock
code={`curl -X DELETE "https://tpmjs.com/api/collections/my-data-tools" \\
-H "Authorization: Bearer YOUR_API_KEY"`}
language="bash"
showCopy={true}
/>
<h4 className="font-semibold">Example Response</h4>
<CodeBlock
code={`{
"success": true,
"message": "Collection deleted successfully"
}`}
language="json"
showCopy={true}
/>
</CardContent>
</Card>
</div>
);
}

View file

@ -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 (
<div className="space-y-8">
<div>
<h1 className="text-3xl font-bold mb-4">Tools API</h1>
<p className="text-foreground-secondary text-lg">
The Tools API allows you to search, discover, and execute TPMJS tools programmatically.
</p>
</div>
{/* List Tools */}
<Card>
<CardHeader>
<CardTitle>List Tools</CardTitle>
<CardDescription>GET /api/tools</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-foreground-secondary">
Retrieve a paginated list of tools with optional filtering.
</p>
<h4 className="font-semibold">Query Parameters</h4>
<ul className="list-disc list-inside space-y-1 text-foreground-secondary">
<li>
<code>limit</code> - Number of results (default: 20, max: 50)
</li>
<li>
<code>offset</code> - Pagination offset
</li>
<li>
<code>category</code> - Filter by category
</li>
<li>
<code>q</code> - Search query
</li>
</ul>
<h4 className="font-semibold">Example Request</h4>
<CodeBlock
code={`curl "https://tpmjs.com/api/tools?category=communication&limit=10"`}
language="bash"
showCopy={true}
/>
<h4 className="font-semibold">Example Response</h4>
<CodeBlock
code={`{
"success": true,
"data": [
{
"id": "clx123abc",
"name": "discordPostTool",
"description": "Post messages to Discord channels",
"qualityScore": 0.85,
"package": {
"npmPackageName": "@tpmjs/discord-post",
"category": "communication"
}
}
],
"pagination": {
"limit": 10,
"offset": 0,
"hasMore": true
}
}`}
language="json"
showCopy={true}
/>
</CardContent>
</Card>
{/* Get Tool by ID */}
<Card>
<CardHeader>
<CardTitle>Get Tool</CardTitle>
<CardDescription>GET /api/tools/:id</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-foreground-secondary">
Retrieve detailed information about a specific tool.
</p>
<h4 className="font-semibold">Path Parameters</h4>
<ul className="list-disc list-inside space-y-1 text-foreground-secondary">
<li>
<code>id</code> - Tool ID or package/tool slug
</li>
</ul>
<h4 className="font-semibold">Example Request</h4>
<CodeBlock
code={`curl "https://tpmjs.com/api/tools/@tpmjs/discord-post/discordPostTool"`}
language="bash"
showCopy={true}
/>
</CardContent>
</Card>
{/* Execute Tool */}
<Card>
<CardHeader>
<CardTitle>Execute Tool</CardTitle>
<CardDescription>POST /api/tools/execute/:slug</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-foreground-secondary">
Execute a tool with an AI agent. Returns streaming SSE response.
</p>
<h4 className="font-semibold">Request Body</h4>
<CodeBlock
code={`{
"prompt": "Send a hello message to #general channel",
"parameters": {
"channel": "#general"
}
}`}
language="json"
showCopy={true}
/>
<h4 className="font-semibold">TypeScript Example</h4>
<CodeBlock
code={`import { executeToolCall } from '@tpmjs/registry-execute';
const result = await executeToolCall({
toolId: '@tpmjs/discord-post/discordPostTool',
prompt: 'Send hello to #general',
apiKey: 'your-api-key',
onChunk: (text) => console.log(text),
});
console.log(result.output);`}
language="typescript"
showCopy={true}
/>
</CardContent>
</Card>
{/* Trending Tools */}
<Card>
<CardHeader>
<CardTitle>Trending Tools</CardTitle>
<CardDescription>GET /api/tools/trending</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-foreground-secondary">
Get trending tools based on downloads, ratings, and activity.
</p>
<h4 className="font-semibold">Query Parameters</h4>
<ul className="list-disc list-inside space-y-1 text-foreground-secondary">
<li>
<code>period</code> - Time period: day, week, month, all (default: week)
</li>
<li>
<code>category</code> - Filter by category
</li>
<li>
<code>limit</code> - Number of results (default: 20)
</li>
</ul>
<h4 className="font-semibold">Example Request</h4>
<CodeBlock
code={`curl "https://tpmjs.com/api/tools/trending?period=week&limit=10"`}
language="bash"
showCopy={true}
/>
</CardContent>
</Card>
{/* Rate Tool */}
<Card>
<CardHeader>
<CardTitle>Rate Tool</CardTitle>
<CardDescription>POST /api/tools/:id/rate</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-foreground-secondary">
Rate a tool from 1-5 stars. Requires authentication.
</p>
<h4 className="font-semibold">Request Body</h4>
<CodeBlock
code={`{
"rating": 5
}`}
language="json"
showCopy={true}
/>
<h4 className="font-semibold">Example Response</h4>
<CodeBlock
code={`{
"success": true,
"data": {
"userRating": 5,
"averageRating": 4.5,
"ratingCount": 42
}
}`}
language="json"
showCopy={true}
/>
</CardContent>
</Card>
</div>
);
}

View file

@ -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 (
<div className="space-y-8">
<div>
<h1 className="text-3xl font-bold mb-4">Quickstart Guide</h1>
<p className="text-foreground-secondary text-lg">
Get your AI agent access to thousands of tools in under 5 minutes.
</p>
</div>
{/* Prerequisites */}
<Card>
<CardHeader>
<CardTitle>Prerequisites</CardTitle>
<CardDescription>What you need before starting</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<ul className="list-disc list-inside space-y-2 text-foreground-secondary">
<li>Node.js 18 or later</li>
<li>npm, pnpm, or yarn</li>
<li>
An AI provider API key (OpenAI, Anthropic, or{' '}
<a
href="https://sdk.vercel.ai/providers/ai-sdk-providers"
className="text-primary hover:underline"
target="_blank"
rel="noopener noreferrer"
>
any AI SDK provider
</a>
)
</li>
</ul>
</CardContent>
</Card>
{/* Step 1: Installation */}
<Card>
<CardHeader>
<CardTitle>Step 1: Install the SDK</CardTitle>
<CardDescription>Add TPMJS packages to your project</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-foreground-secondary">
Install the TPMJS SDK packages alongside the Vercel AI SDK:
</p>
<h4 className="font-semibold">npm</h4>
<CodeBlock
code="npm install @tpmjs/registry-search @tpmjs/registry-execute ai zod"
language="bash"
showCopy={true}
/>
<h4 className="font-semibold">pnpm</h4>
<CodeBlock
code="pnpm add @tpmjs/registry-search @tpmjs/registry-execute ai zod"
language="bash"
showCopy={true}
/>
<h4 className="font-semibold">yarn</h4>
<CodeBlock
code="yarn add @tpmjs/registry-search @tpmjs/registry-execute ai zod"
language="bash"
showCopy={true}
/>
<p className="text-foreground-secondary text-sm mt-4">
Also install your preferred AI provider SDK:
</p>
<CodeBlock
code={`# Anthropic (Claude)
npm install @ai-sdk/anthropic
# OpenAI (GPT-4, etc.)
npm install @ai-sdk/openai
# Google (Gemini)
npm install @ai-sdk/google`}
language="bash"
showCopy={true}
/>
</CardContent>
</Card>
{/* Step 2: Basic Usage */}
<Card>
<CardHeader>
<CardTitle>Step 2: Add Tools to Your Agent</CardTitle>
<CardDescription>Give your agent access to the TPMJS registry</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-foreground-secondary">
Import the TPMJS tools and add them to your AI agent:
</p>
<CodeBlock
code={`import { streamText } from 'ai';
import { anthropic } from '@ai-sdk/anthropic';
import { registrySearchTool } from '@tpmjs/registry-search';
import { registryExecuteTool } from '@tpmjs/registry-execute';
// Create a streaming text generation with tools
const result = await streamText({
model: anthropic('claude-sonnet-4-20250514'),
tools: {
registrySearch: registrySearchTool,
registryExecute: registryExecuteTool,
},
system: \`You are a helpful assistant with access to thousands of tools
via the TPMJS registry. Use registrySearch to find tools for any task,
then registryExecute to run them.\`,
prompt: 'Search for weather tools and get the current weather in Tokyo',
});
// Handle the streaming response
for await (const chunk of result.textStream) {
process.stdout.write(chunk);
}`}
language="typescript"
showCopy={true}
/>
</CardContent>
</Card>
{/* Step 3: Understanding the Tools */}
<Card>
<CardHeader>
<CardTitle>Step 3: Understanding the Tools</CardTitle>
<CardDescription>How registrySearch and registryExecute work</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div>
<h4 className="font-semibold mb-2">registrySearchTool</h4>
<p className="text-foreground-secondary mb-2">
Searches the TPMJS registry to find tools for any task. Returns metadata including the
toolId needed for execution.
</p>
<CodeBlock
code={`// The agent will call this tool to search for tools
{
"query": "web scraping",
"category": "web-scraping", // optional
"limit": 5 // optional
}`}
language="json"
showCopy={true}
/>
</div>
<div>
<h4 className="font-semibold mb-2">registryExecuteTool</h4>
<p className="text-foreground-secondary mb-2">
Executes any tool from the registry by its toolId. Tools run in a secure sandboxno
local installation required.
</p>
<CodeBlock
code={`// The agent will call this tool to execute a tool
{
"toolId": "@firecrawl/ai-sdk::scrapeTool",
"params": { "url": "https://example.com" },
"env": { "FIRECRAWL_API_KEY": "..." } // optional
}`}
language="json"
showCopy={true}
/>
</div>
</CardContent>
</Card>
{/* Step 4: Passing API Keys */}
<Card>
<CardHeader>
<CardTitle>Step 4: Configure API Keys</CardTitle>
<CardDescription>Pre-configure API keys for tools that need them</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-foreground-secondary">
Many tools require API keys (e.g., Firecrawl, Exa, Tavily). Create a wrapper to
auto-inject your keys:
</p>
<h4 className="font-semibold">Create tools.ts</h4>
<CodeBlock
code={`import { tool } from 'ai';
import { registryExecuteTool } from '@tpmjs/registry-execute';
// Pre-configure your API keys
const API_KEYS: Record<string, string> = {
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}
/>
<h4 className="font-semibold">Use the wrapped tool</h4>
<CodeBlock
code={`import { registrySearchTool } from '@tpmjs/registry-search';
import { registryExecute } from './tools';
const result = await streamText({
model: anthropic('claude-sonnet-4-20250514'),
tools: {
registrySearch: registrySearchTool,
registryExecute, // Uses your pre-configured keys
},
prompt: 'Scrape https://example.com',
});`}
language="typescript"
showCopy={true}
/>
</CardContent>
</Card>
{/* Step 5: Real-World Example */}
<Card>
<CardHeader>
<CardTitle>Step 5: Complete Example</CardTitle>
<CardDescription>A full working example you can copy</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<h4 className="font-semibold">agent.ts</h4>
<CodeBlock
code={`import { streamText, generateText } from 'ai';
import { anthropic } from '@ai-sdk/anthropic';
import { registrySearchTool } from '@tpmjs/registry-search';
import { registryExecuteTool } from '@tpmjs/registry-execute';
// Configure environment variables
const API_KEYS: Record<string, string> = {
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<typeof registryExecuteTool.execute>[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}
/>
</CardContent>
</Card>
{/* Next Steps */}
<Card>
<CardHeader>
<CardTitle>Next Steps</CardTitle>
<CardDescription>Continue learning about TPMJS</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<ul className="space-y-3">
<li>
<Link href="/docs/sdk" className="text-primary hover:underline font-medium">
SDK Documentation
</Link>
<p className="text-foreground-secondary text-sm">
Detailed reference for all SDK functions and options
</p>
</li>
<li>
<Link href="/docs/api/tools" className="text-primary hover:underline font-medium">
Tools API Reference
</Link>
<p className="text-foreground-secondary text-sm">
REST API endpoints for searching and executing tools
</p>
</li>
<li>
<Link href="/docs/api/agents" className="text-primary hover:underline font-medium">
Agents API Reference
</Link>
<p className="text-foreground-secondary text-sm">
Create and manage AI agents with TPMJS tools
</p>
</li>
<li>
<Link href="/publish" className="text-primary hover:underline font-medium">
Publishing Guide
</Link>
<p className="text-foreground-secondary text-sm">
Learn how to publish your own tools to TPMJS
</p>
</li>
<li>
<Link href="/" className="text-primary hover:underline font-medium">
Browse Tool Registry
</Link>
<p className="text-foreground-secondary text-sm">
Explore available tools and find ones for your use case
</p>
</li>
</ul>
</CardContent>
</Card>
{/* Troubleshooting */}
<Card>
<CardHeader>
<CardTitle>Common Issues</CardTitle>
<CardDescription>Solutions to frequently encountered problems</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-4">
<div>
<h4 className="font-semibold text-foreground">
Tool execution fails with &quot;missing API key&quot;
</h4>
<p className="text-foreground-secondary text-sm mt-1">
Check that you&apos;re passing the required environment variables in the{' '}
<code className="text-primary">env</code> parameter of registryExecute.
</p>
<CodeBlock
code={`registryExecute.execute({
toolId: '@firecrawl/ai-sdk::scrapeTool',
params: { url: 'https://example.com' },
env: { FIRECRAWL_API_KEY: process.env.FIRECRAWL_API_KEY } // Required!
})`}
language="typescript"
showCopy={true}
/>
</div>
<div>
<h4 className="font-semibold text-foreground">Tool not found in search results</h4>
<p className="text-foreground-secondary text-sm mt-1">
Try broader search terms or check the tool category. You can also browse the full
registry at{' '}
<Link href="/" className="text-primary hover:underline">
tpmjs.com
</Link>
.
</p>
</div>
<div>
<h4 className="font-semibold text-foreground">Agent not using the tools</h4>
<p className="text-foreground-secondary text-sm mt-1">
Make sure your system prompt clearly instructs the agent to use registrySearch
first, then registryExecute. Also ensure{' '}
<code className="text-primary">maxSteps</code> is set high enough (default is 1).
</p>
<CodeBlock
code={`const result = await generateText({
model: anthropic('claude-sonnet-4-20250514'),
tools: { registrySearch, registryExecute },
maxSteps: 5, // Allow multiple tool calls
system: 'Use registrySearch to find tools, then registryExecute to run them.',
prompt: 'Your prompt here',
});`}
language="typescript"
showCopy={true}
/>
</div>
</div>
</CardContent>
</Card>
</div>
);
}

View file

@ -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 (
<div className="space-y-8">
<div>
<h1 className="text-3xl font-bold mb-4">SDK Reference</h1>
<p className="text-foreground-secondary text-lg">
Complete reference for the TPMJS SDK packages: @tpmjs/registry-search and
@tpmjs/registry-execute.
</p>
</div>
{/* Overview */}
<Card>
<CardHeader>
<CardTitle>Overview</CardTitle>
<CardDescription>TPMJS SDK packages</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-foreground-secondary">
The TPMJS SDK consists of two packages that give your AI agent access to the tool
registry:
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
<div className="p-4 border border-border rounded-lg">
<h4 className="font-semibold text-foreground">@tpmjs/registry-search</h4>
<p className="text-foreground-secondary text-sm mt-1">
Search the TPMJS registry to find tools for any task
</p>
</div>
<div className="p-4 border border-border rounded-lg">
<h4 className="font-semibold text-foreground">@tpmjs/registry-execute</h4>
<p className="text-foreground-secondary text-sm mt-1">
Execute any tool from the registry in a secure sandbox
</p>
</div>
</div>
<h4 className="font-semibold mt-6">Installation</h4>
<CodeBlock
code="npm install @tpmjs/registry-search @tpmjs/registry-execute"
language="bash"
showCopy={true}
/>
<h4 className="font-semibold">Peer Dependencies</h4>
<p className="text-foreground-secondary text-sm">
Both packages require <code className="text-primary">ai</code> (Vercel AI SDK) and{' '}
<code className="text-primary">zod</code> as peer dependencies.
</p>
<CodeBlock code="npm install ai zod" language="bash" showCopy={true} />
</CardContent>
</Card>
{/* registrySearchTool */}
<Card>
<CardHeader>
<CardTitle>registrySearchTool</CardTitle>
<CardDescription>@tpmjs/registry-search</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<p className="text-foreground-secondary">
An AI SDK tool that searches the TPMJS registry for tools matching a query.
</p>
<h4 className="font-semibold">Import</h4>
<CodeBlock
code={`import { registrySearchTool } from '@tpmjs/registry-search';`}
language="typescript"
showCopy={true}
/>
<h4 className="font-semibold">Basic Usage</h4>
<CodeBlock
code={`import { generateText } from 'ai';
import { anthropic } from '@ai-sdk/anthropic';
import { registrySearchTool } from '@tpmjs/registry-search';
const result = await generateText({
model: anthropic('claude-sonnet-4-20250514'),
tools: { registrySearch: registrySearchTool },
prompt: 'Find tools for web scraping',
});`}
language="typescript"
showCopy={true}
/>
<h4 className="font-semibold">Parameters</h4>
<div className="overflow-x-auto border border-border rounded-lg">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-surface">
<th className="text-left py-3 px-4">Parameter</th>
<th className="text-left py-3 px-4">Type</th>
<th className="text-left py-3 px-4">Required</th>
<th className="text-left py-3 px-4">Description</th>
</tr>
</thead>
<tbody>
<tr className="border-b border-border">
<td className="py-3 px-4 font-mono text-primary">query</td>
<td className="py-3 px-4 font-mono">string</td>
<td className="py-3 px-4">Yes</td>
<td className="py-3 px-4 text-foreground-secondary">
Search query (keywords, tool names, descriptions)
</td>
</tr>
<tr className="border-b border-border">
<td className="py-3 px-4 font-mono text-primary">category</td>
<td className="py-3 px-4 font-mono">string</td>
<td className="py-3 px-4">No</td>
<td className="py-3 px-4 text-foreground-secondary">Filter by category</td>
</tr>
<tr>
<td className="py-3 px-4 font-mono text-primary">limit</td>
<td className="py-3 px-4 font-mono">number</td>
<td className="py-3 px-4">No</td>
<td className="py-3 px-4 text-foreground-secondary">
Max results (1-20, default 5)
</td>
</tr>
</tbody>
</table>
</div>
<h4 className="font-semibold">Return Value</h4>
<CodeBlock
code={`interface SearchResult {
query: string;
matchCount: number;
tools: Array<{
toolId: string; // Format: package::name
name: string; // Tool export name
package: string; // npm package name
description: string; // Tool description
category: string; // Tool category
requiredEnvVars: string[]; // Required API keys
healthStatus: 'HEALTHY' | 'UNHEALTHY' | 'UNKNOWN';
qualityScore: number; // 0.00 - 1.00
}>;
}`}
language="typescript"
showCopy={true}
/>
<h4 className="font-semibold">Available Categories</h4>
<p className="text-foreground-secondary text-sm mb-2">
Filter results by category to narrow down your search:
</p>
<div className="flex flex-wrap gap-2 text-sm">
{[
'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) => (
<code key={cat} className="px-2 py-1 bg-surface border border-border rounded">
{cat}
</code>
))}
</div>
</CardContent>
</Card>
{/* registryExecuteTool */}
<Card>
<CardHeader>
<CardTitle>registryExecuteTool</CardTitle>
<CardDescription>@tpmjs/registry-execute</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<p className="text-foreground-secondary">
An AI SDK tool that executes any tool from the TPMJS registry in a secure sandbox.
</p>
<h4 className="font-semibold">Import</h4>
<CodeBlock
code={`import { registryExecuteTool } from '@tpmjs/registry-execute';`}
language="typescript"
showCopy={true}
/>
<h4 className="font-semibold">Basic Usage</h4>
<CodeBlock
code={`import { generateText } from 'ai';
import { anthropic } from '@ai-sdk/anthropic';
import { registryExecuteTool } from '@tpmjs/registry-execute';
const result = await generateText({
model: anthropic('claude-sonnet-4-20250514'),
tools: { registryExecute: registryExecuteTool },
prompt: 'Execute the hello world tool',
});`}
language="typescript"
showCopy={true}
/>
<h4 className="font-semibold">Parameters</h4>
<div className="overflow-x-auto border border-border rounded-lg">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-surface">
<th className="text-left py-3 px-4">Parameter</th>
<th className="text-left py-3 px-4">Type</th>
<th className="text-left py-3 px-4">Required</th>
<th className="text-left py-3 px-4">Description</th>
</tr>
</thead>
<tbody>
<tr className="border-b border-border">
<td className="py-3 px-4 font-mono text-primary">toolId</td>
<td className="py-3 px-4 font-mono">string</td>
<td className="py-3 px-4">Yes</td>
<td className="py-3 px-4 text-foreground-secondary">
Tool identifier in format: package::name
</td>
</tr>
<tr className="border-b border-border">
<td className="py-3 px-4 font-mono text-primary">params</td>
<td className="py-3 px-4 font-mono">object</td>
<td className="py-3 px-4">Yes</td>
<td className="py-3 px-4 text-foreground-secondary">
Parameters to pass to the tool
</td>
</tr>
<tr>
<td className="py-3 px-4 font-mono text-primary">env</td>
<td className="py-3 px-4 font-mono">object</td>
<td className="py-3 px-4">No</td>
<td className="py-3 px-4 text-foreground-secondary">
Environment variables (API keys) to pass to the tool
</td>
</tr>
</tbody>
</table>
</div>
<h4 className="font-semibold">Return Value</h4>
<CodeBlock
code={`interface ExecuteResult {
toolId: string; // The executed tool ID
executionTimeMs: number; // Execution time in milliseconds
output: unknown; // Tool output (varies by tool)
}`}
language="typescript"
showCopy={true}
/>
<h4 className="font-semibold">Passing API Keys</h4>
<CodeBlock
code={`// Direct usage with env parameter
const result = await registryExecuteTool.execute({
toolId: '@firecrawl/ai-sdk::scrapeTool',
params: { url: 'https://example.com' },
env: { FIRECRAWL_API_KEY: process.env.FIRECRAWL_API_KEY },
});`}
language="typescript"
showCopy={true}
/>
</CardContent>
</Card>
{/* Creating a Wrapped Execute Tool */}
<Card>
<CardHeader>
<CardTitle>Creating a Wrapped Execute Tool</CardTitle>
<CardDescription>Pre-configure API keys for seamless execution</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-foreground-secondary">
Create a wrapper around registryExecuteTool to automatically inject your API keys:
</p>
<CodeBlock
code={`import { tool } from 'ai';
import { registryExecuteTool } from '@tpmjs/registry-execute';
// Your pre-configured API keys
const API_KEYS: Record<string, string> = {
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}
/>
</CardContent>
</Card>
{/* Direct API Usage */}
<Card>
<CardHeader>
<CardTitle>Direct API Usage</CardTitle>
<CardDescription>Use the SDK without AI agent integration</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<p className="text-foreground-secondary">
Both tools can also be used directly without an AI agent:
</p>
<h4 className="font-semibold">Direct Search</h4>
<CodeBlock
code={`import { registrySearchTool } from '@tpmjs/registry-search';
// Search for tools directly
const searchResult = await registrySearchTool.execute({
query: 'web scraping',
limit: 5,
});
console.log('Found tools:', searchResult.tools);`}
language="typescript"
showCopy={true}
/>
<h4 className="font-semibold">Direct Execution</h4>
<CodeBlock
code={`import { registryExecuteTool } from '@tpmjs/registry-execute';
// Execute a tool directly
const result = await registryExecuteTool.execute({
toolId: '@tpmjs/hello::helloWorldTool',
params: { name: 'World' },
});
console.log('Result:', result.output);`}
language="typescript"
showCopy={true}
/>
</CardContent>
</Card>
{/* Environment Variables */}
<Card>
<CardHeader>
<CardTitle>Environment Variables</CardTitle>
<CardDescription>Configure SDK behavior</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-foreground-secondary">
The SDK supports the following environment variables:
</p>
<div className="overflow-x-auto border border-border rounded-lg">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-surface">
<th className="text-left py-3 px-4">Variable</th>
<th className="text-left py-3 px-4">Default</th>
<th className="text-left py-3 px-4">Description</th>
</tr>
</thead>
<tbody>
<tr className="border-b border-border">
<td className="py-3 px-4 font-mono text-primary">TPMJS_API_URL</td>
<td className="py-3 px-4 font-mono text-foreground-secondary">
https://tpmjs.com
</td>
<td className="py-3 px-4 text-foreground-secondary">
Base URL for the TPMJS registry API
</td>
</tr>
<tr>
<td className="py-3 px-4 font-mono text-primary">TPMJS_EXECUTOR_URL</td>
<td className="py-3 px-4 font-mono text-foreground-secondary">
https://executor.tpmjs.com
</td>
<td className="py-3 px-4 text-foreground-secondary">
URL for the sandbox executor service
</td>
</tr>
</tbody>
</table>
</div>
<h4 className="font-semibold">Self-Hosting</h4>
<p className="text-foreground-secondary text-sm">
If you&apos;re running your own TPMJS registry, set these environment variables:
</p>
<CodeBlock
code={`# .env
TPMJS_API_URL=https://registry.mycompany.com
TPMJS_EXECUTOR_URL=https://executor.mycompany.com`}
language="bash"
showCopy={true}
/>
</CardContent>
</Card>
{/* TypeScript Types */}
<Card>
<CardHeader>
<CardTitle>TypeScript Types</CardTitle>
<CardDescription>Type definitions exported by the SDK</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<h4 className="font-semibold">@tpmjs/registry-search</h4>
<CodeBlock
code={`import type {
SearchToolParams,
SearchToolResult,
ToolInfo,
} from '@tpmjs/registry-search';
// SearchToolParams
interface SearchToolParams {
query: string;
category?: string;
limit?: number;
}
// SearchToolResult
interface SearchToolResult {
query: string;
matchCount: number;
tools: ToolInfo[];
}
// ToolInfo
interface ToolInfo {
toolId: string;
name: string;
package: string;
description: string;
category: string;
requiredEnvVars: string[];
healthStatus: 'HEALTHY' | 'UNHEALTHY' | 'UNKNOWN';
qualityScore: number;
}`}
language="typescript"
showCopy={true}
/>
<h4 className="font-semibold">@tpmjs/registry-execute</h4>
<CodeBlock
code={`import type {
ExecuteToolParams,
ExecuteToolResult,
} from '@tpmjs/registry-execute';
// ExecuteToolParams
interface ExecuteToolParams {
toolId: string;
params: Record<string, unknown>;
env?: Record<string, string>;
}
// ExecuteToolResult
interface ExecuteToolResult {
toolId: string;
executionTimeMs: number;
output: unknown;
}`}
language="typescript"
showCopy={true}
/>
</CardContent>
</Card>
{/* Error Handling */}
<Card>
<CardHeader>
<CardTitle>Error Handling</CardTitle>
<CardDescription>Handle errors from the SDK</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-foreground-secondary">
Both SDK tools throw errors that can be caught and handled:
</p>
<CodeBlock
code={`import { registryExecuteTool } from '@tpmjs/registry-execute';
try {
const result = await registryExecuteTool.execute({
toolId: '@firecrawl/ai-sdk::scrapeTool',
params: { url: 'https://example.com' },
env: { FIRECRAWL_API_KEY: process.env.FIRECRAWL_API_KEY },
});
console.log('Success:', result);
} catch (error) {
if (error instanceof Error) {
console.error('Tool execution failed:', error.message);
// Common error types:
// - "Tool not found: ..." - Invalid toolId
// - "Missing required env var: ..." - API key not provided
// - "Execution timeout" - Tool took too long
// - "Sandbox error: ..." - Tool runtime error
}
}`}
language="typescript"
showCopy={true}
/>
</CardContent>
</Card>
{/* Complete Example */}
<Card>
<CardHeader>
<CardTitle>Complete Example</CardTitle>
<CardDescription>Full agent implementation with both tools</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<CodeBlock
code={`import { streamText, tool } from 'ai';
import { anthropic } from '@ai-sdk/anthropic';
import { registrySearchTool } from '@tpmjs/registry-search';
import { registryExecuteTool } from '@tpmjs/registry-execute';
// Pre-configure API keys
const API_KEYS: Record<string, string> = {
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}
/>
</CardContent>
</Card>
{/* Related Resources */}
<Card>
<CardHeader>
<CardTitle>Related Resources</CardTitle>
</CardHeader>
<CardContent>
<ul className="space-y-3">
<li>
<Link href="/docs/quickstart" className="text-primary hover:underline font-medium">
Quickstart Guide
</Link>
<p className="text-foreground-secondary text-sm">Get started in 5 minutes</p>
</li>
<li>
<Link href="/docs/api/tools" className="text-primary hover:underline font-medium">
Tools API Reference
</Link>
<p className="text-foreground-secondary text-sm">REST API for the registry</p>
</li>
<li>
<Link
href="/docs/api/authentication"
className="text-primary hover:underline font-medium"
>
Authentication
</Link>
<p className="text-foreground-secondary text-sm">API key management</p>
</li>
<li>
<a
href="https://github.com/tpmjs/tpmjs"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline font-medium"
>
GitHub Repository
</a>
<p className="text-foreground-secondary text-sm">Source code and issues</p>
</li>
</ul>
</CardContent>
</Card>
</div>
);
}

View file

@ -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
</p>
)}
</div>
<div className="flex flex-col items-end gap-2">
{pkg.isOfficial && (
<Badge variant="default" size="lg">
Official
</Badge>
)}
<div className="flex items-center gap-3">
<LikeButton
entityType="tool"
entityId={tool.id}
initialCount={tool.likeCount ?? 0}
showCount={true}
size="sm"
/>
<Rating
toolId={tool.id}
initialAverageRating={tool.averageRating ? Number(tool.averageRating) : null}
initialRatingCount={tool.ratingCount ?? 0}
size="md"
showAverage={true}
showCount={true}
/>
</div>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Badge variant="secondary">{pkg.category}</Badge>

View file

@ -78,6 +78,10 @@ async function getTool(slug: string[]): Promise<Tool | null> {
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: {

View file

@ -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<number | null>(initialRating);
const [averageRating, setAverageRating] = useState<number | null>(initialAverageRating);
const [ratingCount, setRatingCount] = useState(initialRatingCount);
const [hoverRating, setHoverRating] = useState<number | null>(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 (
<div className="relative inline-flex flex-col gap-1">
<div className="flex items-center gap-1">
{[1, 2, 3, 4, 5].map((star) => {
const isFilled = star <= displayRating;
const isHalfFilled = !isFilled && star - 0.5 <= displayRating;
return (
<button
key={star}
type="button"
disabled={isLoading || !interactive}
onClick={() => handleRate(star)}
onMouseEnter={() => interactive && setHoverRating(star)}
onMouseLeave={() => interactive && setHoverRating(null)}
className={`
${interactive ? 'cursor-pointer hover:scale-110' : 'cursor-default'}
transition-transform duration-100
${isLoading ? 'opacity-50' : ''}
focus:outline-none focus:ring-2 focus:ring-primary/50 rounded
`}
aria-label={`Rate ${star} stars`}
>
<div className={`${starSize} relative`}>
{isFilled ? (
<Icon
icon="starFilled"
className={`${starSize} ${
userRating && star <= userRating ? 'text-yellow-400' : 'text-yellow-400/70'
}`}
/>
) : isHalfFilled ? (
<div className="relative">
<Icon icon="star" className={`${starSize} text-foreground-tertiary`} />
<div className="absolute inset-0 overflow-hidden w-1/2">
<Icon icon="starFilled" className={`${starSize} text-yellow-400/70`} />
</div>
</div>
) : (
<Icon icon="star" className={`${starSize} text-foreground-tertiary`} />
)}
</div>
</button>
);
})}
</div>
{(showAverage || showCount) && (
<div className="flex items-center gap-2 text-sm text-foreground-secondary">
{showAverage && averageRating !== null && (
<span className="font-medium">{averageRating.toFixed(1)}</span>
)}
{showCount && ratingCount > 0 && (
<span className="text-foreground-tertiary">
({ratingCount} {ratingCount === 1 ? 'rating' : 'ratings'})
</span>
)}
</div>
)}
{showTooltip && (
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-3 py-1.5 bg-surface-secondary border border-border rounded-lg shadow-lg text-xs text-foreground whitespace-nowrap z-50 animate-in fade-in slide-in-from-bottom-1 duration-200">
<a href="/sign-in" className="text-primary hover:underline">
Sign in
</a>{' '}
to rate
<div className="absolute top-full left-1/2 -translate-x-1/2 -mt-px">
<div className="border-4 border-transparent border-t-border" />
</div>
</div>
)}
</div>
);
}
/**
* 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 (
<div className={`inline-flex items-center gap-1 ${className}`}>
<div className="flex items-center gap-0.5">
{[1, 2, 3, 4, 5].map((star) => {
const isFilled = star <= rating;
const isHalfFilled = !isFilled && star - 0.5 <= rating;
return (
<div key={star} className={`${starSize} relative`}>
{isFilled ? (
<Icon icon="starFilled" className={`${starSize} text-yellow-400`} />
) : isHalfFilled ? (
<div className="relative">
<Icon icon="star" className={`${starSize} text-foreground-tertiary`} />
<div className="absolute inset-0 overflow-hidden w-1/2">
<Icon icon="starFilled" className={`${starSize} text-yellow-400`} />
</div>
</div>
) : (
<Icon icon="star" className={`${starSize} text-foreground-tertiary`} />
)}
</div>
);
})}
</div>
{averageRating !== null && (
<span className="text-sm text-foreground-secondary font-medium ml-1">
{averageRating.toFixed(1)}
</span>
)}
{showCount && ratingCount > 0 && (
<span className="text-sm text-foreground-tertiary">({ratingCount})</span>
)}
</div>
);
}

View file

@ -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 (
<Image
src={user.image}
alt={user.name}
width={40}
height={40}
className="h-10 w-10 rounded-full object-cover"
/>
);
}
return (
<div className="h-10 w-10 rounded-full bg-primary/10 text-primary flex items-center justify-center font-medium text-sm">
{initials}
</div>
);
}
export function ReviewCard({ review, className = '' }: ReviewCardProps): React.ReactElement {
const userLink = review.user.username ? `/@${review.user.username}` : null;
return (
<Card className={className}>
<CardContent className="pt-4">
{/* Header */}
<div className="flex items-start gap-3 mb-3">
{/* Avatar */}
{userLink ? (
<Link href={userLink} className="flex-shrink-0">
<UserAvatar user={review.user} />
</Link>
) : (
<div className="flex-shrink-0">
<UserAvatar user={review.user} />
</div>
)}
{/* User info and rating */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
{userLink ? (
<Link
href={userLink}
className="font-medium text-foreground hover:text-primary truncate"
>
{review.user.name}
</Link>
) : (
<span className="font-medium text-foreground truncate">{review.user.name}</span>
)}
<span className="text-foreground-tertiary text-sm">
{formatDate(review.createdAt)}
</span>
</div>
<RatingDisplay
averageRating={review.rating}
showCount={false}
size="sm"
className="mt-0.5"
/>
</div>
</div>
{/* Title */}
{review.title && <h4 className="font-semibold text-foreground mb-2">{review.title}</h4>}
{/* Content */}
<p className="text-foreground-secondary text-sm whitespace-pre-wrap">{review.content}</p>
{/* Helpful count */}
{review.helpfulCount > 0 && (
<div className="mt-3 flex items-center gap-1 text-sm text-foreground-tertiary">
<Icon icon="heart" size="xs" />
<span>
{review.helpfulCount} {review.helpfulCount === 1 ? 'person' : 'people'} found this
helpful
</span>
</div>
)}
</CardContent>
</Card>
);
}
/**
* 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 (
<div className={`text-center py-8 ${className}`}>
<Icon icon="message" size="lg" className="text-foreground-tertiary mx-auto mb-2" />
<p className="text-foreground-secondary">No reviews yet</p>
<p className="text-sm text-foreground-tertiary mt-1">
Be the first to share your experience with this tool
</p>
</div>
);
}
return (
<div className={`space-y-4 ${className}`}>
{initialReviews.map((review) => (
<ReviewCard key={review.id} review={review} />
))}
</div>
);
}

View file

@ -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
// ============================================================================

View file

@ -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;