feat: redesign public pages with virtualized tables
- Add sonner for toast notifications - Create CopyButton component for simple copy actions - Create CopyDropdown component with entity-specific copy options - Create PackageManagerSelector with localStorage persistence - Redesign tools page with TableVirtuoso, sort by downloads/likes/recent/name - Redesign collections page with TableVirtuoso and infinite scroll - Redesign agents page with TableVirtuoso and infinite scroll - All tables have fast client-side filtering and copy functionality
This commit is contained in:
parent
54dedc3056
commit
361a49f3d7
10 changed files with 876 additions and 480 deletions
|
|
@ -51,6 +51,7 @@
|
|||
"rehype-sanitize": "^6.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"resend": "^6.6.0",
|
||||
"sonner": "^2.0.7",
|
||||
"zod": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
|
|
@ -3,9 +3,14 @@
|
|||
import { Badge } from '@tpmjs/ui/Badge/Badge';
|
||||
import { Button } from '@tpmjs/ui/Button/Button';
|
||||
import { Icon } from '@tpmjs/ui/Icon/Icon';
|
||||
import { Input } from '@tpmjs/ui/Input/Input';
|
||||
import { Select } from '@tpmjs/ui/Select/Select';
|
||||
import { Spinner } from '@tpmjs/ui/Spinner/Spinner';
|
||||
import Link from 'next/link';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, 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 {
|
||||
|
|
@ -28,31 +33,52 @@ interface PublicAgent {
|
|||
|
||||
type SortOption = 'likes' | 'recent' | 'tools';
|
||||
|
||||
function sortAgents(agents: PublicAgent[], sortBy: SortOption): PublicAgent[] {
|
||||
return [...agents].sort((a, b) => {
|
||||
switch (sortBy) {
|
||||
case 'likes':
|
||||
return b.likeCount - a.likeCount;
|
||||
case 'recent':
|
||||
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
|
||||
case 'tools':
|
||||
return b.toolCount - a.toolCount;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function truncateText(text: string, maxLength: number): string {
|
||||
if (text.length <= maxLength) return text;
|
||||
return `${text.slice(0, maxLength).trim()}...`;
|
||||
}
|
||||
|
||||
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 [offset, setOffset] = useState(0);
|
||||
const [search, setSearch] = useState('');
|
||||
const [sort, setSort] = useState<SortOption>('likes');
|
||||
const limit = 20;
|
||||
const loadingMore = useRef(false);
|
||||
|
||||
const fetchAgents = useCallback(
|
||||
async (currentOffset: number, resetList = false) => {
|
||||
async (offset: number, resetList = false) => {
|
||||
try {
|
||||
if (loadingMore.current && !resetList) return;
|
||||
loadingMore.current = true;
|
||||
|
||||
const params = new URLSearchParams({
|
||||
limit: String(limit),
|
||||
offset: String(currentOffset),
|
||||
limit: '100',
|
||||
offset: String(offset),
|
||||
sort,
|
||||
...(search && { search }),
|
||||
});
|
||||
|
||||
const response = await fetch(`/api/public/agents?${params}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
if (resetList || currentOffset === 0) {
|
||||
if (resetList || offset === 0) {
|
||||
setAgents(data.data);
|
||||
} else {
|
||||
setAgents((prev) => [...prev, ...data.data]);
|
||||
|
|
@ -66,29 +92,111 @@ export default function PublicAgentsPage(): React.ReactElement {
|
|||
setError('Failed to fetch agents');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
loadingMore.current = false;
|
||||
}
|
||||
},
|
||||
[sort, search]
|
||||
[sort]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setOffset(0);
|
||||
setIsLoading(true);
|
||||
fetchAgents(0, true);
|
||||
}, [fetchAgents]);
|
||||
|
||||
const loadMore = () => {
|
||||
const newOffset = offset + limit;
|
||||
setOffset(newOffset);
|
||||
fetchAgents(newOffset);
|
||||
};
|
||||
const loadMore = useCallback(() => {
|
||||
if (!hasMore || loadingMore.current) return;
|
||||
fetchAgents(agents.length);
|
||||
}, [hasMore, agents.length, fetchAgents]);
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setOffset(0);
|
||||
setIsLoading(true);
|
||||
fetchAgents(0, true);
|
||||
};
|
||||
// Filter and sort agents
|
||||
const filteredAgents = useMemo(() => {
|
||||
let result = agents;
|
||||
|
||||
if (search) {
|
||||
const query = search.toLowerCase();
|
||||
result = result.filter(
|
||||
(a) =>
|
||||
a.name.toLowerCase().includes(query) ||
|
||||
a.description?.toLowerCase().includes(query) ||
|
||||
a.provider.toLowerCase().includes(query)
|
||||
);
|
||||
}
|
||||
|
||||
return sortAgents(result, sort);
|
||||
}, [agents, search, sort]);
|
||||
|
||||
const TableHeader = useCallback(
|
||||
() => (
|
||||
<tr className="bg-surface text-left text-sm font-medium text-foreground-secondary">
|
||||
<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-[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">Likes</th>
|
||||
<th className="px-4 py-3 w-[150px]">Creator</th>
|
||||
<th className="px-4 py-3 w-[100px] text-right">Copy</th>
|
||||
</tr>
|
||||
),
|
||||
[]
|
||||
);
|
||||
|
||||
const TableRow = useCallback((_index: number, agent: PublicAgent) => {
|
||||
return (
|
||||
<>
|
||||
<td className="px-4 py-3">
|
||||
<Link
|
||||
href={`/agents/${agent.id}`}
|
||||
className="font-medium text-foreground hover:text-primary transition-colors"
|
||||
>
|
||||
{agent.name}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-foreground-secondary">
|
||||
{agent.description ? truncateText(agent.description, 50) : '—'}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge variant="secondary" size="sm">
|
||||
{agent.provider}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<Badge variant="secondary" size="sm">
|
||||
{agent.toolCount}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<LikeButton
|
||||
entityType="agent"
|
||||
entityId={agent.id}
|
||||
initialCount={agent.likeCount}
|
||||
size="sm"
|
||||
showCount={true}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{agent.createdBy.image ? (
|
||||
<img
|
||||
src={agent.createdBy.image}
|
||||
alt={agent.createdBy.name}
|
||||
className="w-5 h-5 rounded-full"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-5 h-5 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<Icon icon="user" size="xs" className="text-primary" />
|
||||
</div>
|
||||
)}
|
||||
<span className="text-sm text-foreground-secondary truncate max-w-[100px]">
|
||||
{agent.createdBy.name}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<CopyDropdown options={getAgentCopyOptions(agent.uid, agent.name)} buttonLabel="Copy" />
|
||||
</td>
|
||||
</>
|
||||
);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
|
|
@ -105,34 +213,26 @@ export default function PublicAgentsPage(): React.ReactElement {
|
|||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-col sm:flex-row gap-4 mb-6">
|
||||
<form onSubmit={handleSearch} className="flex-1">
|
||||
<div className="relative">
|
||||
<Icon
|
||||
icon="search"
|
||||
size="sm"
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 text-foreground-tertiary"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search agents..."
|
||||
className="w-full pl-10 pr-4 py-2 bg-surface border border-border rounded-lg text-foreground placeholder:text-foreground-tertiary focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search agents..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-foreground-secondary">Sort:</span>
|
||||
<select
|
||||
<Select
|
||||
value={sort}
|
||||
onChange={(e) => setSort(e.target.value as SortOption)}
|
||||
className="px-3 py-2 bg-surface border border-border rounded-lg text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
>
|
||||
<option value="likes">Most Liked</option>
|
||||
<option value="recent">Most Recent</option>
|
||||
<option value="tools">Most Tools</option>
|
||||
</select>
|
||||
options={[
|
||||
{ value: 'likes', label: 'Most Liked' },
|
||||
{ value: 'recent', label: 'Most Recent' },
|
||||
{ value: 'tools', label: 'Most Tools' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -145,20 +245,11 @@ export default function PublicAgentsPage(): React.ReactElement {
|
|||
<Button onClick={() => fetchAgents(0, true)}>Try Again</Button>
|
||||
</div>
|
||||
) : isLoading ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="bg-background border border-border rounded-lg p-6 animate-pulse"
|
||||
>
|
||||
<div className="h-6 bg-surface-secondary rounded w-3/4 mb-3" />
|
||||
<div className="h-4 bg-surface-secondary rounded w-full mb-2" />
|
||||
<div className="h-4 bg-surface-secondary rounded w-2/3 mb-4" />
|
||||
<div className="h-4 bg-surface-secondary rounded w-1/3" />
|
||||
</div>
|
||||
))}
|
||||
<div className="flex items-center justify-center py-24 gap-4">
|
||||
<Spinner size="lg" />
|
||||
<span className="text-foreground-secondary font-mono text-sm">Loading agents...</span>
|
||||
</div>
|
||||
) : agents.length === 0 ? (
|
||||
) : filteredAgents.length === 0 ? (
|
||||
<div className="text-center py-16">
|
||||
<div className="w-16 h-16 rounded-full bg-primary/10 flex items-center justify-center mx-auto mb-4">
|
||||
<Icon icon="terminal" size="lg" className="text-primary" />
|
||||
|
|
@ -170,75 +261,42 @@ export default function PublicAgentsPage(): React.ReactElement {
|
|||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{agents.map((agent) => (
|
||||
<div
|
||||
key={agent.id}
|
||||
className="bg-background border border-border rounded-lg p-6 hover:border-foreground/20 transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<Link
|
||||
href={`/agents/${agent.id}`}
|
||||
className="text-lg font-medium text-foreground hover:text-primary transition-colors"
|
||||
>
|
||||
{agent.name}
|
||||
</Link>
|
||||
<LikeButton
|
||||
entityType="agent"
|
||||
entityId={agent.id}
|
||||
initialCount={agent.likeCount}
|
||||
size="sm"
|
||||
<div className="border border-border rounded-lg overflow-hidden">
|
||||
<TableVirtuoso
|
||||
style={{ height: 'calc(100vh - 350px)', minHeight: '400px' }}
|
||||
data={filteredAgents}
|
||||
overscan={30}
|
||||
endReached={loadMore}
|
||||
fixedHeaderContent={TableHeader}
|
||||
itemContent={TableRow}
|
||||
components={{
|
||||
Table: (props) => (
|
||||
<table
|
||||
{...props}
|
||||
className="w-full border-collapse text-sm"
|
||||
style={{ tableLayout: 'fixed' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{agent.description && (
|
||||
<p className="text-sm text-foreground-secondary line-clamp-2 mb-3">
|
||||
{agent.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Badge variant="secondary" size="sm">
|
||||
{agent.provider}
|
||||
</Badge>
|
||||
<span className="text-xs text-foreground-tertiary">{agent.modelId}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<div className="flex items-center gap-3 text-foreground-tertiary">
|
||||
<span className="flex items-center gap-1">
|
||||
<Icon icon="puzzle" size="xs" />
|
||||
{agent.toolCount} tool{agent.toolCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{agent.createdBy.image ? (
|
||||
<img
|
||||
src={agent.createdBy.image}
|
||||
alt={agent.createdBy.name}
|
||||
className="w-5 h-5 rounded-full"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-5 h-5 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<Icon icon="user" size="xs" className="text-primary" />
|
||||
</div>
|
||||
)}
|
||||
<span className="text-xs text-foreground-tertiary">
|
||||
{agent.createdBy.name}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
),
|
||||
TableHead: (props) => (
|
||||
<thead {...props} className="bg-surface sticky top-0 z-10" />
|
||||
),
|
||||
TableBody: (props) => <tbody {...props} />,
|
||||
TableRow: (props) => (
|
||||
<tr
|
||||
{...props}
|
||||
className="border-b border-border hover:bg-surface/50 transition-colors"
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{hasMore && (
|
||||
<div className="mt-8 text-center">
|
||||
<Button variant="outline" onClick={loadMore}>
|
||||
Load More
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-4 text-sm text-foreground-tertiary">
|
||||
Showing {filteredAgents.length} agent
|
||||
{filteredAgents.length !== 1 ? 's' : ''}
|
||||
{search && ` matching "${search}"`}
|
||||
{hasMore && ' (scroll for more)'}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,16 @@
|
|||
'use client';
|
||||
|
||||
import { Badge } from '@tpmjs/ui/Badge/Badge';
|
||||
import { Button } from '@tpmjs/ui/Button/Button';
|
||||
import { Icon } from '@tpmjs/ui/Icon/Icon';
|
||||
import { Input } from '@tpmjs/ui/Input/Input';
|
||||
import { Select } from '@tpmjs/ui/Select/Select';
|
||||
import { Spinner } from '@tpmjs/ui/Spinner/Spinner';
|
||||
import Link from 'next/link';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { TableVirtuoso } from 'react-virtuoso';
|
||||
import { AppHeader } from '~/components/AppHeader';
|
||||
import { CopyDropdown, getCollectionCopyOptions } from '~/components/CopyDropdown';
|
||||
import { LikeButton } from '~/components/LikeButton';
|
||||
|
||||
interface PublicCollection {
|
||||
|
|
@ -23,31 +29,52 @@ interface PublicCollection {
|
|||
|
||||
type SortOption = 'likes' | 'recent' | 'tools';
|
||||
|
||||
function sortCollections(collections: PublicCollection[], sortBy: SortOption): PublicCollection[] {
|
||||
return [...collections].sort((a, b) => {
|
||||
switch (sortBy) {
|
||||
case 'likes':
|
||||
return b.likeCount - a.likeCount;
|
||||
case 'recent':
|
||||
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
|
||||
case 'tools':
|
||||
return b.toolCount - a.toolCount;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function truncateText(text: string, maxLength: number): string {
|
||||
if (text.length <= maxLength) return text;
|
||||
return `${text.slice(0, maxLength).trim()}...`;
|
||||
}
|
||||
|
||||
export default function PublicCollectionsPage(): React.ReactElement {
|
||||
const [collections, setCollections] = useState<PublicCollection[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [search, setSearch] = useState('');
|
||||
const [sort, setSort] = useState<SortOption>('likes');
|
||||
const limit = 20;
|
||||
const loadingMore = useRef(false);
|
||||
|
||||
const fetchCollections = useCallback(
|
||||
async (currentOffset: number, resetList = false) => {
|
||||
async (offset: number, resetList = false) => {
|
||||
try {
|
||||
if (loadingMore.current && !resetList) return;
|
||||
loadingMore.current = true;
|
||||
|
||||
const params = new URLSearchParams({
|
||||
limit: String(limit),
|
||||
offset: String(currentOffset),
|
||||
limit: '100',
|
||||
offset: String(offset),
|
||||
sort,
|
||||
...(search && { search }),
|
||||
});
|
||||
|
||||
const response = await fetch(`/api/public/collections?${params}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
if (resetList || currentOffset === 0) {
|
||||
if (resetList || offset === 0) {
|
||||
setCollections(data.data);
|
||||
} else {
|
||||
setCollections((prev) => [...prev, ...data.data]);
|
||||
|
|
@ -61,29 +88,105 @@ export default function PublicCollectionsPage(): React.ReactElement {
|
|||
setError('Failed to fetch collections');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
loadingMore.current = false;
|
||||
}
|
||||
},
|
||||
[sort, search]
|
||||
[sort]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setOffset(0);
|
||||
setIsLoading(true);
|
||||
fetchCollections(0, true);
|
||||
}, [fetchCollections]);
|
||||
|
||||
const loadMore = () => {
|
||||
const newOffset = offset + limit;
|
||||
setOffset(newOffset);
|
||||
fetchCollections(newOffset);
|
||||
};
|
||||
const loadMore = useCallback(() => {
|
||||
if (!hasMore || loadingMore.current) return;
|
||||
fetchCollections(collections.length);
|
||||
}, [hasMore, collections.length, fetchCollections]);
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setOffset(0);
|
||||
setIsLoading(true);
|
||||
fetchCollections(0, true);
|
||||
};
|
||||
// Filter and sort collections
|
||||
const filteredCollections = useMemo(() => {
|
||||
let result = collections;
|
||||
|
||||
if (search) {
|
||||
const query = search.toLowerCase();
|
||||
result = result.filter(
|
||||
(c) => c.name.toLowerCase().includes(query) || c.description?.toLowerCase().includes(query)
|
||||
);
|
||||
}
|
||||
|
||||
return sortCollections(result, sort);
|
||||
}, [collections, search, sort]);
|
||||
|
||||
const TableHeader = useCallback(
|
||||
() => (
|
||||
<tr className="bg-surface text-left text-sm font-medium text-foreground-secondary">
|
||||
<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-[80px] text-center">Tools</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-[100px] text-right">Copy</th>
|
||||
</tr>
|
||||
),
|
||||
[]
|
||||
);
|
||||
|
||||
const TableRow = useCallback((_index: number, collection: PublicCollection) => {
|
||||
return (
|
||||
<>
|
||||
<td className="px-4 py-3">
|
||||
<Link
|
||||
href={`/collections/${collection.id}`}
|
||||
className="font-medium text-foreground hover:text-primary transition-colors"
|
||||
>
|
||||
{collection.name}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-foreground-secondary">
|
||||
{collection.description ? truncateText(collection.description, 60) : '—'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<Badge variant="secondary" size="sm">
|
||||
{collection.toolCount}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<LikeButton
|
||||
entityType="collection"
|
||||
entityId={collection.id}
|
||||
initialCount={collection.likeCount}
|
||||
size="sm"
|
||||
showCount={true}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{collection.createdBy.image ? (
|
||||
<img
|
||||
src={collection.createdBy.image}
|
||||
alt={collection.createdBy.name}
|
||||
className="w-5 h-5 rounded-full"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-5 h-5 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<Icon icon="user" size="xs" className="text-primary" />
|
||||
</div>
|
||||
)}
|
||||
<span className="text-sm text-foreground-secondary truncate max-w-[100px]">
|
||||
{collection.createdBy.name}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<CopyDropdown
|
||||
options={getCollectionCopyOptions(collection.id, collection.name)}
|
||||
buttonLabel="Copy"
|
||||
/>
|
||||
</td>
|
||||
</>
|
||||
);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
|
|
@ -100,34 +203,26 @@ export default function PublicCollectionsPage(): React.ReactElement {
|
|||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-col sm:flex-row gap-4 mb-6">
|
||||
<form onSubmit={handleSearch} className="flex-1">
|
||||
<div className="relative">
|
||||
<Icon
|
||||
icon="search"
|
||||
size="sm"
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 text-foreground-tertiary"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search collections..."
|
||||
className="w-full pl-10 pr-4 py-2 bg-surface border border-border rounded-lg text-foreground placeholder:text-foreground-tertiary focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search collections..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-foreground-secondary">Sort:</span>
|
||||
<select
|
||||
<Select
|
||||
value={sort}
|
||||
onChange={(e) => setSort(e.target.value as SortOption)}
|
||||
className="px-3 py-2 bg-surface border border-border rounded-lg text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
>
|
||||
<option value="likes">Most Liked</option>
|
||||
<option value="recent">Most Recent</option>
|
||||
<option value="tools">Most Tools</option>
|
||||
</select>
|
||||
options={[
|
||||
{ value: 'likes', label: 'Most Liked' },
|
||||
{ value: 'recent', label: 'Most Recent' },
|
||||
{ value: 'tools', label: 'Most Tools' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -140,20 +235,13 @@ export default function PublicCollectionsPage(): React.ReactElement {
|
|||
<Button onClick={() => fetchCollections(0, true)}>Try Again</Button>
|
||||
</div>
|
||||
) : isLoading ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="bg-background border border-border rounded-lg p-6 animate-pulse"
|
||||
>
|
||||
<div className="h-6 bg-surface-secondary rounded w-3/4 mb-3" />
|
||||
<div className="h-4 bg-surface-secondary rounded w-full mb-2" />
|
||||
<div className="h-4 bg-surface-secondary rounded w-2/3 mb-4" />
|
||||
<div className="h-4 bg-surface-secondary rounded w-1/3" />
|
||||
</div>
|
||||
))}
|
||||
<div className="flex items-center justify-center py-24 gap-4">
|
||||
<Spinner size="lg" />
|
||||
<span className="text-foreground-secondary font-mono text-sm">
|
||||
Loading collections...
|
||||
</span>
|
||||
</div>
|
||||
) : collections.length === 0 ? (
|
||||
) : filteredCollections.length === 0 ? (
|
||||
<div className="text-center py-16">
|
||||
<div className="w-16 h-16 rounded-full bg-primary/10 flex items-center justify-center mx-auto mb-4">
|
||||
<Icon icon="folder" size="lg" className="text-primary" />
|
||||
|
|
@ -167,68 +255,42 @@ export default function PublicCollectionsPage(): React.ReactElement {
|
|||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{collections.map((collection) => (
|
||||
<div
|
||||
key={collection.id}
|
||||
className="bg-background border border-border rounded-lg p-6 hover:border-foreground/20 transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<Link
|
||||
href={`/collections/${collection.id}`}
|
||||
className="text-lg font-medium text-foreground hover:text-primary transition-colors"
|
||||
>
|
||||
{collection.name}
|
||||
</Link>
|
||||
<LikeButton
|
||||
entityType="collection"
|
||||
entityId={collection.id}
|
||||
initialCount={collection.likeCount}
|
||||
size="sm"
|
||||
<div className="border border-border rounded-lg overflow-hidden">
|
||||
<TableVirtuoso
|
||||
style={{ height: 'calc(100vh - 350px)', minHeight: '400px' }}
|
||||
data={filteredCollections}
|
||||
overscan={30}
|
||||
endReached={loadMore}
|
||||
fixedHeaderContent={TableHeader}
|
||||
itemContent={TableRow}
|
||||
components={{
|
||||
Table: (props) => (
|
||||
<table
|
||||
{...props}
|
||||
className="w-full border-collapse text-sm"
|
||||
style={{ tableLayout: 'fixed' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{collection.description && (
|
||||
<p className="text-sm text-foreground-secondary line-clamp-2 mb-4">
|
||||
{collection.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<div className="flex items-center gap-3 text-foreground-tertiary">
|
||||
<span className="flex items-center gap-1">
|
||||
<Icon icon="puzzle" size="xs" />
|
||||
{collection.toolCount} tool{collection.toolCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{collection.createdBy.image ? (
|
||||
<img
|
||||
src={collection.createdBy.image}
|
||||
alt={collection.createdBy.name}
|
||||
className="w-5 h-5 rounded-full"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-5 h-5 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<Icon icon="user" size="xs" className="text-primary" />
|
||||
</div>
|
||||
)}
|
||||
<span className="text-xs text-foreground-tertiary">
|
||||
{collection.createdBy.name}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
),
|
||||
TableHead: (props) => (
|
||||
<thead {...props} className="bg-surface sticky top-0 z-10" />
|
||||
),
|
||||
TableBody: (props) => <tbody {...props} />,
|
||||
TableRow: (props) => (
|
||||
<tr
|
||||
{...props}
|
||||
className="border-b border-border hover:bg-surface/50 transition-colors"
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{hasMore && (
|
||||
<div className="mt-8 text-center">
|
||||
<Button variant="outline" onClick={loadMore}>
|
||||
Load More
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-4 text-sm text-foreground-tertiary">
|
||||
Showing {filteredCollections.length} collection
|
||||
{filteredCollections.length !== 1 ? 's' : ''}
|
||||
{search && ` matching "${search}"`}
|
||||
{hasMore && ' (scroll for more)'}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { Analytics } from '@vercel/analytics/next';
|
|||
import type { Metadata } from 'next';
|
||||
import { Space_Grotesk, Space_Mono } from 'next/font/google';
|
||||
import Script from 'next/script';
|
||||
import { Toaster } from 'sonner';
|
||||
import { AppFooter } from '../components/AppFooter';
|
||||
import { ThemeProvider } from '../components/providers/ThemeProvider';
|
||||
import './globals.css';
|
||||
|
|
@ -160,6 +161,7 @@ export default function RootLayout({
|
|||
<div className="flex-1">{children}</div>
|
||||
<AppFooter />
|
||||
</div>
|
||||
<Toaster position="bottom-right" richColors closeButton />
|
||||
</ThemeProvider>
|
||||
<Analytics />
|
||||
</body>
|
||||
|
|
|
|||
|
|
@ -2,24 +2,29 @@
|
|||
|
||||
import { Badge } from '@tpmjs/ui/Badge/Badge';
|
||||
import { Button } from '@tpmjs/ui/Button/Button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@tpmjs/ui/Card/Card';
|
||||
import { CodeBlock } from '@tpmjs/ui/CodeBlock/CodeBlock';
|
||||
import { Card, CardContent } from '@tpmjs/ui/Card/Card';
|
||||
import { Container } from '@tpmjs/ui/Container/Container';
|
||||
import { Icon } from '@tpmjs/ui/Icon/Icon';
|
||||
import { Input } from '@tpmjs/ui/Input/Input';
|
||||
import { ProgressBar } from '@tpmjs/ui/ProgressBar/ProgressBar';
|
||||
import { Select } from '@tpmjs/ui/Select/Select';
|
||||
import { Spinner } from '@tpmjs/ui/Spinner/Spinner';
|
||||
import { formatTimeAgo } from '@tpmjs/utils/format';
|
||||
import Link from 'next/link';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { TableVirtuoso } from 'react-virtuoso';
|
||||
import { AppHeader } from '~/components/AppHeader';
|
||||
import { CopyButton } from '~/components/CopyButton';
|
||||
import {
|
||||
PackageManagerSelector,
|
||||
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;
|
||||
|
|
@ -34,7 +39,13 @@ interface Tool {
|
|||
};
|
||||
}
|
||||
|
||||
type SortOption = 'downloads' | 'recent';
|
||||
type SortOption = 'downloads' | 'likes' | 'recent' | 'name';
|
||||
|
||||
function formatDownloads(count: number): string {
|
||||
if (count >= 1000000) return `${(count / 1000000).toFixed(1)}M`;
|
||||
if (count >= 1000) return `${(count / 1000).toFixed(1)}k`;
|
||||
return count.toString();
|
||||
}
|
||||
|
||||
/** Sort tools by criterion, pushing broken tools to the bottom */
|
||||
function sortTools(tools: Tool[], sortBy: SortOption): Tool[] {
|
||||
|
|
@ -44,24 +55,35 @@ function sortTools(tools: Tool[], sortBy: SortOption): Tool[] {
|
|||
// Always push broken tools to bottom
|
||||
if (aIsBroken && !bIsBroken) return 1;
|
||||
if (!aIsBroken && bIsBroken) return -1;
|
||||
|
||||
// Within same broken status, sort by selected criterion
|
||||
if (sortBy === 'downloads') {
|
||||
const aDownloads = a.package.npmDownloadsLastMonth ?? 0;
|
||||
const bDownloads = b.package.npmDownloadsLastMonth ?? 0;
|
||||
return bDownloads - aDownloads;
|
||||
switch (sortBy) {
|
||||
case 'downloads': {
|
||||
const aDownloads = a.package.npmDownloadsLastMonth ?? 0;
|
||||
const bDownloads = b.package.npmDownloadsLastMonth ?? 0;
|
||||
return bDownloads - aDownloads;
|
||||
}
|
||||
case 'likes': {
|
||||
const aLikes = a.likeCount ?? 0;
|
||||
const bLikes = b.likeCount ?? 0;
|
||||
return bLikes - aLikes;
|
||||
}
|
||||
case 'recent': {
|
||||
const aTime = a.createdAt ? new Date(a.createdAt).getTime() : 0;
|
||||
const bTime = b.createdAt ? new Date(b.createdAt).getTime() : 0;
|
||||
return bTime - aTime;
|
||||
}
|
||||
case 'name': {
|
||||
const aName = a.name.toLowerCase();
|
||||
const bName = b.name.toLowerCase();
|
||||
return aName.localeCompare(bName);
|
||||
}
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
// Sort by recent (createdAt descending)
|
||||
const aTime = a.createdAt ? new Date(a.createdAt).getTime() : 0;
|
||||
const bTime = b.createdAt ? new Date(b.createdAt).getTime() : 0;
|
||||
return bTime - aTime;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool Registry Search Page
|
||||
*
|
||||
* Fetches tools from the /api/tools endpoint and displays them in a searchable grid.
|
||||
*/
|
||||
export default function ToolSearchPage(): React.ReactElement {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [categoryFilter, setCategoryFilter] = useState('all');
|
||||
|
|
@ -71,19 +93,15 @@ export default function ToolSearchPage(): React.ReactElement {
|
|||
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(() => {
|
||||
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Tool search page requires complex filtering logic
|
||||
const fetchTools = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (searchQuery) {
|
||||
params.set('q', searchQuery);
|
||||
}
|
||||
|
||||
if (categoryFilter !== 'all') {
|
||||
params.set('category', categoryFilter);
|
||||
}
|
||||
|
|
@ -102,18 +120,16 @@ export default function ToolSearchPage(): React.ReactElement {
|
|||
|
||||
if (toolsData.success) {
|
||||
const fetchedTools = toolsData.data;
|
||||
setTools(sortTools(fetchedTools, sortBy));
|
||||
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');
|
||||
|
|
@ -126,13 +142,93 @@ export default function ToolSearchPage(): React.ReactElement {
|
|||
};
|
||||
|
||||
fetchTools();
|
||||
}, [searchQuery, categoryFilter, healthFilter, sortBy]);
|
||||
}, [categoryFilter, healthFilter]);
|
||||
|
||||
// Filter and sort tools
|
||||
const filteredTools = useMemo(() => {
|
||||
let result = tools;
|
||||
|
||||
// Apply search filter
|
||||
if (searchQuery) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
result = result.filter(
|
||||
(tool) =>
|
||||
tool.name.toLowerCase().includes(query) ||
|
||||
tool.package.npmPackageName.toLowerCase().includes(query) ||
|
||||
tool.description.toLowerCase().includes(query)
|
||||
);
|
||||
}
|
||||
|
||||
// Sort tools
|
||||
return sortTools(result, sortBy);
|
||||
}, [tools, searchQuery, sortBy]);
|
||||
|
||||
const TableHeader = useCallback(
|
||||
() => (
|
||||
<tr className="bg-surface text-left text-sm font-medium text-foreground-secondary">
|
||||
<th className="px-4 py-3 w-[300px]">Name</th>
|
||||
<th className="px-4 py-3 w-[120px]">Category</th>
|
||||
<th className="px-4 py-3 w-[100px] text-right">Downloads</th>
|
||||
<th className="px-4 py-3 w-[80px] text-right">Likes</th>
|
||||
<th className="px-4 py-3 w-[120px] text-right">Install</th>
|
||||
</tr>
|
||||
),
|
||||
[]
|
||||
);
|
||||
|
||||
const TableRow = useCallback(
|
||||
(_index: number, tool: Tool) => {
|
||||
const isBroken = tool.importHealth === 'BROKEN' || tool.executionHealth === 'BROKEN';
|
||||
const displayName = tool.name !== 'default' ? tool.name : tool.package.npmPackageName;
|
||||
const installCommand = getInstallCommand(tool.package.npmPackageName, packageManager);
|
||||
|
||||
return (
|
||||
<>
|
||||
<td className="px-4 py-3">
|
||||
<Link
|
||||
href={`/tool/${tool.package.npmPackageName}/${tool.name}`}
|
||||
className="group block"
|
||||
>
|
||||
<div className="font-medium text-foreground group-hover:text-primary transition-colors">
|
||||
{displayName}
|
||||
{isBroken && (
|
||||
<Badge variant="error" size="sm" className="ml-2">
|
||||
Broken
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-foreground-tertiary truncate max-w-[280px]">
|
||||
{tool.package.npmPackageName}
|
||||
</div>
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge variant="secondary" size="sm">
|
||||
{tool.package.category}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-sm text-foreground-secondary tabular-nums">
|
||||
{formatDownloads(tool.package.npmDownloadsLastMonth)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<span className="inline-flex items-center gap-1 text-sm text-foreground-secondary">
|
||||
<Icon icon="heart" size="xs" />
|
||||
{tool.likeCount ?? 0}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<CopyButton text={installCommand} label="Copy" size="xs" />
|
||||
</td>
|
||||
</>
|
||||
);
|
||||
},
|
||||
[packageManager]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<AppHeader />
|
||||
|
||||
{/* Main content */}
|
||||
<Container size="xl" padding="md" className="py-8">
|
||||
{/* Page header */}
|
||||
<div className="space-y-4 mb-8">
|
||||
|
|
@ -155,9 +251,9 @@ export default function ToolSearchPage(): React.ReactElement {
|
|||
/>
|
||||
|
||||
{/* Filter row */}
|
||||
<div className="flex flex-col sm:flex-row flex-wrap gap-3 sm:gap-4">
|
||||
<div className="flex flex-col sm:flex-row flex-wrap gap-3 sm:gap-4 items-start sm:items-center">
|
||||
{/* Category filter */}
|
||||
<div className="flex items-center gap-2 w-full sm:w-auto">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-foreground-secondary whitespace-nowrap">
|
||||
Category:
|
||||
</span>
|
||||
|
|
@ -165,7 +261,7 @@ export default function ToolSearchPage(): React.ReactElement {
|
|||
value={categoryFilter}
|
||||
onChange={(e) => setCategoryFilter(e.target.value)}
|
||||
size="sm"
|
||||
className="flex-1 sm:flex-none sm:min-w-[150px]"
|
||||
className="min-w-[150px]"
|
||||
options={[
|
||||
{ value: 'all', label: 'All Categories' },
|
||||
...availableCategories.map((cat) => ({
|
||||
|
|
@ -177,7 +273,7 @@ export default function ToolSearchPage(): React.ReactElement {
|
|||
</div>
|
||||
|
||||
{/* Health filter */}
|
||||
<div className="flex items-center gap-2 w-full sm:w-auto">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-foreground-secondary whitespace-nowrap">
|
||||
Health:
|
||||
</span>
|
||||
|
|
@ -185,7 +281,7 @@ export default function ToolSearchPage(): React.ReactElement {
|
|||
value={healthFilter}
|
||||
onChange={(e) => setHealthFilter(e.target.value)}
|
||||
size="sm"
|
||||
className="flex-1 sm:flex-none sm:min-w-[130px]"
|
||||
className="min-w-[130px]"
|
||||
options={[
|
||||
{ value: 'all', label: 'All Tools' },
|
||||
{ value: 'healthy', label: 'Healthy Only' },
|
||||
|
|
@ -195,7 +291,7 @@ export default function ToolSearchPage(): React.ReactElement {
|
|||
</div>
|
||||
|
||||
{/* Sort dropdown */}
|
||||
<div className="flex items-center gap-2 w-full sm:w-auto">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-foreground-secondary whitespace-nowrap">
|
||||
Sort:
|
||||
</span>
|
||||
|
|
@ -203,10 +299,12 @@ export default function ToolSearchPage(): React.ReactElement {
|
|||
value={sortBy}
|
||||
onChange={(e) => setSortBy(e.target.value as SortOption)}
|
||||
size="sm"
|
||||
className="flex-1 sm:flex-none sm:min-w-[150px]"
|
||||
className="min-w-[150px]"
|
||||
options={[
|
||||
{ value: 'downloads', label: 'Most Downloaded' },
|
||||
{ value: 'downloads', label: 'Downloads' },
|
||||
{ value: 'likes', label: 'Likes' },
|
||||
{ value: 'recent', label: 'Recent' },
|
||||
{ value: 'name', label: 'Name (A-Z)' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -225,6 +323,9 @@ export default function ToolSearchPage(): React.ReactElement {
|
|||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Package manager selector */}
|
||||
<PackageManagerSelector value={packageManager} onChange={setPackageManager} />
|
||||
</div>
|
||||
|
||||
{/* Loading state */}
|
||||
|
|
@ -240,141 +341,55 @@ export default function ToolSearchPage(): React.ReactElement {
|
|||
{/* Error state */}
|
||||
{error && <div className="text-center py-12 text-red-500">Error: {error}</div>}
|
||||
|
||||
{/* Tool grid */}
|
||||
{!loading && !error && tools.length > 0 && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 md:gap-6">
|
||||
{tools.map((tool) => {
|
||||
const isBroken = tool.importHealth === 'BROKEN' || tool.executionHealth === 'BROKEN';
|
||||
const qualityPercent = Math.round(Number.parseFloat(tool.qualityScore) * 100);
|
||||
{/* Tool table */}
|
||||
{!loading && !error && filteredTools.length > 0 && (
|
||||
<div className="border border-border rounded-lg overflow-hidden">
|
||||
<TableVirtuoso
|
||||
style={{ height: 'calc(100vh - 400px)', minHeight: '400px' }}
|
||||
data={filteredTools}
|
||||
overscan={50}
|
||||
fixedHeaderContent={TableHeader}
|
||||
itemContent={TableRow}
|
||||
components={{
|
||||
Table: (props) => (
|
||||
<table
|
||||
{...props}
|
||||
className="w-full border-collapse text-sm"
|
||||
style={{ tableLayout: 'fixed' }}
|
||||
/>
|
||||
),
|
||||
TableHead: (props) => <thead {...props} className="bg-surface sticky top-0 z-10" />,
|
||||
TableBody: (props) => <tbody {...props} />,
|
||||
TableRow: (props) => (
|
||||
<tr
|
||||
{...props}
|
||||
className="border-b border-border hover:bg-surface/50 transition-colors"
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
// Clean up repository URL
|
||||
let repoUrl = tool.package.npmRepository?.url || '';
|
||||
repoUrl = repoUrl.replace(/^git\+/, '');
|
||||
repoUrl = repoUrl.replace(/\.git$/, '');
|
||||
repoUrl = repoUrl.replace(/^git:\/\//, 'https://');
|
||||
repoUrl = repoUrl.replace(/^git@github\.com:/, 'https://github.com/');
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={tool.id}
|
||||
href={`/tool/${tool.package.npmPackageName}/${tool.name}`}
|
||||
className="block select-text"
|
||||
>
|
||||
<Card className="flex flex-col h-full hover:border-foreground-tertiary transition-colors cursor-pointer select-text">
|
||||
<CardHeader className="flex-none">
|
||||
{/* Top row: Title + metadata */}
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<CardTitle className="truncate">
|
||||
{tool.name !== 'default' ? tool.name : tool.package.npmPackageName}
|
||||
</CardTitle>
|
||||
<div className="text-sm text-foreground-secondary mt-1 truncate">
|
||||
{tool.package.npmPackageName}
|
||||
</div>
|
||||
</div>
|
||||
{/* Right side: downloads, version, link */}
|
||||
<div className="flex items-center gap-2 flex-shrink-0 text-xs text-foreground-tertiary">
|
||||
<span>{tool.package.npmDownloadsLastMonth.toLocaleString()}/mo</span>
|
||||
<span>v{tool.package.npmVersion}</span>
|
||||
{repoUrl && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
window.open(repoUrl, '_blank', 'noopener,noreferrer');
|
||||
}}
|
||||
className="text-foreground-secondary hover:text-foreground transition-colors cursor-pointer"
|
||||
>
|
||||
<Icon icon="externalLink" size="sm" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Description */}
|
||||
<CardDescription className="line-clamp-2 min-h-[2.5rem]">
|
||||
{tool.description}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex-1 flex flex-col gap-4">
|
||||
{/* Category badge */}
|
||||
<div className="flex items-center">
|
||||
<Badge variant="secondary" size="sm">
|
||||
{tool.package.category}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Quality + Broken status row */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-1 flex items-center gap-2">
|
||||
<ProgressBar
|
||||
value={qualityPercent}
|
||||
variant={
|
||||
isBroken
|
||||
? 'danger'
|
||||
: qualityPercent >= 70
|
||||
? 'success'
|
||||
: qualityPercent >= 50
|
||||
? 'primary'
|
||||
: 'warning'
|
||||
}
|
||||
size="sm"
|
||||
showLabel={false}
|
||||
className="flex-1"
|
||||
/>
|
||||
<span className="text-xs font-medium text-foreground-secondary w-8">
|
||||
{qualityPercent}%
|
||||
</span>
|
||||
</div>
|
||||
{isBroken && (
|
||||
<Badge variant="error" size="sm">
|
||||
Broken
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bottom section with install command and published date */}
|
||||
<div className="mt-auto space-y-2">
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
role="presentation"
|
||||
>
|
||||
<CodeBlock
|
||||
code={`npm install ${tool.package.npmPackageName}`}
|
||||
language="bash"
|
||||
size="sm"
|
||||
showCopy={true}
|
||||
/>
|
||||
</div>
|
||||
{tool.package.npmPublishedAt && (
|
||||
<div className="text-xs text-foreground-tertiary">
|
||||
Published {formatTimeAgo(tool.package.npmPublishedAt)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
{/* Results count */}
|
||||
{!loading && !error && filteredTools.length > 0 && (
|
||||
<div className="mt-4 text-sm text-foreground-tertiary">
|
||||
Showing {filteredTools.length} tool{filteredTools.length !== 1 ? 's' : ''}
|
||||
{searchQuery && ` matching "${searchQuery}"`}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty States */}
|
||||
{!loading && !error && tools.length === 0 && (
|
||||
{!loading && !error && filteredTools.length === 0 && (
|
||||
<div className="flex items-center justify-center py-24">
|
||||
<Card className="max-w-2xl w-full">
|
||||
<CardContent className="pt-6 pb-6 text-center space-y-6">
|
||||
{/* Icon/Visual Element */}
|
||||
<div className="flex justify-center">
|
||||
<div className="w-16 h-16 rounded-full bg-muted flex items-center justify-center">
|
||||
<Icon icon="x" size="lg" className="text-foreground-tertiary" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search query with no results */}
|
||||
{searchQuery && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
|
|
@ -405,7 +420,6 @@ export default function ToolSearchPage(): React.ReactElement {
|
|||
</>
|
||||
)}
|
||||
|
||||
{/* Filters active but no search query */}
|
||||
{!searchQuery && (categoryFilter !== 'all' || healthFilter !== 'all') && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
|
|
@ -415,32 +429,19 @@ export default function ToolSearchPage(): React.ReactElement {
|
|||
<p className="text-foreground-secondary">
|
||||
Try adjusting or clearing your filters to see more tools.
|
||||
</p>
|
||||
{categoryFilter !== 'all' && (
|
||||
<p className="text-sm text-foreground-tertiary">
|
||||
Current filter: Category = {categoryFilter}
|
||||
</p>
|
||||
)}
|
||||
{healthFilter !== 'all' && (
|
||||
<p className="text-sm text-foreground-tertiary">
|
||||
Current filter: Health = {healthFilter}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row gap-3 justify-center">
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => {
|
||||
setCategoryFilter('all');
|
||||
setHealthFilter('all');
|
||||
}}
|
||||
>
|
||||
Clear All Filters
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => {
|
||||
setCategoryFilter('all');
|
||||
setHealthFilter('all');
|
||||
}}
|
||||
>
|
||||
Clear All Filters
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* No tools at all (edge case) */}
|
||||
{!searchQuery && categoryFilter === 'all' && healthFilter === 'all' && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
|
|
@ -449,64 +450,15 @@ export default function ToolSearchPage(): React.ReactElement {
|
|||
Be the first to publish a tool and help AI agents gain new capabilities.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row gap-3 justify-center">
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() =>
|
||||
window.open('https://github.com/tpmjs/tpmjs', '_blank', 'noopener')
|
||||
}
|
||||
>
|
||||
<Icon icon="github" size="sm" className="mr-2" />
|
||||
View Documentation
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
window.open(
|
||||
'https://www.npmjs.com/search?q=keywords:tpmjs',
|
||||
'_blank',
|
||||
'noopener'
|
||||
)
|
||||
}
|
||||
>
|
||||
Browse on npm
|
||||
</Button>
|
||||
</div>
|
||||
<div className="pt-4 border-t border-border mt-6">
|
||||
<p className="text-sm text-foreground-tertiary mb-4">
|
||||
Publishing a tool is easy:
|
||||
</p>
|
||||
<div className="space-y-3 text-left max-w-md mx-auto">
|
||||
<div className="flex gap-3">
|
||||
<div className="flex-shrink-0 w-6 h-6 rounded-full bg-primary/10 text-primary flex items-center justify-center text-xs font-bold">
|
||||
1
|
||||
</div>
|
||||
<p className="text-sm text-foreground-secondary">
|
||||
Add{' '}
|
||||
<code className="px-1.5 py-0.5 bg-muted rounded text-xs">tpmjs</code>{' '}
|
||||
keyword to your package.json
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<div className="flex-shrink-0 w-6 h-6 rounded-full bg-primary/10 text-primary flex items-center justify-center text-xs font-bold">
|
||||
2
|
||||
</div>
|
||||
<p className="text-sm text-foreground-secondary">
|
||||
Include a{' '}
|
||||
<code className="px-1.5 py-0.5 bg-muted rounded text-xs">tpmjs</code>{' '}
|
||||
field with tool metadata
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<div className="flex-shrink-0 w-6 h-6 rounded-full bg-primary/10 text-primary flex items-center justify-center text-xs font-bold">
|
||||
3
|
||||
</div>
|
||||
<p className="text-sm text-foreground-secondary">
|
||||
Publish to npm and your tool appears here automatically
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() =>
|
||||
window.open('https://github.com/tpmjs/tpmjs', '_blank', 'noopener')
|
||||
}
|
||||
>
|
||||
<Icon icon="github" size="sm" className="mr-2" />
|
||||
View Documentation
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
|
|
|
|||
48
apps/web/src/components/CopyButton.tsx
Normal file
48
apps/web/src/components/CopyButton.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
'use client';
|
||||
|
||||
import { Icon } from '@tpmjs/ui/Icon/Icon';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface CopyButtonProps {
|
||||
text: string;
|
||||
label?: string;
|
||||
successMessage?: string;
|
||||
size?: 'xs' | 'sm' | 'md';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function CopyButton({
|
||||
text,
|
||||
label,
|
||||
successMessage = 'Copied to clipboard',
|
||||
size = 'sm',
|
||||
className = '',
|
||||
}: CopyButtonProps): React.ReactElement {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = useCallback(async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
toast.success(successMessage);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
toast.error('Failed to copy');
|
||||
}
|
||||
}, [text, successMessage]);
|
||||
|
||||
const iconSize = size === 'xs' ? 'xs' : size === 'sm' ? 'sm' : 'md';
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
className={`inline-flex items-center gap-1.5 px-2 py-1 text-foreground-secondary hover:text-foreground hover:bg-surface rounded transition-colors ${className}`}
|
||||
title={label || 'Copy to clipboard'}
|
||||
>
|
||||
<Icon icon={copied ? 'check' : 'copy'} size={iconSize} />
|
||||
{label && <span className="text-xs">{label}</span>}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
146
apps/web/src/components/CopyDropdown.tsx
Normal file
146
apps/web/src/components/CopyDropdown.tsx
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
'use client';
|
||||
|
||||
import { Icon } from '@tpmjs/ui/Icon/Icon';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface CopyOption {
|
||||
label: string;
|
||||
value: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface CopyDropdownProps {
|
||||
options: CopyOption[];
|
||||
buttonLabel?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function CopyDropdown({
|
||||
options,
|
||||
buttonLabel = 'Copy',
|
||||
className = '',
|
||||
}: CopyDropdownProps): React.ReactElement {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const handleCopy = useCallback(async (option: CopyOption) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(option.value);
|
||||
toast.success(`${option.label} copied to clipboard`);
|
||||
setIsOpen(false);
|
||||
} catch {
|
||||
toast.error('Failed to copy');
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={`relative ${className}`} ref={dropdownRef}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 text-xs text-foreground-secondary hover:text-foreground hover:bg-surface rounded transition-colors"
|
||||
>
|
||||
<Icon icon="copy" size="xs" />
|
||||
<span>{buttonLabel}</span>
|
||||
<Icon
|
||||
icon="chevronDown"
|
||||
size="xs"
|
||||
className={`transition-transform ${isOpen ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute right-0 top-full mt-1 w-56 bg-background border border-border rounded-lg shadow-lg py-1 z-50">
|
||||
{options.map((option) => (
|
||||
<button
|
||||
key={option.label}
|
||||
type="button"
|
||||
onClick={() => handleCopy(option)}
|
||||
className="w-full px-3 py-2 text-left text-sm hover:bg-surface transition-colors"
|
||||
>
|
||||
<div className="font-medium text-foreground">{option.label}</div>
|
||||
{option.description && (
|
||||
<div className="text-xs text-foreground-tertiary truncate">
|
||||
{option.description}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Helper functions to generate copy options for different entity types
|
||||
|
||||
export function getCollectionCopyOptions(
|
||||
collectionId: string,
|
||||
collectionName: string
|
||||
): CopyOption[] {
|
||||
const baseUrl = 'https://tpmjs.com';
|
||||
const mcpUrlHttp = `${baseUrl}/mcp/collections/${collectionId}`;
|
||||
const mcpUrlSse = `${baseUrl}/mcp/collections/${collectionId}/sse`;
|
||||
|
||||
const claudeConfig = JSON.stringify(
|
||||
{
|
||||
mcpServers: {
|
||||
[collectionName.toLowerCase().replace(/\s+/g, '-')]: {
|
||||
command: 'npx',
|
||||
args: ['-y', '@anthropic-ai/mcp-remote', mcpUrlSse],
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
);
|
||||
|
||||
return [
|
||||
{ label: 'MCP URL (HTTP)', value: mcpUrlHttp, description: mcpUrlHttp },
|
||||
{ label: 'MCP URL (SSE)', value: mcpUrlSse, description: mcpUrlSse },
|
||||
{
|
||||
label: 'Claude Config',
|
||||
value: claudeConfig,
|
||||
description: 'JSON for claude_desktop_config.json',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function getAgentCopyOptions(agentUid: string, agentName: string): CopyOption[] {
|
||||
const baseUrl = 'https://tpmjs.com';
|
||||
const mcpUrl = `${baseUrl}/mcp/agents/${agentUid}`;
|
||||
|
||||
const claudeConfig = JSON.stringify(
|
||||
{
|
||||
mcpServers: {
|
||||
[agentName.toLowerCase().replace(/\s+/g, '-')]: {
|
||||
command: 'npx',
|
||||
args: ['-y', '@anthropic-ai/mcp-remote', mcpUrl],
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
);
|
||||
|
||||
return [
|
||||
{ label: 'Agent UID', value: agentUid, description: agentUid },
|
||||
{ label: 'MCP URL', value: mcpUrl, description: mcpUrl },
|
||||
{
|
||||
label: 'Claude Config',
|
||||
value: claudeConfig,
|
||||
description: 'JSON for claude_desktop_config.json',
|
||||
},
|
||||
];
|
||||
}
|
||||
112
apps/web/src/components/PackageManagerSelector.tsx
Normal file
112
apps/web/src/components/PackageManagerSelector.tsx
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
export type PackageManager = 'npm' | 'yarn' | 'pnpm' | 'bun';
|
||||
|
||||
const STORAGE_KEY = 'tpmjs-package-manager';
|
||||
|
||||
const packageManagers: { id: PackageManager; label: string }[] = [
|
||||
{ id: 'npm', label: 'npm' },
|
||||
{ id: 'yarn', label: 'yarn' },
|
||||
{ id: 'pnpm', label: 'pnpm' },
|
||||
{ id: 'bun', label: 'bun' },
|
||||
];
|
||||
|
||||
interface PackageManagerSelectorProps {
|
||||
value?: PackageManager;
|
||||
onChange?: (manager: PackageManager) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function PackageManagerSelector({
|
||||
value,
|
||||
onChange,
|
||||
className = '',
|
||||
}: PackageManagerSelectorProps): React.ReactElement {
|
||||
const [selected, setSelected] = useState<PackageManager>('npm');
|
||||
|
||||
// Load from localStorage on mount
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const stored = localStorage.getItem(STORAGE_KEY) as PackageManager | null;
|
||||
if (stored && packageManagers.some((pm) => pm.id === stored)) {
|
||||
setSelected(stored);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Sync with controlled value
|
||||
useEffect(() => {
|
||||
if (value) {
|
||||
setSelected(value);
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(manager: PackageManager) => {
|
||||
setSelected(manager);
|
||||
localStorage.setItem(STORAGE_KEY, manager);
|
||||
onChange?.(manager);
|
||||
},
|
||||
[onChange]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`inline-flex items-center gap-1 ${className}`}>
|
||||
<span className="text-sm text-foreground-secondary mr-1">Package Manager:</span>
|
||||
<div className="inline-flex rounded-lg border border-border overflow-hidden">
|
||||
{packageManagers.map((pm) => (
|
||||
<button
|
||||
key={pm.id}
|
||||
type="button"
|
||||
onClick={() => handleSelect(pm.id)}
|
||||
className={`px-3 py-1.5 text-xs font-medium transition-colors ${
|
||||
selected === pm.id
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-background text-foreground-secondary hover:bg-surface hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
{pm.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function getInstallCommand(packageName: string, manager: PackageManager): string {
|
||||
switch (manager) {
|
||||
case 'npm':
|
||||
return `npm install ${packageName}`;
|
||||
case 'yarn':
|
||||
return `yarn add ${packageName}`;
|
||||
case 'pnpm':
|
||||
return `pnpm add ${packageName}`;
|
||||
case 'bun':
|
||||
return `bun add ${packageName}`;
|
||||
default:
|
||||
return `npm install ${packageName}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Hook for getting current package manager
|
||||
export function usePackageManager(): [PackageManager, (manager: PackageManager) => void] {
|
||||
const [manager, setManager] = useState<PackageManager>('npm');
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const stored = localStorage.getItem(STORAGE_KEY) as PackageManager | null;
|
||||
if (stored && packageManagers.some((pm) => pm.id === stored)) {
|
||||
setManager(stored);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const updateManager = useCallback((newManager: PackageManager) => {
|
||||
setManager(newManager);
|
||||
localStorage.setItem(STORAGE_KEY, newManager);
|
||||
}, []);
|
||||
|
||||
return [manager, updateManager];
|
||||
}
|
||||
|
|
@ -894,14 +894,15 @@ export const manualTools: ManualTool[] = [
|
|||
type: 'object',
|
||||
description: 'An object containing file paths as keys and file contents as values.',
|
||||
required: true,
|
||||
}
|
||||
},
|
||||
],
|
||||
returns: {
|
||||
type: 'object',
|
||||
description: 'An object containing the tools available in the created bash environment.',
|
||||
},
|
||||
aiAgent: {
|
||||
useCase: 'Use this tool to execute bash commands and manipulate files within a sandboxed environment.',
|
||||
useCase:
|
||||
'Use this tool to execute bash commands and manipulate files within a sandboxed environment.',
|
||||
examples: ['Create a bash environment with specific files and execute commands.'],
|
||||
},
|
||||
docsUrl: 'https://github.com/vercel/bash-tool',
|
||||
|
|
|
|||
14
pnpm-lock.yaml
generated
14
pnpm-lock.yaml
generated
|
|
@ -331,6 +331,9 @@ importers:
|
|||
resend:
|
||||
specifier: ^6.6.0
|
||||
version: 6.6.0
|
||||
sonner:
|
||||
specifier: ^2.0.7
|
||||
version: 2.0.7(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||
zod:
|
||||
specifier: ^4.0.0
|
||||
version: 4.1.13
|
||||
|
|
@ -10090,6 +10093,12 @@ packages:
|
|||
resolution: {integrity: sha512-QlaZEqcAH3/RtNyet1IPIYPsEWAaYyXXv1Krsi+1L/QHppjX4Ifm8MQsBISz9vE8cHicIq3clogsheili5vhaQ==}
|
||||
engines: {node: '>= 18'}
|
||||
|
||||
sonner@2.0.7:
|
||||
resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==}
|
||||
peerDependencies:
|
||||
react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
|
||||
react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
|
||||
|
||||
source-map-js@1.2.1:
|
||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
|
@ -18503,6 +18512,11 @@ snapshots:
|
|||
|
||||
smol-toml@1.5.2: {}
|
||||
|
||||
sonner@2.0.7(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
|
||||
dependencies:
|
||||
react: 19.2.0
|
||||
react-dom: 19.2.0(react@19.2.0)
|
||||
|
||||
source-map-js@1.2.1: {}
|
||||
|
||||
source-map-support@0.5.21:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue