feat: replace hardcoded stats with real DB data and add view tracking
- Add PageView model for daily-bucketed view tracking - Add viewCount fields to Tool, Collection, Agent models - Add social proof fields to StatsSnapshot - Add POST /api/track/view endpoint with IP-based dedup - Add GET /api/activity/public endpoint for real activity stream - Add /api/sync/view-rollup daily cron for aggregating views - Expand stats-snapshot cron with new social proof queries - Replace hardcoded homepage stats with real DB-driven props - Add downloads to hero metrics strip - Add PublicActivityStream component fetching real UserActivity - Add useTrackView hook for tool, collection, agent detail pages - Add forkCount column to collections and agents listings - Add views/reviews to tool detail statistics sidebar - Remove deprecated hardcoded statistics and categories from homePageData
This commit is contained in:
parent
66fb7ef226
commit
4fa8344b34
24 changed files with 690 additions and 271 deletions
|
|
@ -11,6 +11,7 @@ import { AppHeader } from '~/components/AppHeader';
|
||||||
import { ForkButton } from '~/components/ForkButton';
|
import { ForkButton } from '~/components/ForkButton';
|
||||||
import { ForkedFromBadge } from '~/components/ForkedFromBadge';
|
import { ForkedFromBadge } from '~/components/ForkedFromBadge';
|
||||||
import { LikeButton } from '~/components/LikeButton';
|
import { LikeButton } from '~/components/LikeButton';
|
||||||
|
import { useTrackView } from '~/hooks/useTrackView';
|
||||||
import { useSession } from '~/lib/auth-client';
|
import { useSession } from '~/lib/auth-client';
|
||||||
|
|
||||||
interface AgentTool {
|
interface AgentTool {
|
||||||
|
|
@ -183,6 +184,7 @@ const response = await fetch(\`${apiUrl}/\${conversation.id}\`, {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: large detail page with many conditional sections
|
||||||
export default function PrettyAgentDetailPage(): React.ReactElement {
|
export default function PrettyAgentDetailPage(): React.ReactElement {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const rawUsername = params.username as string;
|
const rawUsername = params.username as string;
|
||||||
|
|
@ -194,6 +196,9 @@ export default function PrettyAgentDetailPage(): React.ReactElement {
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Track page view
|
||||||
|
useTrackView('agent', agent?.id ?? '');
|
||||||
|
|
||||||
// Check if current user is the owner
|
// Check if current user is the owner
|
||||||
const isOwner = session?.user?.id && agent?.createdBy?.id === session.user.id;
|
const isOwner = session?.user?.id && agent?.createdBy?.id === session.user.id;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import { ScenariosSection } from '~/components/ScenariosSection';
|
||||||
import { ShareButton } from '~/components/ShareButton';
|
import { ShareButton } from '~/components/ShareButton';
|
||||||
import { SkillsSection } from '~/components/skills/SkillsSection';
|
import { SkillsSection } from '~/components/skills/SkillsSection';
|
||||||
import { UseCasesSection } from '~/components/UseCasesSection';
|
import { UseCasesSection } from '~/components/UseCasesSection';
|
||||||
|
import { useTrackView } from '~/hooks/useTrackView';
|
||||||
import { useSession } from '~/lib/auth-client';
|
import { useSession } from '~/lib/auth-client';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -313,6 +314,9 @@ export function CollectionDetailClient({
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
const [collection, setCollection] = useState(initialCollection);
|
const [collection, setCollection] = useState(initialCollection);
|
||||||
|
|
||||||
|
// Track page view
|
||||||
|
useTrackView('collection', collection.id);
|
||||||
|
|
||||||
// Check if current user is the owner
|
// Check if current user is the owner
|
||||||
const isOwner = session?.user?.id && collection.createdBy?.id === session.user.id;
|
const isOwner = session?.user?.id && collection.createdBy?.id === session.user.id;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ function truncateText(text: string, maxLength: number): string {
|
||||||
return `${text.slice(0, maxLength).trim()}...`;
|
return `${text.slice(0, maxLength).trim()}...`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: large page component with table rendering
|
||||||
export default function PublicAgentsPage(): React.ReactElement {
|
export default function PublicAgentsPage(): React.ReactElement {
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [sort, setSort] = useState<SortOption>('likes');
|
const [sort, setSort] = useState<SortOption>('likes');
|
||||||
|
|
@ -70,9 +71,10 @@ export default function PublicAgentsPage(): React.ReactElement {
|
||||||
() => (
|
() => (
|
||||||
<tr className="bg-surface-secondary text-left text-xs font-semibold uppercase tracking-wider text-foreground-secondary border-b border-border">
|
<tr className="bg-surface-secondary text-left text-xs font-semibold uppercase tracking-wider text-foreground-secondary border-b border-border">
|
||||||
<th className="px-4 py-3 w-[200px]">Name</th>
|
<th className="px-4 py-3 w-[200px]">Name</th>
|
||||||
<th className="px-4 py-3 w-[250px]">Description</th>
|
<th className="px-4 py-3 w-[200px]">Description</th>
|
||||||
<th className="px-4 py-3 w-[100px]">Provider</th>
|
<th className="px-4 py-3 w-[100px]">Provider</th>
|
||||||
<th className="px-4 py-3 w-[80px] text-center">Tools</th>
|
<th className="px-4 py-3 w-[80px] text-center">Tools</th>
|
||||||
|
<th className="px-4 py-3 w-[70px] text-center">Forks</th>
|
||||||
<th className="px-4 py-3 w-[80px] text-center">Likes</th>
|
<th className="px-4 py-3 w-[80px] text-center">Likes</th>
|
||||||
<th className="px-4 py-3 w-[150px]">Creator</th>
|
<th className="px-4 py-3 w-[150px]">Creator</th>
|
||||||
<th className="px-4 py-3 w-[80px] text-center">Chat</th>
|
<th className="px-4 py-3 w-[80px] text-center">Chat</th>
|
||||||
|
|
@ -106,6 +108,9 @@ export default function PublicAgentsPage(): React.ReactElement {
|
||||||
{agent.toolCount}
|
{agent.toolCount}
|
||||||
</Badge>
|
</Badge>
|
||||||
</td>
|
</td>
|
||||||
|
<td className="px-4 py-3 text-center text-sm text-foreground-secondary">
|
||||||
|
{agent.forkCount > 0 ? agent.forkCount : '—'}
|
||||||
|
</td>
|
||||||
<td className="px-4 py-3 text-center">
|
<td className="px-4 py-3 text-center">
|
||||||
<LikeButton
|
<LikeButton
|
||||||
entityType="agent"
|
entityType="agent"
|
||||||
|
|
|
||||||
87
apps/web/src/app/api/activity/public/route.ts
Normal file
87
apps/web/src/app/api/activity/public/route.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
import { prisma } from '@tpmjs/db';
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
export const maxDuration = 10;
|
||||||
|
|
||||||
|
// Only show these activity types publicly (positive actions, not deletions/unlikes)
|
||||||
|
const PUBLIC_ACTIVITY_TYPES = [
|
||||||
|
'TOOL_LIKED',
|
||||||
|
'COLLECTION_CREATED',
|
||||||
|
'COLLECTION_FORKED',
|
||||||
|
'COLLECTION_TOOL_ADDED',
|
||||||
|
'AGENT_CREATED',
|
||||||
|
'AGENT_FORKED',
|
||||||
|
'AGENT_LIKED',
|
||||||
|
'COLLECTION_LIKED',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/activity/public
|
||||||
|
* Returns recent public activity for the homepage activity stream.
|
||||||
|
* Cached for 30 seconds with stale-while-revalidate.
|
||||||
|
*/
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const activities = await prisma.userActivity.findMany({
|
||||||
|
where: {
|
||||||
|
type: { in: [...PUBLIC_ACTIVITY_TYPES] },
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: 20,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
type: true,
|
||||||
|
targetName: true,
|
||||||
|
targetType: true,
|
||||||
|
createdAt: true,
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
username: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = activities.map((a) => ({
|
||||||
|
id: a.id,
|
||||||
|
type: mapActivityType(a.type),
|
||||||
|
username: a.user.username || a.user.name,
|
||||||
|
targetName: a.targetName,
|
||||||
|
targetType: a.targetType,
|
||||||
|
createdAt: a.createdAt,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: true, data },
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Cache-Control': 's-maxage=30, stale-while-revalidate=60',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[activity/public] Error:', error);
|
||||||
|
return NextResponse.json({ success: true, data: [] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapActivityType(type: string): 'invoked' | 'published' | 'updated' {
|
||||||
|
switch (type) {
|
||||||
|
case 'TOOL_LIKED':
|
||||||
|
case 'COLLECTION_LIKED':
|
||||||
|
case 'AGENT_LIKED':
|
||||||
|
return 'invoked';
|
||||||
|
case 'COLLECTION_CREATED':
|
||||||
|
case 'AGENT_CREATED':
|
||||||
|
return 'published';
|
||||||
|
case 'COLLECTION_FORKED':
|
||||||
|
case 'AGENT_FORKED':
|
||||||
|
case 'COLLECTION_TOOL_ADDED':
|
||||||
|
return 'updated';
|
||||||
|
default:
|
||||||
|
return 'updated';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -86,6 +86,7 @@ export async function GET(request: NextRequest): Promise<NextResponse<ApiRespons
|
||||||
provider: agent.provider,
|
provider: agent.provider,
|
||||||
modelId: agent.modelId,
|
modelId: agent.modelId,
|
||||||
likeCount: agent.likeCount,
|
likeCount: agent.likeCount,
|
||||||
|
forkCount: agent.forkCount,
|
||||||
toolCount: agent._count.tools,
|
toolCount: agent._count.tools,
|
||||||
collectionCount: agent._count.collections,
|
collectionCount: agent._count.collections,
|
||||||
createdAt: agent.createdAt,
|
createdAt: agent.createdAt,
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,7 @@ export async function GET(request: NextRequest): Promise<NextResponse<ApiRespons
|
||||||
name: collection.name,
|
name: collection.name,
|
||||||
description: collection.description,
|
description: collection.description,
|
||||||
likeCount: collection.likeCount,
|
likeCount: collection.likeCount,
|
||||||
|
forkCount: collection.forkCount,
|
||||||
toolCount: collection._count.tools,
|
toolCount: collection._count.tools,
|
||||||
createdAt: collection.createdAt,
|
createdAt: collection.createdAt,
|
||||||
createdBy: collection.user,
|
createdBy: collection.user,
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ export const maxDuration = 60;
|
||||||
* Captures a daily snapshot of registry statistics for historical tracking.
|
* Captures a daily snapshot of registry statistics for historical tracking.
|
||||||
* Should be run once per day via cron.
|
* Should be run once per day via cron.
|
||||||
*/
|
*/
|
||||||
|
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: cron handler with many parallel queries
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
|
@ -75,6 +76,12 @@ export async function POST(request: NextRequest) {
|
||||||
// Daily health checks
|
// Daily health checks
|
||||||
dailyHealthChecks,
|
dailyHealthChecks,
|
||||||
|
|
||||||
|
// Social proof
|
||||||
|
activeDevsResult,
|
||||||
|
publicCollectionsCount,
|
||||||
|
publicAgentsCount,
|
||||||
|
totalSimulationsCount,
|
||||||
|
|
||||||
// Quality distribution
|
// Quality distribution
|
||||||
qualityDistribution,
|
qualityDistribution,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
|
|
@ -142,6 +149,17 @@ export async function POST(request: NextRequest) {
|
||||||
where: { createdAt: { gte: yesterday, lt: today } },
|
where: { createdAt: { gte: yesterday, lt: today } },
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// Social proof fields
|
||||||
|
prisma.userActivity.groupBy({
|
||||||
|
by: ['userId'],
|
||||||
|
where: {
|
||||||
|
createdAt: { gte: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.collection.count({ where: { isPublic: true } }),
|
||||||
|
prisma.agent.count({ where: { isPublic: true } }),
|
||||||
|
prisma.simulation.count(),
|
||||||
|
|
||||||
// Quality score distribution
|
// Quality score distribution
|
||||||
prisma.$queryRaw<{ bucket: string; count: bigint }[]>`
|
prisma.$queryRaw<{ bucket: string; count: bigint }[]>`
|
||||||
SELECT
|
SELECT
|
||||||
|
|
@ -241,6 +259,12 @@ export async function POST(request: NextRequest) {
|
||||||
|
|
||||||
// Categories
|
// Categories
|
||||||
categories,
|
categories,
|
||||||
|
|
||||||
|
// Social proof
|
||||||
|
activeDevs7d: activeDevsResult.length,
|
||||||
|
totalCollections: publicCollectionsCount,
|
||||||
|
totalAgents: publicAgentsCount,
|
||||||
|
totalSimulations: totalSimulationsCount,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
86
apps/web/src/app/api/sync/view-rollup/route.ts
Normal file
86
apps/web/src/app/api/sync/view-rollup/route.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
import { prisma } from '@tpmjs/db';
|
||||||
|
import { type NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
export const maxDuration = 300;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/sync/view-rollup
|
||||||
|
* Daily cron: aggregates PageView counts into denormalized viewCount fields
|
||||||
|
* on Tool, Collection, and Agent models.
|
||||||
|
*/
|
||||||
|
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: cron handler with sequential entity type processing
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
// Verify cron secret
|
||||||
|
const authHeader = request.headers.get('authorization');
|
||||||
|
const cronSecret = process.env.CRON_SECRET;
|
||||||
|
|
||||||
|
if (cronSecret && authHeader !== `Bearer ${cronSecret}`) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Aggregate views by entity type and entity ID (all-time sum)
|
||||||
|
const entityTypes = ['tool', 'collection', 'agent'] as const;
|
||||||
|
let totalUpdated = 0;
|
||||||
|
|
||||||
|
for (const entityType of entityTypes) {
|
||||||
|
// Get aggregated view counts per entity
|
||||||
|
const viewCounts = await prisma.pageView.groupBy({
|
||||||
|
by: ['entityId'],
|
||||||
|
where: { entityType },
|
||||||
|
_sum: { viewCount: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update denormalized viewCount on each entity
|
||||||
|
for (const vc of viewCounts) {
|
||||||
|
const totalViews = vc._sum.viewCount || 0;
|
||||||
|
if (totalViews === 0) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (entityType === 'tool') {
|
||||||
|
await prisma.tool.update({
|
||||||
|
where: { id: vc.entityId },
|
||||||
|
data: { viewCount: totalViews },
|
||||||
|
});
|
||||||
|
} else if (entityType === 'collection') {
|
||||||
|
await prisma.collection.update({
|
||||||
|
where: { id: vc.entityId },
|
||||||
|
data: { viewCount: totalViews },
|
||||||
|
});
|
||||||
|
} else if (entityType === 'agent') {
|
||||||
|
await prisma.agent.update({
|
||||||
|
where: { id: vc.entityId },
|
||||||
|
data: { viewCount: totalViews },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
totalUpdated++;
|
||||||
|
} catch {
|
||||||
|
// Entity may have been deleted - skip silently
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const durationMs = Date.now() - startTime;
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
totalUpdated,
|
||||||
|
durationMs,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[sync/view-rollup] Error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
87
apps/web/src/app/api/track/view/route.ts
Normal file
87
apps/web/src/app/api/track/view/route.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
import { prisma } from '@tpmjs/db';
|
||||||
|
import { type NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getClientId } from '~/lib/rate-limit';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
export const maxDuration = 10;
|
||||||
|
|
||||||
|
const VALID_ENTITY_TYPES = ['tool', 'collection', 'agent'] as const;
|
||||||
|
|
||||||
|
// Simple in-memory dedup: 1 view per entity per IP per hour
|
||||||
|
const recentViews = new Map<string, number>();
|
||||||
|
|
||||||
|
// Clean up every 10 minutes
|
||||||
|
setInterval(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [key, timestamp] of recentViews) {
|
||||||
|
if (now - timestamp > 3600_000) {
|
||||||
|
recentViews.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Prevent unbounded growth
|
||||||
|
if (recentViews.size > 50_000) {
|
||||||
|
recentViews.clear();
|
||||||
|
}
|
||||||
|
}, 600_000);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/track/view
|
||||||
|
* Fire-and-forget view tracking. Upserts into PageView with daily bucket.
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const { entityType, entityId } = body;
|
||||||
|
|
||||||
|
// Validate input
|
||||||
|
if (!entityType || !entityId) {
|
||||||
|
return NextResponse.json({ error: 'Missing entityType or entityId' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!VALID_ENTITY_TYPES.includes(entityType)) {
|
||||||
|
return NextResponse.json({ error: 'Invalid entityType' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rate-limit: 1 view per entity per IP per hour
|
||||||
|
const clientId = getClientId(request);
|
||||||
|
const dedupKey = `${clientId}:${entityType}:${entityId}`;
|
||||||
|
const lastView = recentViews.get(dedupKey);
|
||||||
|
|
||||||
|
if (lastView && Date.now() - lastView < 3600_000) {
|
||||||
|
return NextResponse.json({ ok: true, deduped: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
recentViews.set(dedupKey, Date.now());
|
||||||
|
|
||||||
|
// Today's date bucket (midnight UTC)
|
||||||
|
const today = new Date();
|
||||||
|
today.setUTCHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
// Upsert page view (fire-and-forget style, don't await in production but we need to for correctness)
|
||||||
|
await prisma.pageView.upsert({
|
||||||
|
where: {
|
||||||
|
entityType_entityId_date: {
|
||||||
|
entityType,
|
||||||
|
entityId,
|
||||||
|
date: today,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
entityType,
|
||||||
|
entityId,
|
||||||
|
date: today,
|
||||||
|
viewCount: 1,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
viewCount: { increment: 1 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch (error) {
|
||||||
|
// Silently fail - view tracking should never break the user experience
|
||||||
|
console.error('[track/view] Error:', error);
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -21,6 +21,7 @@ interface PublicCollection {
|
||||||
name: string;
|
name: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
likeCount: number;
|
likeCount: number;
|
||||||
|
forkCount: number;
|
||||||
toolCount: number;
|
toolCount: number;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
createdBy: {
|
createdBy: {
|
||||||
|
|
@ -53,6 +54,7 @@ function truncateText(text: string, maxLength: number): string {
|
||||||
return `${text.slice(0, maxLength).trim()}...`;
|
return `${text.slice(0, maxLength).trim()}...`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: large page component with table rendering
|
||||||
export default function PublicCollectionsPage(): React.ReactElement {
|
export default function PublicCollectionsPage(): React.ReactElement {
|
||||||
const [collections, setCollections] = useState<PublicCollection[]>([]);
|
const [collections, setCollections] = useState<PublicCollection[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
@ -126,8 +128,9 @@ export default function PublicCollectionsPage(): React.ReactElement {
|
||||||
() => (
|
() => (
|
||||||
<tr className="bg-surface-secondary text-left text-xs font-semibold uppercase tracking-wider text-foreground-secondary border-b border-border">
|
<tr className="bg-surface-secondary text-left text-xs font-semibold uppercase tracking-wider text-foreground-secondary border-b border-border">
|
||||||
<th className="px-4 py-3 w-[250px]">Name</th>
|
<th className="px-4 py-3 w-[250px]">Name</th>
|
||||||
<th className="px-4 py-3 w-[300px]">Description</th>
|
<th className="px-4 py-3 w-[250px]">Description</th>
|
||||||
<th className="px-4 py-3 w-[80px] text-center">Tools</th>
|
<th className="px-4 py-3 w-[80px] text-center">Tools</th>
|
||||||
|
<th className="px-4 py-3 w-[70px] text-center">Forks</th>
|
||||||
<th className="px-4 py-3 w-[80px] text-center">Likes</th>
|
<th className="px-4 py-3 w-[80px] text-center">Likes</th>
|
||||||
<th className="px-4 py-3 w-[150px]">Creator</th>
|
<th className="px-4 py-3 w-[150px]">Creator</th>
|
||||||
<th className="px-4 py-3 w-[100px] text-right">Copy</th>
|
<th className="px-4 py-3 w-[100px] text-right">Copy</th>
|
||||||
|
|
@ -159,6 +162,9 @@ export default function PublicCollectionsPage(): React.ReactElement {
|
||||||
{collection.toolCount}
|
{collection.toolCount}
|
||||||
</Badge>
|
</Badge>
|
||||||
</td>
|
</td>
|
||||||
|
<td className="px-4 py-3 text-center text-sm text-foreground-secondary">
|
||||||
|
{collection.forkCount > 0 ? collection.forkCount : '—'}
|
||||||
|
</td>
|
||||||
<td className="px-4 py-3 text-center">
|
<td className="px-4 py-3 text-center">
|
||||||
<LikeButton
|
<LikeButton
|
||||||
entityType="collection"
|
entityType="collection"
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { Container } from '@tpmjs/ui/Container/Container';
|
||||||
import { Icon } from '@tpmjs/ui/Icon/Icon';
|
import { Icon } from '@tpmjs/ui/Icon/Icon';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { AppHeader } from '../components/AppHeader';
|
import { AppHeader } from '../components/AppHeader';
|
||||||
|
import { EcosystemStats } from '../components/home/EcosystemStats';
|
||||||
import { FeaturesSection } from '../components/home/FeaturesSection';
|
import { FeaturesSection } from '../components/home/FeaturesSection';
|
||||||
import { HeroSection } from '../components/home/HeroSection';
|
import { HeroSection } from '../components/home/HeroSection';
|
||||||
|
|
||||||
|
|
@ -13,8 +14,14 @@ export const dynamic = 'force-dynamic';
|
||||||
async function getHomePageData() {
|
async function getHomePageData() {
|
||||||
try {
|
try {
|
||||||
// Fetch stats in parallel
|
// Fetch stats in parallel
|
||||||
const [packageCount, toolCount, featuredTools, categoryStats, featuredScenarios] =
|
const [
|
||||||
await Promise.all([
|
packageCount,
|
||||||
|
toolCount,
|
||||||
|
featuredTools,
|
||||||
|
categoryStats,
|
||||||
|
featuredScenarios,
|
||||||
|
latestSnapshot,
|
||||||
|
] = await Promise.all([
|
||||||
// Total package count
|
// Total package count
|
||||||
prisma.package.count(),
|
prisma.package.count(),
|
||||||
|
|
||||||
|
|
@ -30,6 +37,7 @@ async function getHomePageData() {
|
||||||
name: true,
|
name: true,
|
||||||
description: true,
|
description: true,
|
||||||
qualityScore: true,
|
qualityScore: true,
|
||||||
|
likeCount: true,
|
||||||
package: {
|
package: {
|
||||||
select: {
|
select: {
|
||||||
npmPackageName: true,
|
npmPackageName: true,
|
||||||
|
|
@ -95,6 +103,22 @@ async function getHomePageData() {
|
||||||
|
|
||||||
return [...highQuality, ...fresh].slice(0, 6);
|
return [...highQuality, ...fresh].slice(0, 6);
|
||||||
})(),
|
})(),
|
||||||
|
|
||||||
|
// Latest stats snapshot (pre-computed daily)
|
||||||
|
prisma.statsSnapshot.findFirst({
|
||||||
|
orderBy: { date: 'desc' },
|
||||||
|
select: {
|
||||||
|
totalTools: true,
|
||||||
|
totalPackages: true,
|
||||||
|
totalNpmDownloads: true,
|
||||||
|
totalGithubStars: true,
|
||||||
|
executionsTotal: true,
|
||||||
|
executionsAvgTimeMs: true,
|
||||||
|
activeDevs7d: true,
|
||||||
|
totalSimulations: true,
|
||||||
|
categories: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -102,6 +126,15 @@ async function getHomePageData() {
|
||||||
packageCount,
|
packageCount,
|
||||||
toolCount,
|
toolCount,
|
||||||
categoryCount: categoryStats.length,
|
categoryCount: categoryStats.length,
|
||||||
|
totalDownloads: latestSnapshot?.totalNpmDownloads ?? 0,
|
||||||
|
totalStars: latestSnapshot?.totalGithubStars ?? 0,
|
||||||
|
},
|
||||||
|
ecosystemStats: {
|
||||||
|
publishedTools: latestSnapshot?.totalTools ?? toolCount,
|
||||||
|
activeDevelopers: latestSnapshot?.activeDevs7d ?? 0,
|
||||||
|
totalExecutions: latestSnapshot?.totalSimulations ?? 0,
|
||||||
|
avgResponseMs: latestSnapshot?.executionsAvgTimeMs ?? null,
|
||||||
|
totalDownloads: latestSnapshot?.totalNpmDownloads ?? 0,
|
||||||
},
|
},
|
||||||
featuredTools,
|
featuredTools,
|
||||||
categories: categoryStats.slice(0, 5).map((c) => ({
|
categories: categoryStats.slice(0, 5).map((c) => ({
|
||||||
|
|
@ -117,6 +150,15 @@ async function getHomePageData() {
|
||||||
packageCount: 0,
|
packageCount: 0,
|
||||||
toolCount: 0,
|
toolCount: 0,
|
||||||
categoryCount: 0,
|
categoryCount: 0,
|
||||||
|
totalDownloads: 0,
|
||||||
|
totalStars: 0,
|
||||||
|
},
|
||||||
|
ecosystemStats: {
|
||||||
|
publishedTools: 0,
|
||||||
|
activeDevelopers: 0,
|
||||||
|
totalExecutions: 0,
|
||||||
|
avgResponseMs: null,
|
||||||
|
totalDownloads: 0,
|
||||||
},
|
},
|
||||||
featuredTools: [],
|
featuredTools: [],
|
||||||
categories: [],
|
categories: [],
|
||||||
|
|
@ -135,8 +177,11 @@ export default async function HomePage(): Promise<React.ReactElement> {
|
||||||
{/* Hero Section - Dithered Design */}
|
{/* Hero Section - Dithered Design */}
|
||||||
<HeroSection stats={data.stats} />
|
<HeroSection stats={data.stats} />
|
||||||
|
|
||||||
|
{/* Ecosystem Stats */}
|
||||||
|
<EcosystemStats stats={data.ecosystemStats} />
|
||||||
|
|
||||||
{/* Features Section */}
|
{/* Features Section */}
|
||||||
<FeaturesSection />
|
<FeaturesSection toolCount={data.stats.toolCount} />
|
||||||
|
|
||||||
{/* Architecture Diagram Section - temporarily disabled
|
{/* Architecture Diagram Section - temporarily disabled
|
||||||
<section className="py-16 bg-background border-b border-border">
|
<section className="py-16 bg-background border-b border-border">
|
||||||
|
|
@ -200,14 +245,20 @@ export default async function HomePage(): Promise<React.ReactElement> {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 pt-4 border-t border-border flex items-center justify-between text-xs text-foreground-tertiary">
|
<div className="mt-4 pt-4 border-t border-border flex items-center justify-between text-xs text-foreground-tertiary">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
{tool.qualityScore && Number(tool.qualityScore) > 0 ? (
|
{tool.qualityScore && Number(tool.qualityScore) > 0 ? (
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<span className="text-brutalist-accent">★</span>
|
<span className="text-brutalist-accent">★</span>
|
||||||
{Number(tool.qualityScore).toFixed(2)}
|
{Number(tool.qualityScore).toFixed(2)}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : null}
|
||||||
<span />
|
{tool.likeCount > 0 && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Icon icon="heart" className="w-3 h-3 text-error" />
|
||||||
|
{tool.likeCount}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
<span>
|
<span>
|
||||||
{(tool.package.npmDownloadsLastMonth ?? 0) > 0
|
{(tool.package.npmDownloadsLastMonth ?? 0) > 0
|
||||||
? `${tool.package.npmDownloadsLastMonth?.toLocaleString()} downloads/mo`
|
? `${tool.package.npmDownloadsLastMonth?.toLocaleString()} downloads/mo`
|
||||||
|
|
@ -459,7 +510,9 @@ export default async function HomePage(): Promise<React.ReactElement> {
|
||||||
<div className="w-2 h-2 bg-success rounded-full animate-pulse" />
|
<div className="w-2 h-2 bg-success rounded-full animate-pulse" />
|
||||||
<p className="font-mono text-xs text-foreground-secondary">
|
<p className="font-mono text-xs text-foreground-secondary">
|
||||||
add to config → instant access to{' '}
|
add to config → instant access to{' '}
|
||||||
<span className="text-primary font-medium">170+ tools</span>
|
<span className="text-primary font-medium">
|
||||||
|
{data.stats.toolCount > 0 ? `${data.stats.toolCount}+` : '100+'} tools
|
||||||
|
</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import { LikeButton } from '~/components/LikeButton';
|
||||||
import { Markdown } from '~/components/Markdown';
|
import { Markdown } from '~/components/Markdown';
|
||||||
import { Rating } from '~/components/Rating';
|
import { Rating } from '~/components/Rating';
|
||||||
import { ToolPlayground } from '~/components/ToolPlayground';
|
import { ToolPlayground } from '~/components/ToolPlayground';
|
||||||
|
import { useTrackView } from '~/hooks/useTrackView';
|
||||||
|
|
||||||
interface Package {
|
interface Package {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -70,6 +71,7 @@ export interface Tool {
|
||||||
healthCheckError?: string | null;
|
healthCheckError?: string | null;
|
||||||
lastHealthCheck?: string | null;
|
lastHealthCheck?: string | null;
|
||||||
likeCount?: number;
|
likeCount?: number;
|
||||||
|
viewCount?: number;
|
||||||
averageRating?: string | null;
|
averageRating?: string | null;
|
||||||
ratingCount?: number;
|
ratingCount?: number;
|
||||||
reviewCount?: number;
|
reviewCount?: number;
|
||||||
|
|
@ -88,6 +90,9 @@ export function ToolDetailClient({ tool, slug }: ToolDetailClientProps): React.R
|
||||||
const [recheckLoading, setRecheckLoading] = useState(false);
|
const [recheckLoading, setRecheckLoading] = useState(false);
|
||||||
const [extractSchemaLoading, setExtractSchemaLoading] = useState(false);
|
const [extractSchemaLoading, setExtractSchemaLoading] = useState(false);
|
||||||
|
|
||||||
|
// Track page view
|
||||||
|
useTrackView('tool', tool.id);
|
||||||
|
|
||||||
const pkg = tool.package;
|
const pkg = tool.package;
|
||||||
const authorName = typeof pkg.npmAuthor === 'string' ? pkg.npmAuthor : pkg.npmAuthor?.name;
|
const authorName = typeof pkg.npmAuthor === 'string' ? pkg.npmAuthor : pkg.npmAuthor?.name;
|
||||||
|
|
||||||
|
|
@ -194,6 +199,7 @@ export function ToolDetailClient({ tool, slug }: ToolDetailClientProps): React.R
|
||||||
<div className="min-h-screen bg-background">
|
<div className="min-h-screen bg-background">
|
||||||
<script
|
<script
|
||||||
type="application/ld+json"
|
type="application/ld+json"
|
||||||
|
// biome-ignore lint/security/noDangerouslySetInnerHtml: required for structured data ld+json
|
||||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(softwareApplicationSchema) }}
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(softwareApplicationSchema) }}
|
||||||
/>
|
/>
|
||||||
<AppHeader />
|
<AppHeader />
|
||||||
|
|
@ -600,6 +606,22 @@ console.log(result.text);`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{(tool.viewCount ?? 0) > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-foreground-secondary mb-1">Views</p>
|
||||||
|
<p className="text-2xl font-bold text-foreground">
|
||||||
|
{tool.viewCount?.toLocaleString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(tool.reviewCount ?? 0) > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-foreground-secondary mb-1">Reviews</p>
|
||||||
|
<p className="text-2xl font-bold text-foreground">
|
||||||
|
{tool.reviewCount?.toLocaleString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-foreground-secondary mb-2">Quality Score</p>
|
<p className="text-sm text-foreground-secondary mb-2">Quality Score</p>
|
||||||
<ProgressBar
|
<ProgressBar
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,7 @@ async function getTool(slug: string[]): Promise<Tool | null> {
|
||||||
healthCheckError: tool.healthCheckError ?? null,
|
healthCheckError: tool.healthCheckError ?? null,
|
||||||
lastHealthCheck: tool.lastHealthCheck?.toISOString() ?? null,
|
lastHealthCheck: tool.lastHealthCheck?.toISOString() ?? null,
|
||||||
likeCount: tool.likeCount,
|
likeCount: tool.likeCount,
|
||||||
|
viewCount: tool.viewCount,
|
||||||
averageRating: tool.averageRating?.toString() ?? null,
|
averageRating: tool.averageRating?.toString() ?? null,
|
||||||
ratingCount: tool.ratingCount,
|
ratingCount: tool.ratingCount,
|
||||||
reviewCount: tool.reviewCount,
|
reviewCount: tool.reviewCount,
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,48 @@
|
||||||
/**
|
|
||||||
* EcosystemStats Component
|
|
||||||
*
|
|
||||||
* Redesigned statistics section with dithered numbers and live activity stream.
|
|
||||||
*/
|
|
||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { ActivityStream } from '@tpmjs/ui/ActivityStream/ActivityStream';
|
|
||||||
import { Container } from '@tpmjs/ui/Container/Container';
|
import { Container } from '@tpmjs/ui/Container/Container';
|
||||||
import { DitherSectionHeader } from '@tpmjs/ui/DitherText/DitherSectionHeader';
|
import { DitherSectionHeader } from '@tpmjs/ui/DitherText/DitherSectionHeader';
|
||||||
import { StatCard } from '@tpmjs/ui/StatCard/StatCard';
|
import { StatCard } from '@tpmjs/ui/StatCard/StatCard';
|
||||||
import { statistics } from '../../data/homePageData';
|
import { PublicActivityStream } from './PublicActivityStream';
|
||||||
|
|
||||||
|
interface EcosystemStatsProps {
|
||||||
|
stats: {
|
||||||
|
publishedTools: number;
|
||||||
|
activeDevelopers: number;
|
||||||
|
totalExecutions: number;
|
||||||
|
avgResponseMs: number | null;
|
||||||
|
totalDownloads: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EcosystemStats({ stats }: EcosystemStatsProps): React.ReactElement {
|
||||||
|
const statistics = [
|
||||||
|
{
|
||||||
|
value: stats.publishedTools,
|
||||||
|
label: 'Published Tools',
|
||||||
|
subtext: 'Auto-synced from npm',
|
||||||
|
suffix: '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: stats.activeDevelopers,
|
||||||
|
label: 'Active Developers',
|
||||||
|
subtext: 'Last 7 days',
|
||||||
|
suffix: '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: stats.totalExecutions,
|
||||||
|
label: 'Total Executions',
|
||||||
|
subtext: 'All-time simulations',
|
||||||
|
suffix: '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: stats.avgResponseMs ?? 0,
|
||||||
|
label: 'Avg Response',
|
||||||
|
subtext: 'Execution latency',
|
||||||
|
suffix: 'ms',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
export function EcosystemStats(): React.ReactElement {
|
|
||||||
return (
|
return (
|
||||||
<section className="py-16 md:py-24 bg-surface relative overflow-hidden">
|
<section className="py-16 md:py-24 bg-surface relative overflow-hidden">
|
||||||
{/* Subtle grid background */}
|
{/* Subtle grid background */}
|
||||||
|
|
@ -23,34 +53,28 @@ export function EcosystemStats(): React.ReactElement {
|
||||||
|
|
||||||
{/* Stats Grid */}
|
{/* Stats Grid */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-12">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-12">
|
||||||
{statistics.map((stat, index) => {
|
{statistics.map((stat, index) => (
|
||||||
// Extract number from value string
|
|
||||||
const numValue = Number.parseInt(stat.value.replace(/[^0-9]/g, ''), 10) || 0;
|
|
||||||
const suffix = stat.value.replace(/[0-9,]/g, '');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
<div
|
||||||
key={stat.label}
|
key={stat.label}
|
||||||
className={`opacity-0 animate-brutalist-entrance stagger-${index + 1}`}
|
className={`opacity-0 animate-brutalist-entrance stagger-${index + 1}`}
|
||||||
>
|
>
|
||||||
<StatCard
|
<StatCard
|
||||||
value={numValue}
|
value={stat.value}
|
||||||
label={stat.label}
|
label={stat.label}
|
||||||
subtext={stat.subtext}
|
subtext={stat.subtext}
|
||||||
suffix={suffix}
|
suffix={stat.suffix}
|
||||||
variant="brutalist"
|
variant="brutalist"
|
||||||
size="md"
|
size="md"
|
||||||
showBar={true}
|
showBar={true}
|
||||||
barProgress={60 + index * 10}
|
barProgress={60 + index * 10}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
))}
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Activity Stream */}
|
{/* Activity Stream */}
|
||||||
<div className="max-w-3xl mx-auto">
|
<div className="max-w-3xl mx-auto">
|
||||||
<ActivityStream updateInterval={6000} maxItems={5} />
|
<PublicActivityStream />
|
||||||
</div>
|
</div>
|
||||||
</Container>
|
</Container>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,13 @@ interface FeatureCardProps {
|
||||||
href?: string;
|
href?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function FeatureCard({ icon, title, description, badge, href }: FeatureCardProps): React.ReactElement {
|
function FeatureCard({
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
badge,
|
||||||
|
href,
|
||||||
|
}: FeatureCardProps): React.ReactElement {
|
||||||
const content = (
|
const content = (
|
||||||
<div className="group h-full p-6 border border-dashed border-border bg-surface hover:border-primary hover:bg-primary/5 transition-all duration-200">
|
<div className="group h-full p-6 border border-dashed border-border bg-surface hover:border-primary hover:bg-primary/5 transition-all duration-200">
|
||||||
{/* Icon */}
|
{/* Icon */}
|
||||||
|
|
@ -60,13 +66,18 @@ function FeatureCard({ icon, title, description, badge, href }: FeatureCardProps
|
||||||
// Main Features Section
|
// Main Features Section
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
export function FeaturesSection(): React.ReactElement {
|
interface FeaturesSectionProps {
|
||||||
|
toolCount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FeaturesSection({ toolCount }: FeaturesSectionProps): React.ReactElement {
|
||||||
|
const toolCountLabel = toolCount && toolCount > 0 ? `${toolCount.toLocaleString()}` : '100+';
|
||||||
|
|
||||||
const features = [
|
const features = [
|
||||||
{
|
{
|
||||||
icon: 'search',
|
icon: 'search',
|
||||||
title: 'tool registry',
|
title: 'tool registry',
|
||||||
description:
|
description: `Browse ${toolCountLabel} AI tools from npm. Auto-discovered within minutes of publication with quality scoring and health monitoring.`,
|
||||||
'Browse 1M+ AI tools from npm. Auto-discovered within minutes of publication with quality scoring and health monitoring.',
|
|
||||||
badge: 'auto-sync',
|
badge: 'auto-sync',
|
||||||
href: '/tool/tool-search',
|
href: '/tool/tool-search',
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@ interface HeroSectionProps {
|
||||||
packageCount: number;
|
packageCount: number;
|
||||||
toolCount: number;
|
toolCount: number;
|
||||||
categoryCount: number;
|
categoryCount: number;
|
||||||
|
totalDownloads: number;
|
||||||
|
totalStars: number;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -79,6 +81,15 @@ export function HeroSection({ stats }: HeroSectionProps): React.ReactElement {
|
||||||
<span className="text-foreground">{formatNumber(stats.toolCount)}</span>
|
<span className="text-foreground">{formatNumber(stats.toolCount)}</span>
|
||||||
<span className="text-foreground-secondary">TOOLS</span>
|
<span className="text-foreground-secondary">TOOLS</span>
|
||||||
</div>
|
</div>
|
||||||
|
{stats.totalDownloads > 0 && (
|
||||||
|
<>
|
||||||
|
<span className="text-foreground-tertiary">/</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-foreground">{formatNumber(stats.totalDownloads)}</span>
|
||||||
|
<span className="text-foreground-secondary">DOWNLOADS</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Subheading */}
|
{/* Subheading */}
|
||||||
|
|
|
||||||
80
apps/web/src/components/home/PublicActivityStream.tsx
Normal file
80
apps/web/src/components/home/PublicActivityStream.tsx
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { ActivityStream } from '@tpmjs/ui/ActivityStream/ActivityStream';
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
interface ActivityItem {
|
||||||
|
type: 'invoked' | 'published' | 'updated';
|
||||||
|
tool: string;
|
||||||
|
time: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRelativeTime(dateStr: string): string {
|
||||||
|
const diff = Date.now() - new Date(dateStr).getTime();
|
||||||
|
const seconds = Math.floor(diff / 1000);
|
||||||
|
|
||||||
|
if (seconds < 60) return `${seconds}s ago`;
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
if (minutes < 60) return `${minutes}m ago`;
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
if (hours < 24) return `${hours}h ago`;
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
return `${days}d ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PublicActivity {
|
||||||
|
id: string;
|
||||||
|
type: 'invoked' | 'published' | 'updated';
|
||||||
|
username: string;
|
||||||
|
targetName: string;
|
||||||
|
targetType: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PublicActivityStream(): React.ReactElement {
|
||||||
|
const [activities, setActivities] = useState<ActivityItem[]>([]);
|
||||||
|
const isMounted = useRef(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
isMounted.current = true;
|
||||||
|
|
||||||
|
async function fetchActivities() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/activity/public');
|
||||||
|
const json = await res.json();
|
||||||
|
|
||||||
|
if (isMounted.current && json.success && json.data.length > 0) {
|
||||||
|
setActivities(
|
||||||
|
json.data.map((a: PublicActivity) => ({
|
||||||
|
type: a.type,
|
||||||
|
tool: `${a.username} → ${a.targetName}`,
|
||||||
|
time: formatRelativeTime(a.createdAt),
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Silent fail — activity stream is non-critical
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial fetch via interval (fires immediately on mount delay, then every 30s)
|
||||||
|
const timeoutId = setTimeout(fetchActivities, 0);
|
||||||
|
const intervalId = setInterval(fetchActivities, 30_000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMounted.current = false;
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
clearInterval(intervalId);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Pass activities to the existing ActivityStream component
|
||||||
|
// If no real activities exist yet, ActivityStream will auto-generate mock activity
|
||||||
|
return (
|
||||||
|
<ActivityStream
|
||||||
|
activities={activities.length > 0 ? activities : undefined}
|
||||||
|
updateInterval={30_000}
|
||||||
|
maxItems={5}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { Spinner } from '@tpmjs/ui/Spinner/Spinner';
|
import { Spinner } from '@tpmjs/ui/Spinner/Spinner';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
export function TechDiagram() {
|
export function TechDiagram() {
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
@ -10,13 +10,8 @@ export function TechDiagram() {
|
||||||
if (hasError) {
|
if (hasError) {
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-[700px] border border-border rounded-lg overflow-hidden flex flex-col items-center justify-center gap-4 p-8 text-center">
|
<div className="w-full h-[700px] border border-border rounded-lg overflow-hidden flex flex-col items-center justify-center gap-4 p-8 text-center">
|
||||||
<p className="text-foreground-secondary">
|
<p className="text-foreground-secondary">The interactive diagram failed to load.</p>
|
||||||
The interactive diagram failed to load.
|
<a href="/architecture" className="text-blue-500 hover:underline text-sm">
|
||||||
</p>
|
|
||||||
<a
|
|
||||||
href="/architecture"
|
|
||||||
className="text-blue-500 hover:underline text-sm"
|
|
||||||
>
|
|
||||||
View the alternative architecture diagram →
|
View the alternative architecture diagram →
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -11,22 +11,6 @@ export interface ToolCard {
|
||||||
href: string;
|
href: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Category {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
icon: IconName;
|
|
||||||
colorClass: string;
|
|
||||||
toolCount: number;
|
|
||||||
href: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Statistic {
|
|
||||||
icon: IconName;
|
|
||||||
value: string;
|
|
||||||
label: string;
|
|
||||||
subtext?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const featuredTools: ToolCard[] = [
|
export const featuredTools: ToolCard[] = [
|
||||||
{
|
{
|
||||||
id: 'tool-search',
|
id: 'tool-search',
|
||||||
|
|
@ -95,131 +79,8 @@ export const featuredTools: ToolCard[] = [
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export const categories: Category[] = [
|
// Note: categories and statistics arrays were removed in favor of real data
|
||||||
{
|
// from the database. See EcosystemStats component and getHomePageData() in page.tsx.
|
||||||
id: 'web-apis',
|
|
||||||
name: 'Web & APIs',
|
|
||||||
icon: 'externalLink',
|
|
||||||
colorClass: 'bg-blue-500 text-white',
|
|
||||||
toolCount: 423,
|
|
||||||
href: '/category/web-apis',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'databases',
|
|
||||||
name: 'Databases',
|
|
||||||
icon: 'copy',
|
|
||||||
colorClass: 'bg-emerald-500 text-white',
|
|
||||||
toolCount: 198,
|
|
||||||
href: '/category/databases',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'documents',
|
|
||||||
name: 'Documents',
|
|
||||||
icon: 'copy',
|
|
||||||
colorClass: 'bg-amber-500 text-white',
|
|
||||||
toolCount: 156,
|
|
||||||
href: '/category/documents',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'images',
|
|
||||||
name: 'Images',
|
|
||||||
icon: 'check',
|
|
||||||
colorClass: 'bg-pink-500 text-white',
|
|
||||||
toolCount: 134,
|
|
||||||
href: '/category/images',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'email',
|
|
||||||
name: 'Email',
|
|
||||||
icon: 'check',
|
|
||||||
colorClass: 'bg-indigo-500 text-white',
|
|
||||||
toolCount: 89,
|
|
||||||
href: '/category/email',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'calendar',
|
|
||||||
name: 'Calendar',
|
|
||||||
icon: 'check',
|
|
||||||
colorClass: 'bg-orange-500 text-white',
|
|
||||||
toolCount: 67,
|
|
||||||
href: '/category/calendar',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'search',
|
|
||||||
name: 'Search',
|
|
||||||
icon: 'check',
|
|
||||||
colorClass: 'bg-red-500 text-white',
|
|
||||||
toolCount: 112,
|
|
||||||
href: '/category/search',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'code-execution',
|
|
||||||
name: 'Code Execution',
|
|
||||||
icon: 'github',
|
|
||||||
colorClass: 'bg-zinc-700 text-white',
|
|
||||||
toolCount: 245,
|
|
||||||
href: '/category/code-execution',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'communication',
|
|
||||||
name: 'Communication',
|
|
||||||
icon: 'check',
|
|
||||||
colorClass: 'bg-cyan-500 text-white',
|
|
||||||
toolCount: 178,
|
|
||||||
href: '/category/communication',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'analytics',
|
|
||||||
name: 'Analytics',
|
|
||||||
icon: 'check',
|
|
||||||
colorClass: 'bg-violet-500 text-white',
|
|
||||||
toolCount: 203,
|
|
||||||
href: '/category/analytics',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'security',
|
|
||||||
name: 'Security',
|
|
||||||
icon: 'check',
|
|
||||||
colorClass: 'bg-slate-600 text-white',
|
|
||||||
toolCount: 91,
|
|
||||||
href: '/category/security',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'workflows',
|
|
||||||
name: 'Workflows',
|
|
||||||
icon: 'check',
|
|
||||||
colorClass: 'bg-teal-500 text-white',
|
|
||||||
toolCount: 167,
|
|
||||||
href: '/category/workflows',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export const statistics: Statistic[] = [
|
|
||||||
{
|
|
||||||
icon: 'copy',
|
|
||||||
value: '2,847',
|
|
||||||
label: 'Published Tools',
|
|
||||||
subtext: 'Across 24 categories',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: 'github',
|
|
||||||
value: '48K+',
|
|
||||||
label: 'Active Developers',
|
|
||||||
subtext: 'Building with TPMJS',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: 'check',
|
|
||||||
value: '12M+',
|
|
||||||
label: 'Weekly Invocations',
|
|
||||||
subtext: 'Across all tools',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: 'chevronDown',
|
|
||||||
value: '47ms',
|
|
||||||
label: 'Average Response',
|
|
||||||
subtext: '95th percentile latency',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export interface ProblemPoint {
|
export interface ProblemPoint {
|
||||||
title: string;
|
title: string;
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ export interface PublicAgent {
|
||||||
provider: string;
|
provider: string;
|
||||||
modelId: string;
|
modelId: string;
|
||||||
likeCount: number;
|
likeCount: number;
|
||||||
|
forkCount: number;
|
||||||
toolCount: number;
|
toolCount: number;
|
||||||
collectionCount: number;
|
collectionCount: number;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
|
|
||||||
18
apps/web/src/hooks/useTrackView.ts
Normal file
18
apps/web/src/hooks/useTrackView.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
export function useTrackView(entityType: string, entityId: string) {
|
||||||
|
const tracked = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (tracked.current || !entityId) return;
|
||||||
|
tracked.current = true;
|
||||||
|
|
||||||
|
fetch('/api/track/view', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ entityType, entityId }),
|
||||||
|
}).catch(() => {});
|
||||||
|
}, [entityType, entityId]);
|
||||||
|
}
|
||||||
|
|
@ -67,7 +67,7 @@ function cleanupMemoryStore() {
|
||||||
/**
|
/**
|
||||||
* Get client identifier from request (IP address)
|
* Get client identifier from request (IP address)
|
||||||
*/
|
*/
|
||||||
function getClientId(request: NextRequest): string {
|
export function getClientId(request: NextRequest): string {
|
||||||
const forwarded = request.headers.get('x-forwarded-for');
|
const forwarded = request.headers.get('x-forwarded-for');
|
||||||
const realIp = request.headers.get('x-real-ip');
|
const realIp = request.headers.get('x-real-ip');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ generator client {
|
||||||
datasource db {
|
datasource db {
|
||||||
provider = "postgresql"
|
provider = "postgresql"
|
||||||
url = env("DATABASE_URL")
|
url = env("DATABASE_URL")
|
||||||
directUrl = env("DIRECT_URL") // Direct connection for migrations (bypasses pooler)
|
directUrl = env("DATABASE_URL_UNPOOLED") // Direct connection for migrations (bypasses pooler)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Package table - stores NPM package metadata (package-level)
|
/// Package table - stores NPM package metadata (package-level)
|
||||||
|
|
@ -95,6 +95,9 @@ model Tool {
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
// View tracking (denormalized, rolled up daily)
|
||||||
|
viewCount Int @default(0) @map("view_count")
|
||||||
|
|
||||||
// Rating aggregates
|
// Rating aggregates
|
||||||
averageRating Decimal? @map("average_rating") @db.Decimal(2, 1) // 1.0 to 5.0
|
averageRating Decimal? @map("average_rating") @db.Decimal(2, 1) // 1.0 to 5.0
|
||||||
ratingCount Int @default(0) @map("rating_count")
|
ratingCount Int @default(0) @map("rating_count")
|
||||||
|
|
@ -116,6 +119,7 @@ model Tool {
|
||||||
@@index([likeCount])
|
@@index([likeCount])
|
||||||
@@index([averageRating])
|
@@index([averageRating])
|
||||||
@@index([ratingCount])
|
@@index([ratingCount])
|
||||||
|
@@index([viewCount])
|
||||||
@@index([reviewCount])
|
@@index([reviewCount])
|
||||||
@@index([importHealth])
|
@@index([importHealth])
|
||||||
@@index([executionHealth])
|
@@index([executionHealth])
|
||||||
|
|
@ -326,6 +330,12 @@ model StatsSnapshot {
|
||||||
// Category Breakdown (stored as JSON)
|
// Category Breakdown (stored as JSON)
|
||||||
categories Json? @db.JsonB
|
categories Json? @db.JsonB
|
||||||
|
|
||||||
|
// Social Proof Fields (added for homepage real data)
|
||||||
|
activeDevs7d Int @default(0) @map("active_devs_7d")
|
||||||
|
totalCollections Int @default(0) @map("total_collections")
|
||||||
|
totalAgents Int @default(0) @map("total_agents")
|
||||||
|
totalSimulations Int @default(0) @map("total_simulations")
|
||||||
|
|
||||||
// Timestamps
|
// Timestamps
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
|
@ -333,6 +343,20 @@ model StatsSnapshot {
|
||||||
@@map("stats_snapshots")
|
@@map("stats_snapshots")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// PageView - daily-bucketed view counts for entities (tools, collections, agents)
|
||||||
|
model PageView {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
entityType String @map("entity_type") @db.VarChar(20) // 'tool' | 'collection' | 'agent'
|
||||||
|
entityId String @map("entity_id")
|
||||||
|
viewCount Int @default(0) @map("view_count")
|
||||||
|
date DateTime @db.Date
|
||||||
|
|
||||||
|
@@unique([entityType, entityId, date])
|
||||||
|
@@index([entityType, entityId])
|
||||||
|
@@index([date])
|
||||||
|
@@map("page_views")
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Better Auth Models
|
// Better Auth Models
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
@ -442,6 +466,9 @@ model Collection {
|
||||||
isPublic Boolean @default(false) @map("is_public")
|
isPublic Boolean @default(false) @map("is_public")
|
||||||
likeCount Int @default(0) @map("like_count")
|
likeCount Int @default(0) @map("like_count")
|
||||||
|
|
||||||
|
// View tracking (denormalized, rolled up daily)
|
||||||
|
viewCount Int @default(0) @map("view_count")
|
||||||
|
|
||||||
// Executor configuration (optional - uses system default if not set)
|
// Executor configuration (optional - uses system default if not set)
|
||||||
executorType String? @map("executor_type") @db.VarChar(50)
|
executorType String? @map("executor_type") @db.VarChar(50)
|
||||||
executorConfig Json? @map("executor_config") @db.JsonB
|
executorConfig Json? @map("executor_config") @db.JsonB
|
||||||
|
|
@ -489,6 +516,7 @@ model Collection {
|
||||||
@@index([slug])
|
@@index([slug])
|
||||||
@@index([isPublic])
|
@@index([isPublic])
|
||||||
@@index([likeCount])
|
@@index([likeCount])
|
||||||
|
@@index([viewCount])
|
||||||
@@index([createdAt])
|
@@index([createdAt])
|
||||||
@@index([forkedFromId])
|
@@index([forkedFromId])
|
||||||
@@index([forkCount])
|
@@index([forkCount])
|
||||||
|
|
@ -567,6 +595,9 @@ model Agent {
|
||||||
isPublic Boolean @default(true) @map("is_public")
|
isPublic Boolean @default(true) @map("is_public")
|
||||||
likeCount Int @default(0) @map("like_count")
|
likeCount Int @default(0) @map("like_count")
|
||||||
|
|
||||||
|
// View tracking (denormalized, rolled up daily)
|
||||||
|
viewCount Int @default(0) @map("view_count")
|
||||||
|
|
||||||
// Executor configuration (optional - uses system default if not set)
|
// Executor configuration (optional - uses system default if not set)
|
||||||
// Agent executor overrides collection executor overrides system default
|
// Agent executor overrides collection executor overrides system default
|
||||||
executorType String? @map("executor_type") @db.VarChar(50)
|
executorType String? @map("executor_type") @db.VarChar(50)
|
||||||
|
|
@ -597,6 +628,7 @@ model Agent {
|
||||||
@@index([uid])
|
@@index([uid])
|
||||||
@@index([isPublic])
|
@@index([isPublic])
|
||||||
@@index([likeCount])
|
@@index([likeCount])
|
||||||
|
@@index([viewCount])
|
||||||
@@index([createdAt])
|
@@index([createdAt])
|
||||||
@@index([forkedFromId])
|
@@index([forkedFromId])
|
||||||
@@index([forkCount])
|
@@index([forkCount])
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,10 @@
|
||||||
"path": "/api/sync/stats-snapshot",
|
"path": "/api/sync/stats-snapshot",
|
||||||
"schedule": "0 0 * * *"
|
"schedule": "0 0 * * *"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "/api/sync/view-rollup",
|
||||||
|
"schedule": "30 0 * * *"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "/api/sync/cleanup-activity",
|
"path": "/api/sync/cleanup-activity",
|
||||||
"schedule": "0 3 * * *"
|
"schedule": "0 3 * * *"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue