feat: add /tool-ideas page with 10K AI-generated tools

- Add virtualized list using react-virtuoso for smooth scrolling
- Filter by category, verb, quality score, and search
- Expandable cards with parameters, returns, AI guidance
- Add redirect from /tools-ideas to /tool-ideas

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Ajax Davis 2026-01-01 17:09:51 +10:00
parent 1560f38f85
commit d00ab8afbb
7 changed files with 847125 additions and 249 deletions

844696
apps/web/data/tools-export.json Normal file

File diff suppressed because it is too large Load diff

View file

@ -4,6 +4,15 @@ const nextConfig: NextConfig = {
transpilePackages: ['@tpmjs/ui', '@tpmjs/utils', '@tpmjs/db', '@tpmjs/types', '@tpmjs/env'],
reactStrictMode: true,
serverExternalPackages: ['@tpmjs/package-executor'],
async redirects() {
return [
{
source: '/tools-ideas',
destination: '/tool-ideas',
permanent: true,
},
];
},
};
export default nextConfig;

View file

@ -37,6 +37,7 @@
"react-dom": "^19.0.0",
"react-markdown": "^10.1.0",
"react-syntax-highlighter": "^16.1.0",
"react-virtuoso": "^4.18.1",
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
"remark-gfm": "^4.0.1",

View file

@ -0,0 +1,128 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { type NextRequest, NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
export const maxDuration = 60;
interface ToolIdea {
name: string;
description: string;
category: string;
parameters: Array<{
name: string;
type: string;
description: string;
required: boolean;
defaultValue: string;
}>;
returns: {
type: string;
description: string;
};
aiAgent: {
useCase: string;
limitations: string;
examples: string[];
};
tags: string[];
examples: Array<{
inputJson: string;
description: string;
}>;
qualityScore: number;
skeleton: {
verb: string;
object: string;
context: string | null;
};
}
interface ToolIdeasData {
metadata: {
exportedAt: string;
count: number;
minQuality: number;
excludeNonsensical: boolean;
};
tools: ToolIdea[];
}
// Cache the data in memory
let cachedData: ToolIdeasData | null = null;
function loadToolIdeas(): ToolIdeasData {
if (cachedData) {
return cachedData;
}
try {
const filePath = join(process.cwd(), 'data', 'tools-export.json');
const fileContent = readFileSync(filePath, 'utf-8');
cachedData = JSON.parse(fileContent) as ToolIdeasData;
return cachedData;
} catch (error) {
console.error('Failed to load tool ideas:', error);
return {
metadata: { exportedAt: '', count: 0, minQuality: 0, excludeNonsensical: false },
tools: [],
};
}
}
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
const search = searchParams.get('search')?.toLowerCase() || '';
const category = searchParams.get('category') || '';
const minQuality = Number.parseFloat(searchParams.get('minQuality') || '0');
const verb = searchParams.get('verb') || '';
const limit = Math.min(Number.parseInt(searchParams.get('limit') || '100'), 1000);
const offset = Number.parseInt(searchParams.get('offset') || '0');
const data = loadToolIdeas();
let tools = data.tools;
// Apply filters
if (search) {
tools = tools.filter(
(t) =>
t.name.toLowerCase().includes(search) ||
t.description.toLowerCase().includes(search) ||
t.tags.some((tag) => tag.toLowerCase().includes(search))
);
}
if (category) {
tools = tools.filter((t) => t.category === category);
}
if (minQuality > 0) {
tools = tools.filter((t) => t.qualityScore >= minQuality);
}
if (verb) {
tools = tools.filter((t) => t.skeleton.verb === verb);
}
const totalCount = tools.length;
// Apply pagination
const paginatedTools = tools.slice(offset, offset + limit);
// Get unique categories and verbs for filters
const categories = [...new Set(data.tools.map((t) => t.category))].sort();
const verbs = [...new Set(data.tools.map((t) => t.skeleton.verb))].sort();
return NextResponse.json({
success: true,
data: paginatedTools,
meta: {
total: totalCount,
limit,
offset,
hasMore: offset + limit < totalCount,
categories,
verbs,
},
});
}

View file

@ -0,0 +1,322 @@
'use client';
import { Badge } from '@tpmjs/ui/Badge/Badge';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@tpmjs/ui/Card/Card';
import { Input } from '@tpmjs/ui/Input/Input';
import { ProgressBar } from '@tpmjs/ui/ProgressBar/ProgressBar';
import { Select } from '@tpmjs/ui/Select/Select';
import { Spinner } from '@tpmjs/ui/Spinner/Spinner';
import { useCallback, useEffect, useState } from 'react';
import { Virtuoso } from 'react-virtuoso';
interface ToolIdea {
name: string;
description: string;
category: string;
parameters: Array<{
name: string;
type: string;
description: string;
required: boolean;
defaultValue: string;
}>;
returns: {
type: string;
description: string;
};
aiAgent: {
useCase: string;
limitations: string;
examples: string[];
};
tags: string[];
qualityScore: number;
skeleton: {
verb: string;
object: string;
context: string | null;
};
}
interface ApiResponse {
success: boolean;
data: ToolIdea[];
meta: {
total: number;
limit: number;
offset: number;
hasMore: boolean;
categories: string[];
verbs: string[];
};
}
export function ToolIdeasClient() {
const [tools, setTools] = useState<ToolIdea[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
const [category, setCategory] = useState('');
const [verb, setVerb] = useState('');
const [minQuality, setMinQuality] = useState('0');
const [categories, setCategories] = useState<string[]>([]);
const [verbs, setVerbs] = useState<string[]>([]);
const [total, setTotal] = useState(0);
const [expandedId, setExpandedId] = useState<string | null>(null);
const fetchTools = useCallback(async () => {
setLoading(true);
try {
const params = new URLSearchParams();
if (search) params.set('search', search);
if (category) params.set('category', category);
if (verb) params.set('verb', verb);
if (minQuality !== '0') params.set('minQuality', minQuality);
params.set('limit', '10000'); // Load all for client-side virtualization
const response = await fetch(`/api/tool-ideas?${params}`);
const data: ApiResponse = await response.json();
if (data.success) {
setTools(data.data);
setTotal(data.meta.total);
if (categories.length === 0) {
setCategories(data.meta.categories);
}
if (verbs.length === 0) {
setVerbs(data.meta.verbs);
}
}
} catch (error) {
console.error('Failed to fetch tools:', error);
} finally {
setLoading(false);
}
}, [search, category, verb, minQuality, categories.length, verbs.length]);
useEffect(() => {
const debounce = setTimeout(fetchTools, 300);
return () => clearTimeout(debounce);
}, [fetchTools]);
const qualityColor = (score: number) => {
if (score >= 0.9) return 'text-green-600';
if (score >= 0.7) return 'text-yellow-600';
return 'text-red-600';
};
const ToolCard = ({ tool }: { tool: ToolIdea }) => {
const isExpanded = expandedId === tool.name;
return (
<Card
className="mb-3 cursor-pointer hover:border-primary/50 transition-colors"
onClick={() => setExpandedId(isExpanded ? null : tool.name)}
>
<CardHeader className="pb-2">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<CardTitle className="text-base font-mono truncate">{tool.name}</CardTitle>
<div className="flex flex-wrap gap-1.5 mt-1.5">
<Badge variant="secondary" size="sm">
{tool.category}
</Badge>
<Badge variant="outline" size="sm">
{tool.skeleton.verb}
</Badge>
<Badge variant="outline" size="sm">
{tool.skeleton.object}
</Badge>
</div>
</div>
<div className="flex flex-col items-end gap-1">
<span className={`text-sm font-semibold ${qualityColor(tool.qualityScore)}`}>
{(tool.qualityScore * 100).toFixed(0)}%
</span>
<ProgressBar value={tool.qualityScore * 100} size="sm" className="w-16" />
</div>
</div>
</CardHeader>
<CardContent className="pt-0">
<CardDescription className={isExpanded ? '' : 'line-clamp-2'}>
{tool.description}
</CardDescription>
{isExpanded && (
<div className="mt-4 space-y-4">
{/* Parameters */}
<div>
<h4 className="text-sm font-semibold text-foreground mb-2">
Parameters ({tool.parameters.length})
</h4>
<div className="space-y-1.5">
{tool.parameters.map((param) => (
<div
key={param.name}
className="text-xs bg-surface rounded px-2 py-1.5 flex items-start gap-2"
>
<code className="font-mono text-primary">{param.name}</code>
<Badge variant="outline" size="sm">
{param.type}
</Badge>
{param.required && (
<Badge variant="error" size="sm">
required
</Badge>
)}
<span className="text-foreground-tertiary flex-1">{param.description}</span>
</div>
))}
</div>
</div>
{/* Returns */}
<div>
<h4 className="text-sm font-semibold text-foreground mb-2">Returns</h4>
<div className="text-xs bg-surface rounded px-2 py-1.5">
<code className="font-mono text-primary">{tool.returns.type}</code>
<span className="text-foreground-tertiary ml-2">{tool.returns.description}</span>
</div>
</div>
{/* AI Agent Guidance */}
<div>
<h4 className="text-sm font-semibold text-foreground mb-2">AI Agent Guidance</h4>
<p className="text-xs text-foreground-secondary">{tool.aiAgent.useCase}</p>
{tool.aiAgent.limitations && (
<p className="text-xs text-foreground-tertiary mt-1">
<span className="font-medium">Limitations:</span> {tool.aiAgent.limitations}
</p>
)}
</div>
{/* Tags */}
<div className="flex flex-wrap gap-1">
{tool.tags.map((tag) => (
<Badge key={tag} variant="secondary" size="sm">
{tag}
</Badge>
))}
</div>
</div>
)}
</CardContent>
</Card>
);
};
// Build options for Select components
const categoryOptions = [
{ value: '', label: 'All categories' },
...categories.map((cat) => ({ value: cat, label: cat })),
];
const verbOptions = [
{ value: '', label: 'All verbs' },
...verbs.map((v) => ({ value: v, label: v })),
];
const qualityOptions = [
{ value: '0', label: 'Any quality' },
{ value: '0.9', label: '90%+ (Excellent)' },
{ value: '0.8', label: '80%+ (Great)' },
{ value: '0.7', label: '70%+ (Good)' },
];
return (
<div className="space-y-4">
{/* Filters */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3 bg-surface p-4 rounded-lg border border-border">
<div>
<label
htmlFor="tool-search"
className="text-xs font-medium text-foreground-secondary mb-1.5 block"
>
Search
</label>
<Input
id="tool-search"
type="text"
placeholder="Search tools..."
value={search}
onChange={(e) => setSearch(e.target.value)}
size="sm"
/>
</div>
<div>
<label
htmlFor="tool-category"
className="text-xs font-medium text-foreground-secondary mb-1.5 block"
>
Category
</label>
<Select
id="tool-category"
value={category}
onChange={(e) => setCategory(e.target.value)}
options={categoryOptions}
size="sm"
/>
</div>
<div>
<label
htmlFor="tool-verb"
className="text-xs font-medium text-foreground-secondary mb-1.5 block"
>
Verb
</label>
<Select
id="tool-verb"
value={verb}
onChange={(e) => setVerb(e.target.value)}
options={verbOptions}
size="sm"
/>
</div>
<div>
<label
htmlFor="tool-quality"
className="text-xs font-medium text-foreground-secondary mb-1.5 block"
>
Min Quality
</label>
<Select
id="tool-quality"
value={minQuality}
onChange={(e) => setMinQuality(e.target.value)}
options={qualityOptions}
size="sm"
/>
</div>
</div>
{/* Results count */}
<div className="flex items-center justify-between text-sm text-foreground-secondary">
<span>{loading ? 'Loading...' : `${total.toLocaleString()} tools`}</span>
{!loading && total > 0 && <span className="text-xs">Click a tool to expand details</span>}
</div>
{/* Virtualized list */}
{loading ? (
<div className="flex items-center justify-center py-20">
<Spinner size="lg" />
</div>
) : tools.length === 0 ? (
<div className="text-center py-20 text-foreground-secondary">
No tools found matching your filters.
</div>
) : (
<div className="h-[calc(100vh-320px)] min-h-[400px]">
<Virtuoso
data={tools}
itemContent={(_, tool) => <ToolCard tool={tool} />}
overscan={200}
style={{ height: '100%' }}
/>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,27 @@
import { Container } from '@tpmjs/ui/Container/Container';
import type { Metadata } from 'next';
import { ToolIdeasClient } from './ToolIdeasClient';
export const metadata: Metadata = {
title: 'Tool Ideas | TPMJS',
description: 'Browse 10,000 AI-generated tool ideas for agents',
};
export const dynamic = 'force-dynamic';
export default function ToolIdeasPage() {
return (
<main className="min-h-screen bg-background py-8">
<Container size="xl">
<div className="mb-8">
<h1 className="text-3xl font-bold text-foreground mb-2">Tool Ideas</h1>
<p className="text-foreground-secondary">
Browse 10,000 AI-generated tool ideas for agents. Filter by category, quality score, or
search.
</p>
</div>
<ToolIdeasClient />
</Container>
</main>
);
}

2191
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff