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:
parent
d22d0e6f59
commit
b1dd3371cd
15 changed files with 3736 additions and 5 deletions
341
apps/web/src/app/api/tools/[id]/rate/route.ts
Normal file
341
apps/web/src/app/api/tools/[id]/rate/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
469
apps/web/src/app/api/tools/[id]/reviews/route.ts
Normal file
469
apps/web/src/app/api/tools/[id]/reviews/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
181
apps/web/src/app/api/tools/trending/route.ts
Normal file
181
apps/web/src/app/api/tools/trending/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
204
apps/web/src/app/docs/api/agents/page.tsx
Normal file
204
apps/web/src/app/docs/api/agents/page.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
316
apps/web/src/app/docs/api/authentication/page.tsx
Normal file
316
apps/web/src/app/docs/api/authentication/page.tsx
Normal 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'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 "Generate New Key"</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'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'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>
|
||||
);
|
||||
}
|
||||
359
apps/web/src/app/docs/api/collections/page.tsx
Normal file
359
apps/web/src/app/docs/api/collections/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
220
apps/web/src/app/docs/api/tools/page.tsx
Normal file
220
apps/web/src/app/docs/api/tools/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
417
apps/web/src/app/docs/quickstart/page.tsx
Normal file
417
apps/web/src/app/docs/quickstart/page.tsx
Normal 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 sandbox—no
|
||||
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 "missing API key"
|
||||
</h4>
|
||||
<p className="text-foreground-secondary text-sm mt-1">
|
||||
Check that you'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>
|
||||
);
|
||||
}
|
||||
651
apps/web/src/app/docs/sdk/page.tsx
Normal file
651
apps/web/src/app/docs/sdk/page.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
{pkg.isOfficial && (
|
||||
<Badge variant="default" size="lg">
|
||||
Official
|
||||
</Badge>
|
||||
)}
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
275
apps/web/src/components/Rating.tsx
Normal file
275
apps/web/src/components/Rating.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
190
apps/web/src/components/ReviewCard.tsx
Normal file
190
apps/web/src/components/ReviewCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
// ============================================================================
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue