feat(web): implement SWR for instant data fetching
- Add SWR package and global SWRProvider - Create reusable hooks: useTools, useAgents, useCollections, useStats, useLikeStatus, useBundleSize, useActivity - Update tool-search page to use useTools hook - Update agents page to use useAgents hook - Update BundleSize component to use useBundleSize hook - Update LikeButton with optimistic updates via useLikeStatus - Add simple about page with creator info
This commit is contained in:
parent
7483e697fc
commit
0489f5cfdb
17 changed files with 1085 additions and 255 deletions
|
|
@ -27,6 +27,8 @@
|
|||
"@ai-sdk/openai": "3.0.7",
|
||||
"@modelcontextprotocol/sdk": "^1.25.2",
|
||||
"@prisma/client": "^6.19.1",
|
||||
"@react-three/drei": "^10.7.7",
|
||||
"@react-three/fiber": "^9.5.0",
|
||||
"@tpmjs/db": "workspace:*",
|
||||
"@tpmjs/env": "workspace:*",
|
||||
"@tpmjs/npm-client": "workspace:*",
|
||||
|
|
@ -57,7 +59,9 @@
|
|||
"remark-gfm": "^4.0.1",
|
||||
"resend": "^6.7.0",
|
||||
"sonner": "^2.0.7",
|
||||
"swr": "^2.2.5",
|
||||
"streamdown": "^1.6.11",
|
||||
"three": "^0.182.0",
|
||||
"zod": "^4.3.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
@ -68,6 +72,7 @@
|
|||
"@types/node": "^25.0.3",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/three": "^0.182.0",
|
||||
"autoprefixer": "^10.4.23",
|
||||
"dotenv": "^17.2.3",
|
||||
"eslint": "^9.39.2",
|
||||
|
|
|
|||
57
apps/web/src/app/about/page.tsx
Normal file
57
apps/web/src/app/about/page.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { Container } from '@tpmjs/ui/Container/Container';
|
||||
import { Icon } from '@tpmjs/ui/Icon/Icon';
|
||||
import type { Metadata } from 'next';
|
||||
import { AppHeader } from '~/components/AppHeader';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'About',
|
||||
description: 'About TPMJS and its creator',
|
||||
};
|
||||
|
||||
export default function AboutPage(): React.ReactElement {
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<AppHeader />
|
||||
|
||||
<Container size="md" padding="lg" className="py-16">
|
||||
<h1 className="text-3xl font-bold mb-8 text-foreground">About TPMJS</h1>
|
||||
|
||||
<div className="prose prose-neutral dark:prose-invert max-w-none">
|
||||
<p className="text-lg text-foreground-secondary mb-6">
|
||||
TPMJS (Tool Package Manager for JavaScript) is the npm registry for AI agent tools.
|
||||
It was started in 2024 to make it easy for developers to publish and discover tools
|
||||
that AI agents can use.
|
||||
</p>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-10 mb-4 text-foreground">Creator</h2>
|
||||
|
||||
<p className="text-foreground-secondary mb-6">
|
||||
TPMJS was created by <strong>Ajax Davis</strong> (Thomas Davis).
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-6">
|
||||
<a
|
||||
href="https://x.com/ajaxdavis"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 text-foreground-secondary hover:text-primary transition-colors"
|
||||
>
|
||||
<Icon icon="x" size="md" />
|
||||
<span>@ajaxdavis</span>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="https://ajaxdavis.dev"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 text-foreground-secondary hover:text-primary transition-colors"
|
||||
>
|
||||
<Icon icon="globe" size="md" />
|
||||
<span>ajaxdavis.dev</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -9,30 +9,12 @@ import { LoadingState } from '@tpmjs/ui/LoadingState/LoadingState';
|
|||
import { PageHeader } from '@tpmjs/ui/PageHeader/PageHeader';
|
||||
import { Select } from '@tpmjs/ui/Select/Select';
|
||||
import Link from 'next/link';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { TableVirtuoso } from 'react-virtuoso';
|
||||
import { AppHeader } from '~/components/AppHeader';
|
||||
import { CopyDropdown, getAgentCopyOptions } from '~/components/CopyDropdown';
|
||||
import { LikeButton } from '~/components/LikeButton';
|
||||
|
||||
interface PublicAgent {
|
||||
id: string;
|
||||
uid: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
provider: string;
|
||||
modelId: string;
|
||||
likeCount: number;
|
||||
toolCount: number;
|
||||
collectionCount: number;
|
||||
createdAt: string;
|
||||
createdBy: {
|
||||
id: string;
|
||||
name: string;
|
||||
image: string | null;
|
||||
username: string | null;
|
||||
};
|
||||
}
|
||||
import { type PublicAgent, useAgents } from '~/hooks/useAgents';
|
||||
|
||||
type SortOption = 'likes' | 'recent' | 'tools';
|
||||
|
||||
|
|
@ -57,68 +39,24 @@ function truncateText(text: string, maxLength: number): string {
|
|||
}
|
||||
|
||||
export default function PublicAgentsPage(): React.ReactElement {
|
||||
const [agents, setAgents] = useState<PublicAgent[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const [sort, setSort] = useState<SortOption>('likes');
|
||||
const loadingMore = useRef(false);
|
||||
|
||||
const fetchAgents = useCallback(
|
||||
async (offset: number, resetList = false) => {
|
||||
try {
|
||||
if (loadingMore.current && !resetList) return;
|
||||
loadingMore.current = true;
|
||||
// Fetch agents using SWR
|
||||
const { data, isLoading, error: swrError, mutate } = useAgents({ sort });
|
||||
|
||||
const params = new URLSearchParams({
|
||||
limit: '100',
|
||||
offset: String(offset),
|
||||
sort,
|
||||
});
|
||||
const agents = data?.agents ?? [];
|
||||
const hasMore = data?.pagination.hasMore ?? false;
|
||||
const error = swrError?.message ?? null;
|
||||
|
||||
const response = await fetch(`/api/public/agents?${params}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
if (resetList || offset === 0) {
|
||||
setAgents(data.data);
|
||||
} else {
|
||||
setAgents((prev) => [...prev, ...data.data]);
|
||||
}
|
||||
setHasMore(data.pagination.hasMore);
|
||||
} else {
|
||||
setError(data.error?.message || 'Failed to fetch agents');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch agents:', err);
|
||||
setError('Failed to fetch agents');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
loadingMore.current = false;
|
||||
}
|
||||
},
|
||||
[sort]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoading(true);
|
||||
fetchAgents(0, true);
|
||||
}, [fetchAgents]);
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
if (!hasMore || loadingMore.current) return;
|
||||
fetchAgents(agents.length);
|
||||
}, [hasMore, agents.length, fetchAgents]);
|
||||
|
||||
// Filter and sort agents
|
||||
// Filter and sort agents (client-side search)
|
||||
const filteredAgents = useMemo(() => {
|
||||
let result = agents;
|
||||
|
||||
if (search) {
|
||||
const query = search.toLowerCase();
|
||||
result = result.filter(
|
||||
(a) =>
|
||||
(a: PublicAgent) =>
|
||||
a.name.toLowerCase().includes(query) ||
|
||||
a.description?.toLowerCase().includes(query) ||
|
||||
a.provider.toLowerCase().includes(query)
|
||||
|
|
@ -253,7 +191,7 @@ export default function PublicAgentsPage(): React.ReactElement {
|
|||
|
||||
{/* Content */}
|
||||
{error ? (
|
||||
<ErrorState message={error} onRetry={() => fetchAgents(0, true)} />
|
||||
<ErrorState message={error} onRetry={() => mutate()} />
|
||||
) : isLoading ? (
|
||||
<LoadingState message="Loading agents..." size="lg" />
|
||||
) : filteredAgents.length === 0 ? (
|
||||
|
|
@ -269,7 +207,6 @@ export default function PublicAgentsPage(): React.ReactElement {
|
|||
style={{ height: 'calc(100vh - 350px)', minHeight: '400px' }}
|
||||
data={filteredAgents}
|
||||
overscan={30}
|
||||
endReached={loadMore}
|
||||
fixedHeaderContent={TableHeader}
|
||||
itemContent={TableRow}
|
||||
components={{
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import Script from 'next/script';
|
|||
import { Toaster } from 'sonner';
|
||||
import { AppFooter } from '../components/AppFooter';
|
||||
import { ThemeProvider } from '../components/providers/ThemeProvider';
|
||||
import { SWRProvider } from '../components/SWRProvider';
|
||||
import './globals.css';
|
||||
|
||||
const spaceGrotesk = Space_Grotesk({
|
||||
|
|
@ -157,11 +158,13 @@ export default function RootLayout({
|
|||
enableSystem={true}
|
||||
disableTransitionOnChange={false}
|
||||
>
|
||||
<div className="flex flex-col min-h-screen">
|
||||
<div className="flex-1">{children}</div>
|
||||
<AppFooter />
|
||||
</div>
|
||||
<Toaster position="bottom-right" richColors closeButton />
|
||||
<SWRProvider>
|
||||
<div className="flex flex-col min-h-screen">
|
||||
<div className="flex-1">{children}</div>
|
||||
<AppFooter />
|
||||
</div>
|
||||
<Toaster position="bottom-right" richColors closeButton />
|
||||
</SWRProvider>
|
||||
</ThemeProvider>
|
||||
<Analytics />
|
||||
</body>
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import { LoadingState } from '@tpmjs/ui/LoadingState/LoadingState';
|
|||
import { PageHeader } from '@tpmjs/ui/PageHeader/PageHeader';
|
||||
import { Select } from '@tpmjs/ui/Select/Select';
|
||||
import Link from 'next/link';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { TableVirtuoso } from 'react-virtuoso';
|
||||
import { AppHeader } from '~/components/AppHeader';
|
||||
import { CopyButton } from '~/components/CopyButton';
|
||||
|
|
@ -21,26 +21,7 @@ import {
|
|||
getInstallCommand,
|
||||
usePackageManager,
|
||||
} from '~/components/PackageManagerSelector';
|
||||
|
||||
interface Tool {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
qualityScore: string;
|
||||
likeCount?: number;
|
||||
importHealth?: 'HEALTHY' | 'BROKEN' | 'UNKNOWN';
|
||||
executionHealth?: 'HEALTHY' | 'BROKEN' | 'UNKNOWN';
|
||||
createdAt: string;
|
||||
package: {
|
||||
npmPackageName: string;
|
||||
npmVersion: string;
|
||||
npmPublishedAt: string;
|
||||
category: string;
|
||||
npmRepository: { url: string; type: string } | null;
|
||||
isOfficial: boolean;
|
||||
npmDownloadsLastMonth: number;
|
||||
};
|
||||
}
|
||||
import { type Tool, useTools } from '~/hooks/useTools';
|
||||
|
||||
type SortOption = 'downloads' | 'likes' | 'recent' | 'name';
|
||||
|
||||
|
|
@ -97,60 +78,28 @@ export default function ToolSearchPage(): React.ReactElement {
|
|||
const [categoryFilter, setCategoryFilter] = useState('all');
|
||||
const [healthFilter, setHealthFilter] = useState('all');
|
||||
const [sortBy, setSortBy] = useState<SortOption>('downloads');
|
||||
const [tools, setTools] = useState<Tool[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [availableCategories, setAvailableCategories] = useState<string[]>([]);
|
||||
const [packageManager, setPackageManager] = usePackageManager();
|
||||
|
||||
// Fetch tools from API
|
||||
useEffect(() => {
|
||||
const fetchTools = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const params = new URLSearchParams();
|
||||
// Fetch tools from API using SWR
|
||||
const { data: tools = [], isLoading: loading, error: swrError } = useTools({
|
||||
category: categoryFilter !== 'all' ? categoryFilter : undefined,
|
||||
importHealth: healthFilter === 'healthy' ? 'HEALTHY' : undefined,
|
||||
executionHealth: healthFilter === 'healthy' ? 'HEALTHY' : undefined,
|
||||
broken: healthFilter === 'broken' ? true : undefined,
|
||||
});
|
||||
|
||||
if (categoryFilter !== 'all') {
|
||||
params.set('category', categoryFilter);
|
||||
}
|
||||
const error = swrError?.message ?? null;
|
||||
|
||||
if (healthFilter === 'healthy') {
|
||||
params.set('importHealth', 'HEALTHY');
|
||||
params.set('executionHealth', 'HEALTHY');
|
||||
} else if (healthFilter === 'broken') {
|
||||
params.set('broken', 'true');
|
||||
}
|
||||
|
||||
// Fetch all tools (no pagination limit)
|
||||
params.set('limit', '1000');
|
||||
const toolsResponse = await fetch(`/api/tools?${params.toString()}`);
|
||||
const toolsData = await toolsResponse.json();
|
||||
|
||||
if (toolsData.success) {
|
||||
const fetchedTools = toolsData.data;
|
||||
setTools(fetchedTools);
|
||||
setError(null);
|
||||
|
||||
// Extract unique categories from all tools
|
||||
const categories = new Set<string>();
|
||||
for (const tool of fetchedTools) {
|
||||
if (tool.package.category) {
|
||||
categories.add(tool.package.category);
|
||||
}
|
||||
}
|
||||
setAvailableCategories(Array.from(categories).sort());
|
||||
} else {
|
||||
setError(toolsData.error || 'Failed to fetch tools');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
// Extract unique categories from all tools
|
||||
const availableCategories = useMemo(() => {
|
||||
const categories = new Set<string>();
|
||||
for (const tool of tools) {
|
||||
if (tool.package.category) {
|
||||
categories.add(tool.package.category);
|
||||
}
|
||||
};
|
||||
|
||||
fetchTools();
|
||||
}, [categoryFilter, healthFilter]);
|
||||
}
|
||||
return Array.from(categories).sort();
|
||||
}, [tools]);
|
||||
|
||||
// Filter and sort tools
|
||||
const filteredTools = useMemo(() => {
|
||||
|
|
@ -160,7 +109,7 @@ export default function ToolSearchPage(): React.ReactElement {
|
|||
if (searchQuery) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
result = result.filter(
|
||||
(tool) =>
|
||||
(tool: Tool) =>
|
||||
tool.name.toLowerCase().includes(query) ||
|
||||
tool.package.npmPackageName.toLowerCase().includes(query) ||
|
||||
tool.description.toLowerCase().includes(query)
|
||||
|
|
|
|||
|
|
@ -1,15 +1,7 @@
|
|||
'use client';
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@tpmjs/ui/Card/Card';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
interface BundleSizeData {
|
||||
name: string;
|
||||
version: string;
|
||||
size: number;
|
||||
gzip: number;
|
||||
dependencyCount: number;
|
||||
}
|
||||
import { useBundleSize } from '~/hooks/useBundleSize';
|
||||
|
||||
interface BundleSizeProps {
|
||||
packageName: string;
|
||||
|
|
@ -25,42 +17,10 @@ function formatBytes(bytes: number): string {
|
|||
}
|
||||
|
||||
export function BundleSize({ packageName, version }: BundleSizeProps): React.ReactElement | null {
|
||||
const [data, setData] = useState<BundleSizeData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { data, isLoading: loading, error } = useBundleSize(packageName, version);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchBundleSize = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({ package: packageName, version });
|
||||
const response = await fetch(`/api/bundlephobia?${params}`);
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
setError('not-found');
|
||||
} else {
|
||||
setError('failed');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
setData(result);
|
||||
} catch {
|
||||
setError('failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchBundleSize();
|
||||
}, [packageName, version]);
|
||||
|
||||
// Don't render anything if package not found (common for scoped packages)
|
||||
if (error === 'not-found') {
|
||||
// Don't render anything if package not found (common for scoped packages) or error
|
||||
if (error || (!loading && !data)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -80,7 +40,7 @@ export function BundleSize({ packageName, version }: BundleSizeProps): React.Rea
|
|||
);
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@
|
|||
import { useSession } from '@/lib/auth-client';
|
||||
import { Button } from '@tpmjs/ui/Button/Button';
|
||||
import { Icon } from '@tpmjs/ui/Icon/Icon';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useLikeStatus } from '~/hooks/useLikeStatus';
|
||||
|
||||
export type LikeEntityType = 'tool' | 'collection' | 'agent';
|
||||
|
||||
|
|
@ -31,41 +32,19 @@ export function LikeButton({
|
|||
onLikeChange,
|
||||
}: LikeButtonProps): React.ReactElement {
|
||||
const { data: session } = useSession();
|
||||
const [liked, setLiked] = useState(initialLiked);
|
||||
const [count, setCount] = useState(initialCount);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [hasFetched, setHasFetched] = useState(false);
|
||||
const [showTooltip, setShowTooltip] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// Fetch initial like status when user is logged in
|
||||
useEffect(() => {
|
||||
if (!session || hasFetched) return;
|
||||
// Use SWR for like status with optimistic updates
|
||||
const { data, toggleLike } = useLikeStatus(
|
||||
entityType,
|
||||
entityId,
|
||||
!!session,
|
||||
{ liked: initialLiked, likeCount: initialCount }
|
||||
);
|
||||
|
||||
const fetchLikeStatus = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/${entityType}s/${entityId}/like`);
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
setLiked(data.data.liked);
|
||||
setCount(data.data.likeCount);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch like status:', error);
|
||||
} finally {
|
||||
setHasFetched(true);
|
||||
}
|
||||
};
|
||||
|
||||
fetchLikeStatus();
|
||||
}, [session, entityType, entityId, hasFetched]);
|
||||
|
||||
// Update from props when they change
|
||||
useEffect(() => {
|
||||
if (!hasFetched) {
|
||||
setLiked(initialLiked);
|
||||
setCount(initialCount);
|
||||
}
|
||||
}, [initialLiked, initialCount, hasFetched]);
|
||||
const liked = data?.liked ?? initialLiked;
|
||||
const count = data?.likeCount ?? initialCount;
|
||||
|
||||
const handleClick = useCallback(async () => {
|
||||
if (!session) {
|
||||
|
|
@ -77,40 +56,17 @@ export function LikeButton({
|
|||
|
||||
if (isLoading) return;
|
||||
|
||||
// Optimistic update
|
||||
const newLiked = !liked;
|
||||
const newCount = newLiked ? count + 1 : Math.max(0, count - 1);
|
||||
setLiked(newLiked);
|
||||
setCount(newCount);
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/${entityType}s/${entityId}/like`, {
|
||||
method: newLiked ? 'POST' : 'DELETE',
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
// Update with server values
|
||||
setLiked(data.data.liked);
|
||||
setCount(data.data.likeCount);
|
||||
onLikeChange?.(data.data.liked, data.data.likeCount);
|
||||
} else {
|
||||
// Revert on error
|
||||
setLiked(!newLiked);
|
||||
setCount(liked ? count : Math.max(0, count - 1));
|
||||
await toggleLike();
|
||||
// Call the onLikeChange callback if provided
|
||||
if (onLikeChange && data) {
|
||||
onLikeChange(!data.liked, data.liked ? data.likeCount - 1 : data.likeCount + 1);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to toggle like:', error);
|
||||
// Revert on error
|
||||
setLiked(!newLiked);
|
||||
setCount(liked ? count : Math.max(0, count - 1));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [session, liked, count, isLoading, entityType, entityId, onLikeChange]);
|
||||
}, [session, isLoading, toggleLike, onLikeChange, data]);
|
||||
|
||||
return (
|
||||
<div className="relative inline-block">
|
||||
|
|
|
|||
23
apps/web/src/components/SWRProvider.tsx
Normal file
23
apps/web/src/components/SWRProvider.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
'use client';
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import { SWRConfig } from 'swr';
|
||||
import { fetcher } from '~/lib/swr/fetcher';
|
||||
|
||||
interface SWRProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function SWRProvider({ children }: SWRProviderProps): React.ReactElement {
|
||||
return (
|
||||
<SWRConfig
|
||||
value={{
|
||||
fetcher,
|
||||
revalidateOnFocus: false,
|
||||
dedupingInterval: 5000,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</SWRConfig>
|
||||
);
|
||||
}
|
||||
91
apps/web/src/hooks/useActivity.ts
Normal file
91
apps/web/src/hooks/useActivity.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import useSWRInfinite from 'swr/infinite';
|
||||
|
||||
export interface Activity {
|
||||
id: string;
|
||||
type: string;
|
||||
metadata: Record<string, unknown>;
|
||||
createdAt: string;
|
||||
tool?: {
|
||||
name: string;
|
||||
package: { npmPackageName: string };
|
||||
};
|
||||
agent?: {
|
||||
name: string;
|
||||
uid: string;
|
||||
};
|
||||
collection?: {
|
||||
name: string;
|
||||
uid: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface ActivityResponse {
|
||||
activities: Activity[];
|
||||
pagination: {
|
||||
nextCursor: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Infinite scroll hook for user activity stream
|
||||
*/
|
||||
export function useActivity(limit = 20) {
|
||||
const getKey = (pageIndex: number, previousPageData: ActivityResponse | null) => {
|
||||
// Reached the end
|
||||
if (previousPageData && !previousPageData.pagination.nextCursor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// First page
|
||||
if (pageIndex === 0) {
|
||||
return `/api/user/activity?limit=${limit}`;
|
||||
}
|
||||
|
||||
// Next pages
|
||||
const cursor = previousPageData?.pagination.nextCursor;
|
||||
return `/api/user/activity?limit=${limit}&cursor=${cursor}`;
|
||||
};
|
||||
|
||||
const { data, error, isLoading, isValidating, size, setSize, mutate } =
|
||||
useSWRInfinite<ActivityResponse>(
|
||||
getKey,
|
||||
async (url: string) => {
|
||||
const res = await fetch(url);
|
||||
const json = await res.json();
|
||||
|
||||
if (!json.success) {
|
||||
throw new Error(json.error || 'Failed to fetch activity');
|
||||
}
|
||||
|
||||
return {
|
||||
activities: json.data,
|
||||
pagination: json.pagination,
|
||||
};
|
||||
},
|
||||
{
|
||||
revalidateFirstPage: false,
|
||||
}
|
||||
);
|
||||
|
||||
// Flatten all activities from all pages
|
||||
const activities = data ? data.flatMap((page: ActivityResponse) => page.activities) : [];
|
||||
|
||||
// Check if there are more pages
|
||||
const hasMore = data ? data[data.length - 1]?.pagination.nextCursor !== null : false;
|
||||
|
||||
const loadMore = () => {
|
||||
if (hasMore && !isValidating) {
|
||||
setSize(size + 1);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
activities,
|
||||
isLoading,
|
||||
isLoadingMore: isValidating && data && data.length > 0,
|
||||
error,
|
||||
hasMore,
|
||||
loadMore,
|
||||
mutate,
|
||||
};
|
||||
}
|
||||
66
apps/web/src/hooks/useAgents.ts
Normal file
66
apps/web/src/hooks/useAgents.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import useSWR from 'swr';
|
||||
|
||||
export interface PublicAgent {
|
||||
id: string;
|
||||
uid: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
provider: string;
|
||||
modelId: string;
|
||||
likeCount: number;
|
||||
toolCount: number;
|
||||
collectionCount: number;
|
||||
createdAt: string;
|
||||
createdBy: {
|
||||
id: string;
|
||||
name: string;
|
||||
image: string | null;
|
||||
username: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface UseAgentsParams {
|
||||
sort?: 'likes' | 'recent' | 'tools';
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
interface AgentsResponse {
|
||||
agents: PublicAgent[];
|
||||
pagination: {
|
||||
hasMore: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch public agents
|
||||
*/
|
||||
export function useAgents(params: UseAgentsParams = {}) {
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
searchParams.set('limit', String(params.limit ?? 100));
|
||||
searchParams.set('offset', String(params.offset ?? 0));
|
||||
if (params.sort) {
|
||||
searchParams.set('sort', params.sort);
|
||||
}
|
||||
|
||||
const queryString = searchParams.toString();
|
||||
|
||||
// Custom response handler since the API returns { success, data, pagination }
|
||||
return useSWR<AgentsResponse>(
|
||||
`/api/public/agents?${queryString}`,
|
||||
async (url: string) => {
|
||||
const res = await fetch(url);
|
||||
const json = await res.json();
|
||||
|
||||
if (!json.success) {
|
||||
throw new Error(json.error?.message || 'Failed to fetch agents');
|
||||
}
|
||||
|
||||
return {
|
||||
agents: json.data,
|
||||
pagination: json.pagination,
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
31
apps/web/src/hooks/useBundleSize.ts
Normal file
31
apps/web/src/hooks/useBundleSize.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import useSWR from 'swr';
|
||||
|
||||
export interface BundleSizeData {
|
||||
name: string;
|
||||
version: string;
|
||||
size: number;
|
||||
gzip: number;
|
||||
dependencyCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch bundle size data from bundlephobia API
|
||||
* Returns null for the key if packageName is not provided (disables fetching)
|
||||
*/
|
||||
export function useBundleSize(packageName: string | undefined, version?: string) {
|
||||
const params = new URLSearchParams();
|
||||
if (packageName) {
|
||||
params.set('package', packageName);
|
||||
if (version) {
|
||||
params.set('version', version);
|
||||
}
|
||||
}
|
||||
|
||||
return useSWR<BundleSizeData>(
|
||||
packageName ? `/api/bundlephobia?${params.toString()}` : null,
|
||||
{
|
||||
// Don't retry on 404s (common for scoped packages)
|
||||
shouldRetryOnError: false,
|
||||
}
|
||||
);
|
||||
}
|
||||
64
apps/web/src/hooks/useCollections.ts
Normal file
64
apps/web/src/hooks/useCollections.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import useSWR from 'swr';
|
||||
|
||||
export interface PublicCollection {
|
||||
id: string;
|
||||
uid: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
likeCount: number;
|
||||
forkCount: number;
|
||||
toolCount: number;
|
||||
createdAt: string;
|
||||
createdBy: {
|
||||
id: string;
|
||||
name: string;
|
||||
image: string | null;
|
||||
username: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface UseCollectionsParams {
|
||||
sort?: 'likes' | 'recent' | 'tools';
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
interface CollectionsResponse {
|
||||
collections: PublicCollection[];
|
||||
pagination: {
|
||||
hasMore: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch public collections
|
||||
*/
|
||||
export function useCollections(params: UseCollectionsParams = {}) {
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
searchParams.set('limit', String(params.limit ?? 100));
|
||||
searchParams.set('offset', String(params.offset ?? 0));
|
||||
if (params.sort) {
|
||||
searchParams.set('sort', params.sort);
|
||||
}
|
||||
|
||||
const queryString = searchParams.toString();
|
||||
|
||||
// Custom response handler since the API returns { success, data, pagination }
|
||||
return useSWR<CollectionsResponse>(
|
||||
`/api/public/collections?${queryString}`,
|
||||
async (url: string) => {
|
||||
const res = await fetch(url);
|
||||
const json = await res.json();
|
||||
|
||||
if (!json.success) {
|
||||
throw new Error(json.error?.message || 'Failed to fetch collections');
|
||||
}
|
||||
|
||||
return {
|
||||
collections: json.data,
|
||||
pagination: json.pagination,
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
76
apps/web/src/hooks/useLikeStatus.ts
Normal file
76
apps/web/src/hooks/useLikeStatus.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import useSWR, { type KeyedMutator } from 'swr';
|
||||
import type { LikeEntityType } from '~/components/LikeButton';
|
||||
|
||||
export interface LikeStatusData {
|
||||
liked: boolean;
|
||||
likeCount: number;
|
||||
}
|
||||
|
||||
interface UseLikeStatusReturn {
|
||||
data: LikeStatusData | undefined;
|
||||
isLoading: boolean;
|
||||
error: Error | undefined;
|
||||
mutate: KeyedMutator<LikeStatusData>;
|
||||
toggleLike: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing like status with optimistic updates
|
||||
* @param entityType - 'tool' | 'collection' | 'agent'
|
||||
* @param entityId - The ID of the entity
|
||||
* @param enabled - Whether to enable fetching (typically based on session)
|
||||
* @param initialData - Optional initial data to use before fetch completes
|
||||
*/
|
||||
export function useLikeStatus(
|
||||
entityType: LikeEntityType,
|
||||
entityId: string,
|
||||
enabled: boolean,
|
||||
initialData?: LikeStatusData
|
||||
): UseLikeStatusReturn {
|
||||
const key = enabled ? `/api/${entityType}s/${entityId}/like` : null;
|
||||
|
||||
const { data, error, isLoading, mutate } = useSWR<LikeStatusData>(key, {
|
||||
fallbackData: initialData,
|
||||
revalidateOnFocus: false,
|
||||
});
|
||||
|
||||
const toggleLike = async () => {
|
||||
if (!data) return;
|
||||
|
||||
const newLiked = !data.liked;
|
||||
const newCount = newLiked ? data.likeCount + 1 : Math.max(0, data.likeCount - 1);
|
||||
|
||||
// Optimistic update
|
||||
await mutate(
|
||||
{ liked: newLiked, likeCount: newCount },
|
||||
{ revalidate: false }
|
||||
);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/${entityType}s/${entityId}/like`, {
|
||||
method: newLiked ? 'POST' : 'DELETE',
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
// Update with server values
|
||||
await mutate(result.data, { revalidate: false });
|
||||
} else {
|
||||
// Revert on error
|
||||
await mutate(data, { revalidate: false });
|
||||
}
|
||||
} catch {
|
||||
// Revert on error
|
||||
await mutate(data, { revalidate: false });
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
data,
|
||||
isLoading,
|
||||
error,
|
||||
mutate,
|
||||
toggleLike,
|
||||
};
|
||||
}
|
||||
61
apps/web/src/hooks/useStats.ts
Normal file
61
apps/web/src/hooks/useStats.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import useSWR from 'swr';
|
||||
|
||||
export interface PlatformStats {
|
||||
packages: number;
|
||||
tools: number;
|
||||
agents: number;
|
||||
collections: number;
|
||||
executions: number;
|
||||
users: number;
|
||||
categories: { category: string; _count: { _all: number } }[];
|
||||
healthyTools: number;
|
||||
brokenTools: number;
|
||||
}
|
||||
|
||||
export interface ExecutionStats {
|
||||
total: number;
|
||||
successRate: number;
|
||||
avgDurationMs: number;
|
||||
dailyExecutions: { date: string; count: number }[];
|
||||
}
|
||||
|
||||
export interface HistorySnapshot {
|
||||
date: string;
|
||||
data: {
|
||||
packages?: number;
|
||||
tools?: number;
|
||||
agents?: number;
|
||||
collections?: number;
|
||||
users?: number;
|
||||
executions?: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch platform statistics
|
||||
* Returns multiple SWR hooks for parallel fetching
|
||||
*/
|
||||
export function useStats() {
|
||||
const stats = useSWR<PlatformStats>('/api/stats');
|
||||
const executions = useSWR<ExecutionStats>('/api/stats/executions');
|
||||
const history = useSWR<HistorySnapshot[]>('/api/sync/stats-snapshot?days=90');
|
||||
|
||||
return {
|
||||
stats: {
|
||||
data: stats.data,
|
||||
isLoading: stats.isLoading,
|
||||
error: stats.error,
|
||||
},
|
||||
executions: {
|
||||
data: executions.data,
|
||||
isLoading: executions.isLoading,
|
||||
error: executions.error,
|
||||
},
|
||||
history: {
|
||||
data: history.data,
|
||||
isLoading: history.isLoading,
|
||||
error: history.error,
|
||||
},
|
||||
isLoading: stats.isLoading || executions.isLoading || history.isLoading,
|
||||
};
|
||||
}
|
||||
51
apps/web/src/hooks/useTools.ts
Normal file
51
apps/web/src/hooks/useTools.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import useSWR from 'swr';
|
||||
|
||||
export interface Tool {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
qualityScore: string;
|
||||
likeCount?: number;
|
||||
importHealth?: 'HEALTHY' | 'BROKEN' | 'UNKNOWN';
|
||||
executionHealth?: 'HEALTHY' | 'BROKEN' | 'UNKNOWN';
|
||||
createdAt: string;
|
||||
package: {
|
||||
npmPackageName: string;
|
||||
npmVersion: string;
|
||||
npmPublishedAt: string;
|
||||
category: string;
|
||||
npmRepository: { url: string; type: string } | null;
|
||||
isOfficial: boolean;
|
||||
npmDownloadsLastMonth: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface UseToolsParams {
|
||||
category?: string;
|
||||
importHealth?: string;
|
||||
executionHealth?: string;
|
||||
broken?: boolean;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export function useTools(params: UseToolsParams = {}) {
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
if (params.category && params.category !== 'all') {
|
||||
searchParams.set('category', params.category);
|
||||
}
|
||||
if (params.importHealth) {
|
||||
searchParams.set('importHealth', params.importHealth);
|
||||
}
|
||||
if (params.executionHealth) {
|
||||
searchParams.set('executionHealth', params.executionHealth);
|
||||
}
|
||||
if (params.broken) {
|
||||
searchParams.set('broken', 'true');
|
||||
}
|
||||
searchParams.set('limit', String(params.limit ?? 1000));
|
||||
|
||||
const queryString = searchParams.toString();
|
||||
|
||||
return useSWR<Tool[]>(`/api/tools?${queryString}`);
|
||||
}
|
||||
14
apps/web/src/lib/swr/fetcher.ts
Normal file
14
apps/web/src/lib/swr/fetcher.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
/**
|
||||
* SWR fetcher that handles our API response format
|
||||
* All API responses have { success: boolean, data: T, error?: string }
|
||||
*/
|
||||
export const fetcher = async <T>(url: string): Promise<T> => {
|
||||
const res = await fetch(url);
|
||||
const json = await res.json();
|
||||
|
||||
if (!json.success) {
|
||||
throw new Error(json.error || 'Request failed');
|
||||
}
|
||||
|
||||
return json.data;
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue