feat(ui): add state components and apply design system compliance
Add new reusable components: - EmptyState: for empty list/search states with icon, title, description - ErrorState: for error displays with optional retry button - LoadingState: for loading states with Spinner and message - PageHeader: for consistent page headers with title, description, actions Apply components across pages: - agents, collections, tool-search pages use new state components - stats page uses LoadingState and ErrorState - faq page uses Icon for chevrons - health page uses Table components - dashboard agent forms use Input, Select, Textarea, Label - Replace custom div spinners with Spinner component
This commit is contained in:
parent
499a38ce1b
commit
f0d271720c
15 changed files with 607 additions and 328 deletions
|
|
@ -1,5 +1,6 @@
|
|||
'use client';
|
||||
|
||||
import { LoadingState } from '@tpmjs/ui/LoadingState/LoadingState';
|
||||
import { notFound, useParams, useRouter } from 'next/navigation';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
|
|
@ -53,7 +54,7 @@ export default function PrettyChatPage(): React.ReactElement {
|
|||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-foreground" />
|
||||
<LoadingState size="lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
'use client';
|
||||
|
||||
import { Badge } from '@tpmjs/ui/Badge/Badge';
|
||||
import { Button } from '@tpmjs/ui/Button/Button';
|
||||
import { EmptyState } from '@tpmjs/ui/EmptyState/EmptyState';
|
||||
import { ErrorState } from '@tpmjs/ui/ErrorState/ErrorState';
|
||||
import { Icon } from '@tpmjs/ui/Icon/Icon';
|
||||
import { Input } from '@tpmjs/ui/Input/Input';
|
||||
import { LoadingState } from '@tpmjs/ui/LoadingState/LoadingState';
|
||||
import { PageHeader } from '@tpmjs/ui/PageHeader/PageHeader';
|
||||
import { Select } from '@tpmjs/ui/Select/Select';
|
||||
import { Spinner } from '@tpmjs/ui/Spinner/Spinner';
|
||||
import Link from 'next/link';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { TableVirtuoso } from 'react-virtuoso';
|
||||
|
|
@ -128,7 +130,7 @@ export default function PublicAgentsPage(): React.ReactElement {
|
|||
|
||||
const TableHeader = useCallback(
|
||||
() => (
|
||||
<tr className="bg-surface text-left text-sm font-medium text-foreground-secondary">
|
||||
<tr className="bg-surface-secondary text-left text-xs font-semibold uppercase tracking-wider text-foreground-secondary border-b border-border">
|
||||
<th className="px-4 py-3 w-[200px]">Name</th>
|
||||
<th className="px-4 py-3 w-[250px]">Description</th>
|
||||
<th className="px-4 py-3 w-[100px]">Provider</th>
|
||||
|
|
@ -148,7 +150,7 @@ export default function PublicAgentsPage(): React.ReactElement {
|
|||
<td className="px-4 py-3">
|
||||
<Link
|
||||
href={`/agents/${agent.id}`}
|
||||
className="font-medium text-foreground hover:text-primary transition-colors"
|
||||
className="font-semibold text-foreground hover:text-primary group-hover:text-primary transition-colors"
|
||||
>
|
||||
{agent.name}
|
||||
</Link>
|
||||
|
|
@ -219,13 +221,10 @@ export default function PublicAgentsPage(): React.ReactElement {
|
|||
<AppHeader />
|
||||
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-foreground mb-2">Public Agents</h1>
|
||||
<p className="text-foreground-secondary">
|
||||
Discover AI agents created and shared by the community
|
||||
</p>
|
||||
</div>
|
||||
<PageHeader
|
||||
title="Public Agents"
|
||||
description="Discover AI agents created and shared by the community"
|
||||
/>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-col sm:flex-row gap-4 mb-6">
|
||||
|
|
@ -254,27 +253,15 @@ export default function PublicAgentsPage(): React.ReactElement {
|
|||
|
||||
{/* Content */}
|
||||
{error ? (
|
||||
<div className="text-center py-16">
|
||||
<Icon icon="alertCircle" size="lg" className="mx-auto text-error mb-4" />
|
||||
<h2 className="text-lg font-medium text-foreground mb-2">Error</h2>
|
||||
<p className="text-foreground-secondary mb-4">{error}</p>
|
||||
<Button onClick={() => fetchAgents(0, true)}>Try Again</Button>
|
||||
</div>
|
||||
<ErrorState message={error} onRetry={() => fetchAgents(0, true)} />
|
||||
) : isLoading ? (
|
||||
<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>
|
||||
<LoadingState message="Loading agents..." size="lg" />
|
||||
) : 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" />
|
||||
</div>
|
||||
<h2 className="text-lg font-medium text-foreground mb-2">No agents found</h2>
|
||||
<p className="text-foreground-secondary">
|
||||
{search ? 'Try adjusting your search terms' : 'Be the first to share a public agent!'}
|
||||
</p>
|
||||
</div>
|
||||
<EmptyState
|
||||
icon="terminal"
|
||||
title="No agents found"
|
||||
description={search ? 'Try adjusting your search terms' : 'Be the first to share a public agent!'}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="border border-border rounded-lg overflow-hidden">
|
||||
|
|
@ -294,13 +281,13 @@ export default function PublicAgentsPage(): React.ReactElement {
|
|||
/>
|
||||
),
|
||||
TableHead: (props) => (
|
||||
<thead {...props} className="bg-surface sticky top-0 z-10" />
|
||||
<thead {...props} className="bg-surface-secondary sticky top-0 z-10" />
|
||||
),
|
||||
TableBody: (props) => <tbody {...props} />,
|
||||
TableRow: (props) => (
|
||||
<tr
|
||||
{...props}
|
||||
className="border-b border-border hover:bg-surface/50 transition-colors"
|
||||
className="border-b border-border bg-white dark:bg-zinc-900 hover:bg-surface transition-all duration-150 group"
|
||||
/>
|
||||
),
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
'use client';
|
||||
|
||||
import { Badge } from '@tpmjs/ui/Badge/Badge';
|
||||
import { Button } from '@tpmjs/ui/Button/Button';
|
||||
import { EmptyState } from '@tpmjs/ui/EmptyState/EmptyState';
|
||||
import { ErrorState } from '@tpmjs/ui/ErrorState/ErrorState';
|
||||
import { Icon } from '@tpmjs/ui/Icon/Icon';
|
||||
import { Input } from '@tpmjs/ui/Input/Input';
|
||||
import { LoadingState } from '@tpmjs/ui/LoadingState/LoadingState';
|
||||
import { PageHeader } from '@tpmjs/ui/PageHeader/PageHeader';
|
||||
import { Select } from '@tpmjs/ui/Select/Select';
|
||||
import { Spinner } from '@tpmjs/ui/Spinner/Spinner';
|
||||
import Link from 'next/link';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { TableVirtuoso } from 'react-virtuoso';
|
||||
|
|
@ -122,7 +124,7 @@ export default function PublicCollectionsPage(): React.ReactElement {
|
|||
|
||||
const TableHeader = useCallback(
|
||||
() => (
|
||||
<tr className="bg-surface text-left text-sm font-medium text-foreground-secondary">
|
||||
<tr className="bg-surface-secondary text-left text-xs font-semibold uppercase tracking-wider text-foreground-secondary border-b border-border">
|
||||
<th className="px-4 py-3 w-[250px]">Name</th>
|
||||
<th className="px-4 py-3 w-[300px]">Description</th>
|
||||
<th className="px-4 py-3 w-[80px] text-center">Tools</th>
|
||||
|
|
@ -140,7 +142,7 @@ export default function PublicCollectionsPage(): React.ReactElement {
|
|||
<td className="px-4 py-3">
|
||||
<Link
|
||||
href={`/collections/${collection.id}`}
|
||||
className="font-medium text-foreground hover:text-primary transition-colors"
|
||||
className="font-semibold text-foreground hover:text-primary group-hover:text-primary transition-colors"
|
||||
>
|
||||
{collection.name}
|
||||
</Link>
|
||||
|
|
@ -201,13 +203,10 @@ export default function PublicCollectionsPage(): React.ReactElement {
|
|||
<AppHeader />
|
||||
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-foreground mb-2">Public Collections</h1>
|
||||
<p className="text-foreground-secondary">
|
||||
Discover curated tool collections shared by the community
|
||||
</p>
|
||||
</div>
|
||||
<PageHeader
|
||||
title="Public Collections"
|
||||
description="Discover curated tool collections shared by the community"
|
||||
/>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-col sm:flex-row gap-4 mb-6">
|
||||
|
|
@ -236,31 +235,15 @@ export default function PublicCollectionsPage(): React.ReactElement {
|
|||
|
||||
{/* Content */}
|
||||
{error ? (
|
||||
<div className="text-center py-16">
|
||||
<Icon icon="alertCircle" size="lg" className="mx-auto text-error mb-4" />
|
||||
<h2 className="text-lg font-medium text-foreground mb-2">Error</h2>
|
||||
<p className="text-foreground-secondary mb-4">{error}</p>
|
||||
<Button onClick={() => fetchCollections(0, true)}>Try Again</Button>
|
||||
</div>
|
||||
<ErrorState message={error} onRetry={() => fetchCollections(0, true)} />
|
||||
) : isLoading ? (
|
||||
<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>
|
||||
<LoadingState message="Loading collections..." size="lg" />
|
||||
) : 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" />
|
||||
</div>
|
||||
<h2 className="text-lg font-medium text-foreground mb-2">No collections found</h2>
|
||||
<p className="text-foreground-secondary">
|
||||
{search
|
||||
? 'Try adjusting your search terms'
|
||||
: 'Be the first to share a public collection!'}
|
||||
</p>
|
||||
</div>
|
||||
<EmptyState
|
||||
icon="folder"
|
||||
title="No collections found"
|
||||
description={search ? 'Try adjusting your search terms' : 'Be the first to share a public collection!'}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="border border-border rounded-lg overflow-hidden">
|
||||
|
|
@ -280,13 +263,13 @@ export default function PublicCollectionsPage(): React.ReactElement {
|
|||
/>
|
||||
),
|
||||
TableHead: (props) => (
|
||||
<thead {...props} className="bg-surface sticky top-0 z-10" />
|
||||
<thead {...props} className="bg-surface-secondary sticky top-0 z-10" />
|
||||
),
|
||||
TableBody: (props) => <tbody {...props} />,
|
||||
TableRow: (props) => (
|
||||
<tr
|
||||
{...props}
|
||||
className="border-b border-border hover:bg-surface/50 transition-colors"
|
||||
className="border-b border-border bg-white dark:bg-zinc-900 hover:bg-surface transition-all duration-150 group"
|
||||
/>
|
||||
),
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { Badge } from '@tpmjs/ui/Badge/Badge';
|
|||
import { Button } from '@tpmjs/ui/Button/Button';
|
||||
import { CodeBlock } from '@tpmjs/ui/CodeBlock/CodeBlock';
|
||||
import { Icon } from '@tpmjs/ui/Icon/Icon';
|
||||
import { Spinner } from '@tpmjs/ui/Spinner/Spinner';
|
||||
import { Switch } from '@tpmjs/ui/Switch/Switch';
|
||||
import {
|
||||
Table,
|
||||
|
|
@ -819,7 +820,7 @@ export default function AgentDetailPage(): React.ReactElement {
|
|||
/>
|
||||
{isSearchingTools && (
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2">
|
||||
<div className="w-4 h-4 border-2 border-primary border-t-transparent rounded-full animate-spin" />
|
||||
<Spinner size="sm" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -932,7 +933,7 @@ export default function AgentDetailPage(): React.ReactElement {
|
|||
/>
|
||||
{isSearchingCollections && (
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2">
|
||||
<div className="w-4 h-4 border-2 border-primary border-t-transparent rounded-full animate-spin" />
|
||||
<Spinner size="sm" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,10 @@ import type { AIProvider } from '@tpmjs/types/agent';
|
|||
import { PROVIDER_MODELS, SUPPORTED_PROVIDERS } from '@tpmjs/types/agent';
|
||||
import { Button } from '@tpmjs/ui/Button/Button';
|
||||
import { Icon } from '@tpmjs/ui/Icon/Icon';
|
||||
import { Input } from '@tpmjs/ui/Input/Input';
|
||||
import { Label } from '@tpmjs/ui/Label/Label';
|
||||
import { Select } from '@tpmjs/ui/Select/Select';
|
||||
import { Textarea } from '@tpmjs/ui/Textarea/Textarea';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
|
|
@ -133,11 +137,10 @@ export default function NewAgentPage(): React.ReactElement {
|
|||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium text-foreground mb-1">
|
||||
Name *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
<Label htmlFor="name" required className="mb-1">
|
||||
Name
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
value={formData.name}
|
||||
|
|
@ -145,16 +148,14 @@ export default function NewAgentPage(): React.ReactElement {
|
|||
required
|
||||
maxLength={100}
|
||||
placeholder="My AI Agent"
|
||||
className="w-full px-3 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 focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="uid" className="block text-sm font-medium text-foreground mb-1">
|
||||
<Label htmlFor="uid" className="mb-1">
|
||||
UID (URL-friendly identifier)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
</Label>
|
||||
<Input
|
||||
id="uid"
|
||||
name="uid"
|
||||
value={formData.uid}
|
||||
|
|
@ -162,7 +163,7 @@ export default function NewAgentPage(): React.ReactElement {
|
|||
maxLength={50}
|
||||
pattern="[a-z0-9-]+"
|
||||
placeholder="my-ai-agent"
|
||||
className="w-full px-3 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 focus:border-primary font-mono text-sm"
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
<p className="text-xs text-foreground-tertiary mt-1">
|
||||
Used in API URLs. Lowercase letters, numbers, and hyphens only.
|
||||
|
|
@ -170,21 +171,18 @@ export default function NewAgentPage(): React.ReactElement {
|
|||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="description"
|
||||
className="block text-sm font-medium text-foreground mb-1"
|
||||
>
|
||||
<Label htmlFor="description" className="mb-1">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
name="description"
|
||||
value={formData.description}
|
||||
onChange={handleChange}
|
||||
maxLength={500}
|
||||
rows={2}
|
||||
resize="none"
|
||||
placeholder="What does this agent do?"
|
||||
className="w-full px-3 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 focus:border-primary resize-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -197,68 +195,54 @@ export default function NewAgentPage(): React.ReactElement {
|
|||
<div className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="provider"
|
||||
className="block text-sm font-medium text-foreground mb-1"
|
||||
>
|
||||
Provider *
|
||||
</label>
|
||||
<select
|
||||
<Label htmlFor="provider" required className="mb-1">
|
||||
Provider
|
||||
</Label>
|
||||
<Select
|
||||
id="provider"
|
||||
name="provider"
|
||||
value={formData.provider}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="w-full px-3 py-2 bg-surface border border-border rounded-lg text-foreground focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary"
|
||||
>
|
||||
{SUPPORTED_PROVIDERS.map((provider) => (
|
||||
<option key={provider} value={provider}>
|
||||
{PROVIDER_DISPLAY_NAMES[provider]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
options={SUPPORTED_PROVIDERS.map((provider) => ({
|
||||
value: provider,
|
||||
label: PROVIDER_DISPLAY_NAMES[provider],
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="modelId"
|
||||
className="block text-sm font-medium text-foreground mb-1"
|
||||
>
|
||||
Model *
|
||||
</label>
|
||||
<select
|
||||
<Label htmlFor="modelId" required className="mb-1">
|
||||
Model
|
||||
</Label>
|
||||
<Select
|
||||
id="modelId"
|
||||
name="modelId"
|
||||
value={formData.modelId}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="w-full px-3 py-2 bg-surface border border-border rounded-lg text-foreground focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary"
|
||||
>
|
||||
{models.map((model) => (
|
||||
<option key={model.id} value={model.id}>
|
||||
{model.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
options={models.map((model) => ({
|
||||
value: model.id,
|
||||
label: model.name,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="systemPrompt"
|
||||
className="block text-sm font-medium text-foreground mb-1"
|
||||
>
|
||||
<Label htmlFor="systemPrompt" className="mb-1">
|
||||
System Prompt
|
||||
</label>
|
||||
<textarea
|
||||
</Label>
|
||||
<Textarea
|
||||
id="systemPrompt"
|
||||
name="systemPrompt"
|
||||
value={formData.systemPrompt}
|
||||
onChange={handleChange}
|
||||
maxLength={10000}
|
||||
rows={6}
|
||||
resize="none"
|
||||
placeholder="You are a helpful assistant that..."
|
||||
className="w-full px-3 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 focus:border-primary resize-none font-mono text-sm"
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
<p className="text-xs text-foreground-tertiary mt-1">
|
||||
Instructions that define how the agent behaves.
|
||||
|
|
@ -267,13 +251,10 @@ export default function NewAgentPage(): React.ReactElement {
|
|||
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="temperature"
|
||||
className="block text-sm font-medium text-foreground mb-1"
|
||||
>
|
||||
<Label htmlFor="temperature" className="mb-1">
|
||||
Temperature
|
||||
</label>
|
||||
<input
|
||||
</Label>
|
||||
<Input
|
||||
type="number"
|
||||
id="temperature"
|
||||
name="temperature"
|
||||
|
|
@ -282,7 +263,6 @@ export default function NewAgentPage(): React.ReactElement {
|
|||
min={0}
|
||||
max={2}
|
||||
step={0.1}
|
||||
className="w-full px-3 py-2 bg-surface border border-border rounded-lg text-foreground focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary"
|
||||
/>
|
||||
<p className="text-xs text-foreground-tertiary mt-1">
|
||||
0 = deterministic, 2 = creative
|
||||
|
|
@ -290,13 +270,10 @@ export default function NewAgentPage(): React.ReactElement {
|
|||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="maxToolCallsPerTurn"
|
||||
className="block text-sm font-medium text-foreground mb-1"
|
||||
>
|
||||
<Label htmlFor="maxToolCallsPerTurn" className="mb-1">
|
||||
Max Tool Calls
|
||||
</label>
|
||||
<input
|
||||
</Label>
|
||||
<Input
|
||||
type="number"
|
||||
id="maxToolCallsPerTurn"
|
||||
name="maxToolCallsPerTurn"
|
||||
|
|
@ -304,19 +281,15 @@ export default function NewAgentPage(): React.ReactElement {
|
|||
onChange={handleChange}
|
||||
min={1}
|
||||
max={100}
|
||||
className="w-full px-3 py-2 bg-surface border border-border rounded-lg text-foreground focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary"
|
||||
/>
|
||||
<p className="text-xs text-foreground-tertiary mt-1">Per response turn</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="maxMessagesInContext"
|
||||
className="block text-sm font-medium text-foreground mb-1"
|
||||
>
|
||||
<Label htmlFor="maxMessagesInContext" className="mb-1">
|
||||
Context Messages
|
||||
</label>
|
||||
<input
|
||||
</Label>
|
||||
<Input
|
||||
type="number"
|
||||
id="maxMessagesInContext"
|
||||
name="maxMessagesInContext"
|
||||
|
|
@ -324,7 +297,6 @@ export default function NewAgentPage(): React.ReactElement {
|
|||
onChange={handleChange}
|
||||
min={1}
|
||||
max={100}
|
||||
className="w-full px-3 py-2 bg-surface border border-border rounded-lg text-foreground focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary"
|
||||
/>
|
||||
<p className="text-xs text-foreground-tertiary mt-1">
|
||||
Recent messages to include
|
||||
|
|
@ -348,14 +320,14 @@ export default function NewAgentPage(): React.ReactElement {
|
|||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
|
||||
<div className="bg-error/10 border border-error/20 rounded-lg p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Icon
|
||||
icon="alertCircle"
|
||||
size="sm"
|
||||
className="text-red-600 dark:text-red-400 mt-0.5"
|
||||
className="text-error mt-0.5"
|
||||
/>
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
|
||||
<p className="text-sm text-error">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import { Button } from '@tpmjs/ui/Button/Button';
|
||||
import { Container } from '@tpmjs/ui/Container/Container';
|
||||
import { Icon } from '@tpmjs/ui/Icon/Icon';
|
||||
import Link from 'next/link';
|
||||
import { AppHeader } from '~/components/AppHeader';
|
||||
|
||||
|
|
@ -29,22 +31,7 @@ function FAQItem({ question, children }: FAQItemProps): React.ReactElement {
|
|||
<summary className="cursor-pointer px-6 py-4 font-semibold text-foreground flex items-center justify-between list-none">
|
||||
<span className="pr-4">{question}</span>
|
||||
<span className="text-foreground-secondary group-open:rotate-180 transition-transform">
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M5 7.5L10 12.5L15 7.5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
<Icon icon="chevronDown" size="sm" />
|
||||
</span>
|
||||
</summary>
|
||||
<div className="px-6 pb-6 pt-2 text-foreground-secondary space-y-4">{children}</div>
|
||||
|
|
@ -409,17 +396,17 @@ export default function FAQPage(): React.ReactElement {
|
|||
href="https://github.com/tpmjs/tpmjs/issues/new"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-block px-6 py-3 bg-primary text-primary-foreground font-semibold rounded-lg hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
Ask on GitHub
|
||||
<Button size="lg">Ask on GitHub</Button>
|
||||
</a>
|
||||
<a
|
||||
href="https://twitter.com/tpmjs_registry"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-block px-6 py-3 border border-border bg-background text-foreground font-semibold rounded-lg hover:border-foreground transition-colors"
|
||||
>
|
||||
<Button variant="outline" size="lg">
|
||||
Follow on Twitter
|
||||
</Button>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,17 @@
|
|||
'use client';
|
||||
|
||||
import { Badge } from '@tpmjs/ui/Badge/Badge';
|
||||
import { Button } from '@tpmjs/ui/Button/Button';
|
||||
import { Icon } from '@tpmjs/ui/Icon/Icon';
|
||||
import { Spinner } from '@tpmjs/ui/Spinner/Spinner';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@tpmjs/ui/Table/Table';
|
||||
import Link from 'next/link';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { AppHeader } from '~/components/AppHeader';
|
||||
|
|
@ -42,9 +52,9 @@ interface HealthData {
|
|||
}
|
||||
|
||||
const statusColors: Record<string, { bg: string; text: string; border: string }> = {
|
||||
healthy: { bg: 'bg-green-500/10', text: 'text-green-500', border: 'border-green-500/30' },
|
||||
degraded: { bg: 'bg-yellow-500/10', text: 'text-yellow-500', border: 'border-yellow-500/30' },
|
||||
down: { bg: 'bg-red-500/10', text: 'text-red-500', border: 'border-red-500/30' },
|
||||
healthy: { bg: 'bg-success/10', text: 'text-success', border: 'border-success/30' },
|
||||
degraded: { bg: 'bg-warning/10', text: 'text-warning', border: 'border-warning/30' },
|
||||
down: { bg: 'bg-error/10', text: 'text-error', border: 'border-error/30' },
|
||||
unknown: { bg: 'bg-zinc-500/10', text: 'text-zinc-500', border: 'border-zinc-500/30' },
|
||||
};
|
||||
|
||||
|
|
@ -71,7 +81,7 @@ function StatusBadge({ status }: { status: string }) {
|
|||
className={`inline-flex items-center px-3 py-1 rounded-full text-sm font-medium ${colors.bg} ${colors.text} border ${colors.border}`}
|
||||
>
|
||||
<span
|
||||
className={`w-2 h-2 rounded-full mr-2 ${status === 'healthy' ? 'bg-green-500' : status === 'degraded' ? 'bg-yellow-500' : status === 'down' ? 'bg-red-500' : 'bg-zinc-500'}`}
|
||||
className={`w-2 h-2 rounded-full mr-2 ${status === 'healthy' ? 'bg-success' : status === 'degraded' ? 'bg-warning' : status === 'down' ? 'bg-error' : 'bg-zinc-500'}`}
|
||||
/>
|
||||
{status.charAt(0).toUpperCase() + status.slice(1)}
|
||||
</span>
|
||||
|
|
@ -80,17 +90,9 @@ function StatusBadge({ status }: { status: string }) {
|
|||
|
||||
function CheckStatusIcon({ status }: { status: string }) {
|
||||
if (status === 'pass') {
|
||||
return (
|
||||
<svg className="w-5 h-5 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
);
|
||||
return <Icon icon="checkCircle" size="sm" className="text-success" />;
|
||||
}
|
||||
return (
|
||||
<svg className="w-5 h-5 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
);
|
||||
return <Icon icon="xCircle" size="sm" className="text-error" />;
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
|
|
@ -110,7 +112,7 @@ function StatCard({
|
|||
<p className="text-3xl font-bold text-foreground">{value}</p>
|
||||
{subtitle && (
|
||||
<p
|
||||
className={`text-sm mt-1 ${trend === 'up' ? 'text-green-500' : trend === 'down' ? 'text-red-500' : 'text-foreground-secondary'}`}
|
||||
className={`text-sm mt-1 ${trend === 'up' ? 'text-success' : trend === 'down' ? 'text-error' : 'text-foreground-secondary'}`}
|
||||
>
|
||||
{subtitle}
|
||||
</p>
|
||||
|
|
@ -196,15 +198,11 @@ export default function HealthPage() {
|
|||
<Spinner size="lg" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="bg-red-500/10 border border-red-500/30 rounded-lg p-6 text-center">
|
||||
<p className="text-red-500 font-medium">{error}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={fetchHealthData}
|
||||
className="mt-4 px-4 py-2 bg-red-500 text-white rounded-md hover:bg-red-600 transition-colors"
|
||||
>
|
||||
<div className="bg-error/10 border border-error/20 rounded-lg p-6 text-center">
|
||||
<p className="text-error font-medium">{error}</p>
|
||||
<Button onClick={fetchHealthData} className="mt-4">
|
||||
Retry
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
) : healthData ? (
|
||||
<>
|
||||
|
|
@ -294,43 +292,34 @@ export default function HealthPage() {
|
|||
{/* Recent Reports */}
|
||||
<div className="bg-surface-elevated rounded-lg border border-border p-6">
|
||||
<h2 className="text-xl font-semibold text-foreground mb-4">Recent Health Checks</h2>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-border">
|
||||
<th className="text-left py-3 px-2 text-sm font-medium text-foreground-secondary">
|
||||
Time
|
||||
</th>
|
||||
<th className="text-left py-3 px-2 text-sm font-medium text-foreground-secondary">
|
||||
Status
|
||||
</th>
|
||||
<th className="text-left py-3 px-2 text-sm font-medium text-foreground-secondary">
|
||||
Checks
|
||||
</th>
|
||||
<th className="text-left py-3 px-2 text-sm font-medium text-foreground-secondary">
|
||||
Source
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Time</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Checks</TableHead>
|
||||
<TableHead>Source</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{healthData.recentReports.map((report) => (
|
||||
<tr key={report.id} className="border-b border-border last:border-0">
|
||||
<td className="py-3 px-2">
|
||||
<TableRow key={report.id}>
|
||||
<TableCell>
|
||||
<span className="text-sm text-foreground">
|
||||
{formatTimestamp(report.timestamp)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-2">
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge status={report.overallStatus} />
|
||||
</td>
|
||||
<td className="py-3 px-2">
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-foreground">
|
||||
<span className="text-green-500">{report.passCount}</span>
|
||||
<span className="text-success">{report.passCount}</span>
|
||||
<span className="text-foreground-tertiary">/</span>
|
||||
<span className="text-foreground-secondary">{report.totalChecks}</span>
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-2">
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-foreground-secondary">{report.source}</span>
|
||||
{report.runId && (
|
||||
<Link
|
||||
|
|
@ -341,12 +330,11 @@ export default function HealthPage() {
|
|||
View Run
|
||||
</Link>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Footer Links */}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
'use client';
|
||||
|
||||
import { ErrorState } from '@tpmjs/ui/ErrorState/ErrorState';
|
||||
import { LoadingState } from '@tpmjs/ui/LoadingState/LoadingState';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { AppHeader } from '~/components/AppHeader';
|
||||
import { AnimatedCounter } from '~/components/stats/AnimatedCounter';
|
||||
|
|
@ -184,10 +186,7 @@ export default function StatsPage() {
|
|||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="w-12 h-12 border-4 border-primary border-t-transparent rounded-full animate-spin" />
|
||||
<p className="text-foreground-secondary animate-pulse">Loading statistics...</p>
|
||||
</div>
|
||||
<LoadingState message="Loading statistics..." size="lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -195,10 +194,10 @@ export default function StatsPage() {
|
|||
if (error || !stats) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h2 className="text-xl font-semibold text-error mb-2">Failed to load statistics</h2>
|
||||
<p className="text-foreground-secondary">{error || 'Unknown error'}</p>
|
||||
</div>
|
||||
<ErrorState
|
||||
title="Failed to load statistics"
|
||||
message={error || 'Unknown error'}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,10 +4,12 @@ import { Badge } from '@tpmjs/ui/Badge/Badge';
|
|||
import { Button } from '@tpmjs/ui/Button/Button';
|
||||
import { Card, CardContent } from '@tpmjs/ui/Card/Card';
|
||||
import { Container } from '@tpmjs/ui/Container/Container';
|
||||
import { EmptyState } from '@tpmjs/ui/EmptyState/EmptyState';
|
||||
import { Icon } from '@tpmjs/ui/Icon/Icon';
|
||||
import { Input } from '@tpmjs/ui/Input/Input';
|
||||
import { LoadingState } from '@tpmjs/ui/LoadingState/LoadingState';
|
||||
import { PageHeader } from '@tpmjs/ui/PageHeader/PageHeader';
|
||||
import { Select } from '@tpmjs/ui/Select/Select';
|
||||
import { Spinner } from '@tpmjs/ui/Spinner/Spinner';
|
||||
import Link from 'next/link';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { TableVirtuoso } from 'react-virtuoso';
|
||||
|
|
@ -171,7 +173,7 @@ export default function ToolSearchPage(): React.ReactElement {
|
|||
|
||||
const TableHeader = useCallback(
|
||||
() => (
|
||||
<tr className="bg-surface text-left text-sm font-medium text-foreground-secondary">
|
||||
<tr className="bg-surface-secondary text-left text-xs font-semibold uppercase tracking-wider text-foreground-secondary border-b border-border">
|
||||
<th className="px-4 py-3 w-[250px]">Name</th>
|
||||
<th className="px-4 py-3 w-[300px]">Description</th>
|
||||
<th className="px-4 py-3 w-[100px]">Category</th>
|
||||
|
|
@ -194,9 +196,9 @@ export default function ToolSearchPage(): React.ReactElement {
|
|||
<td className="px-4 py-3">
|
||||
<Link
|
||||
href={`/tool/${tool.package.npmPackageName}/${tool.name}`}
|
||||
className="group block"
|
||||
className="block"
|
||||
>
|
||||
<div className="font-medium text-foreground group-hover:text-primary transition-colors">
|
||||
<div className="font-semibold text-foreground group-hover:text-primary transition-colors">
|
||||
{displayName}
|
||||
{isBroken && (
|
||||
<Badge variant="error" size="sm" className="ml-2">
|
||||
|
|
@ -243,16 +245,11 @@ export default function ToolSearchPage(): React.ReactElement {
|
|||
<AppHeader />
|
||||
|
||||
<Container size="xl" padding="md" className="py-8">
|
||||
{/* Page header */}
|
||||
<div className="space-y-4 mb-8">
|
||||
<h1 className="text-2xl sm:text-3xl md:text-4xl font-bold text-foreground">
|
||||
Tool Registry
|
||||
</h1>
|
||||
<p className="text-lg text-foreground-secondary">
|
||||
Search npm packages indexed as AI agent tools. Filter by category, health status, or
|
||||
keyword.
|
||||
</p>
|
||||
</div>
|
||||
<PageHeader
|
||||
title="Tool Registry"
|
||||
description="Search npm packages indexed as AI agent tools. Filter by category, health status, or keyword."
|
||||
size="lg"
|
||||
/>
|
||||
|
||||
{/* Search and filters section */}
|
||||
<div className="space-y-4 mb-6">
|
||||
|
|
@ -342,14 +339,7 @@ export default function ToolSearchPage(): React.ReactElement {
|
|||
</div>
|
||||
|
||||
{/* Loading state */}
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-24 gap-4">
|
||||
<Spinner size="lg" />
|
||||
<span className="text-foreground-secondary font-mono text-sm tracking-wide">
|
||||
Loading tools...
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{loading && <LoadingState message="Loading tools..." size="lg" />}
|
||||
|
||||
{/* Error state */}
|
||||
{error && <div className="text-center py-12 text-error">Error: {error}</div>}
|
||||
|
|
@ -371,12 +361,12 @@ export default function ToolSearchPage(): React.ReactElement {
|
|||
style={{ tableLayout: 'fixed' }}
|
||||
/>
|
||||
),
|
||||
TableHead: (props) => <thead {...props} className="bg-surface sticky top-0 z-10" />,
|
||||
TableHead: (props) => <thead {...props} className="bg-surface-secondary sticky top-0 z-10" />,
|
||||
TableBody: (props) => <tbody {...props} />,
|
||||
TableRow: (props) => (
|
||||
<tr
|
||||
{...props}
|
||||
className="border-b border-border hover:bg-surface/50 transition-colors"
|
||||
className="border-b border-border bg-white dark:bg-zinc-900 hover:bg-surface transition-all duration-150 group"
|
||||
/>
|
||||
),
|
||||
}}
|
||||
|
|
@ -394,26 +384,14 @@ export default function ToolSearchPage(): React.ReactElement {
|
|||
|
||||
{/* Empty States */}
|
||||
{!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">
|
||||
<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>
|
||||
|
||||
{searchQuery && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-xl font-semibold text-foreground">
|
||||
No tools found matching “{searchQuery}”
|
||||
</h3>
|
||||
<p className="text-foreground-secondary">
|
||||
We couldn't find any tools matching your search. Try adjusting your
|
||||
search terms or filters.
|
||||
</p>
|
||||
</div>
|
||||
<Card className="max-w-2xl mx-auto">
|
||||
<CardContent className="pt-6 pb-6">
|
||||
{searchQuery ? (
|
||||
<EmptyState
|
||||
icon="search"
|
||||
title={`No tools found matching "${searchQuery}"`}
|
||||
description="Try adjusting your search terms or filters."
|
||||
action={
|
||||
<div className="flex flex-col sm:flex-row gap-3 justify-center">
|
||||
<Button variant="default" onClick={() => setSearchQuery('')}>
|
||||
Clear Search
|
||||
|
|
@ -430,19 +408,14 @@ export default function ToolSearchPage(): React.ReactElement {
|
|||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!searchQuery && (categoryFilter !== 'all' || healthFilter !== 'all') && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-xl font-semibold text-foreground">
|
||||
No tools match your filters
|
||||
</h3>
|
||||
<p className="text-foreground-secondary">
|
||||
Try adjusting or clearing your filters to see more tools.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
) : categoryFilter !== 'all' || healthFilter !== 'all' ? (
|
||||
<EmptyState
|
||||
icon="search"
|
||||
title="No tools match your filters"
|
||||
description="Try adjusting or clearing your filters to see more tools."
|
||||
action={
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => {
|
||||
|
|
@ -452,17 +425,14 @@ export default function ToolSearchPage(): React.ReactElement {
|
|||
>
|
||||
Clear All Filters
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!searchQuery && categoryFilter === 'all' && healthFilter === 'all' && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-xl font-semibold text-foreground">No tools yet</h3>
|
||||
<p className="text-foreground-secondary">
|
||||
Be the first to publish a tool and help AI agents gain new capabilities.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<EmptyState
|
||||
icon="puzzle"
|
||||
title="No tools yet"
|
||||
description="Be the first to publish a tool and help AI agents gain new capabilities."
|
||||
action={
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() =>
|
||||
|
|
@ -472,11 +442,11 @@ export default function ToolSearchPage(): React.ReactElement {
|
|||
<Icon icon="github" size="sm" className="mr-2" />
|
||||
View Documentation
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</Container>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -218,6 +218,22 @@
|
|||
"./ToolCard/ToolCard": {
|
||||
"types": "./dist/ToolCard/ToolCard.d.ts",
|
||||
"default": "./dist/ToolCard/ToolCard.js"
|
||||
},
|
||||
"./EmptyState/EmptyState": {
|
||||
"types": "./dist/EmptyState/EmptyState.d.ts",
|
||||
"default": "./dist/EmptyState/EmptyState.js"
|
||||
},
|
||||
"./ErrorState/ErrorState": {
|
||||
"types": "./dist/ErrorState/ErrorState.d.ts",
|
||||
"default": "./dist/ErrorState/ErrorState.js"
|
||||
},
|
||||
"./LoadingState/LoadingState": {
|
||||
"types": "./dist/LoadingState/LoadingState.d.ts",
|
||||
"default": "./dist/LoadingState/LoadingState.js"
|
||||
},
|
||||
"./PageHeader/PageHeader": {
|
||||
"types": "./dist/PageHeader/PageHeader.d.ts",
|
||||
"default": "./dist/PageHeader/PageHeader.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
|
|
|
|||
110
packages/ui/src/EmptyState/EmptyState.tsx
Normal file
110
packages/ui/src/EmptyState/EmptyState.tsx
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import { cn } from '@tpmjs/utils/cn';
|
||||
import { forwardRef } from 'react';
|
||||
import { Icon, type IconName } from '../Icon/Icon';
|
||||
|
||||
export interface EmptyStateProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
/** Icon to display */
|
||||
icon: IconName;
|
||||
/** Title text */
|
||||
title: string;
|
||||
/** Optional description text */
|
||||
description?: string;
|
||||
/** Optional action element (button, link, etc.) */
|
||||
action?: React.ReactNode;
|
||||
/** Size variant */
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
const sizeConfig = {
|
||||
sm: {
|
||||
container: 'py-8',
|
||||
iconContainer: 'w-12 h-12',
|
||||
iconSize: 'md' as const,
|
||||
title: 'text-base',
|
||||
description: 'text-sm',
|
||||
},
|
||||
md: {
|
||||
container: 'py-12',
|
||||
iconContainer: 'w-16 h-16',
|
||||
iconSize: 'lg' as const,
|
||||
title: 'text-lg',
|
||||
description: 'text-sm',
|
||||
},
|
||||
lg: {
|
||||
container: 'py-16',
|
||||
iconContainer: 'w-20 h-20',
|
||||
iconSize: 'lg' as const,
|
||||
title: 'text-xl',
|
||||
description: 'text-base',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* EmptyState component
|
||||
*
|
||||
* A consistent empty state pattern for displaying when no data is available.
|
||||
* Use this for empty lists, search results with no matches, or first-time states.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* import { EmptyState } from '@tpmjs/ui/EmptyState/EmptyState';
|
||||
* import { Button } from '@tpmjs/ui/Button/Button';
|
||||
*
|
||||
* function MyComponent() {
|
||||
* return (
|
||||
* <EmptyState
|
||||
* icon="folder"
|
||||
* title="No collections found"
|
||||
* description="Create your first collection to get started."
|
||||
* action={<Button>Create Collection</Button>}
|
||||
* />
|
||||
* );
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export const EmptyState = forwardRef<HTMLDivElement, EmptyStateProps>(
|
||||
(
|
||||
{ className, icon, title, description, action, size = 'md', ...props },
|
||||
ref
|
||||
) => {
|
||||
const config = sizeConfig[size];
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('text-center', config.container, className)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-full bg-primary/10 flex items-center justify-center mx-auto mb-4',
|
||||
config.iconContainer
|
||||
)}
|
||||
>
|
||||
<Icon icon={icon} size={config.iconSize} className="text-primary" />
|
||||
</div>
|
||||
<h3
|
||||
className={cn(
|
||||
'font-medium text-foreground mb-2',
|
||||
config.title
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</h3>
|
||||
{description && (
|
||||
<p
|
||||
className={cn(
|
||||
'text-foreground-secondary mb-4 max-w-md mx-auto',
|
||||
config.description
|
||||
)}
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
{action && <div className="flex justify-center">{action}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
EmptyState.displayName = 'EmptyState';
|
||||
98
packages/ui/src/ErrorState/ErrorState.tsx
Normal file
98
packages/ui/src/ErrorState/ErrorState.tsx
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import { cn } from '@tpmjs/utils/cn';
|
||||
import { forwardRef } from 'react';
|
||||
import { Button } from '../Button/Button';
|
||||
import { Icon } from '../Icon/Icon';
|
||||
|
||||
export interface ErrorStateProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
/** Optional title (defaults to "Error") */
|
||||
title?: string;
|
||||
/** Error message to display */
|
||||
message: string;
|
||||
/** Optional retry callback - shows retry button if provided */
|
||||
onRetry?: () => void;
|
||||
/** Size variant */
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
const sizeConfig = {
|
||||
sm: {
|
||||
container: 'py-8',
|
||||
iconSize: 'md' as const,
|
||||
title: 'text-base',
|
||||
message: 'text-sm',
|
||||
},
|
||||
md: {
|
||||
container: 'py-12',
|
||||
iconSize: 'lg' as const,
|
||||
title: 'text-lg',
|
||||
message: 'text-sm',
|
||||
},
|
||||
lg: {
|
||||
container: 'py-16',
|
||||
iconSize: 'lg' as const,
|
||||
title: 'text-xl',
|
||||
message: 'text-base',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* ErrorState component
|
||||
*
|
||||
* A consistent error state pattern for displaying error messages with optional retry.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* import { ErrorState } from '@tpmjs/ui/ErrorState/ErrorState';
|
||||
*
|
||||
* function MyComponent() {
|
||||
* return (
|
||||
* <ErrorState
|
||||
* message="Failed to load data"
|
||||
* onRetry={() => refetch()}
|
||||
* />
|
||||
* );
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export const ErrorState = forwardRef<HTMLDivElement, ErrorStateProps>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
title = 'Error',
|
||||
message,
|
||||
onRetry,
|
||||
size = 'md',
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const config = sizeConfig[size];
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('text-center', config.container, className)}
|
||||
{...props}
|
||||
>
|
||||
<Icon
|
||||
icon="alertCircle"
|
||||
size={config.iconSize}
|
||||
className="mx-auto text-error mb-4"
|
||||
/>
|
||||
<h2 className={cn('font-medium text-foreground mb-2', config.title)}>
|
||||
{title}
|
||||
</h2>
|
||||
<p className={cn('text-foreground-secondary mb-4', config.message)}>
|
||||
{message}
|
||||
</p>
|
||||
{onRetry && (
|
||||
<Button onClick={onRetry} variant="default">
|
||||
Try Again
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
ErrorState.displayName = 'ErrorState';
|
||||
81
packages/ui/src/LoadingState/LoadingState.tsx
Normal file
81
packages/ui/src/LoadingState/LoadingState.tsx
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import { cn } from '@tpmjs/utils/cn';
|
||||
import { forwardRef } from 'react';
|
||||
import { Spinner } from '../Spinner/Spinner';
|
||||
|
||||
export interface LoadingStateProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
/** Optional loading message */
|
||||
message?: string;
|
||||
/** Size variant */
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
const sizeConfig = {
|
||||
sm: {
|
||||
container: 'py-8',
|
||||
spinnerSize: 'sm' as const,
|
||||
messageClass: 'text-xs',
|
||||
gap: 'gap-2',
|
||||
},
|
||||
md: {
|
||||
container: 'py-16',
|
||||
spinnerSize: 'md' as const,
|
||||
messageClass: 'text-sm',
|
||||
gap: 'gap-3',
|
||||
},
|
||||
lg: {
|
||||
container: 'py-24',
|
||||
spinnerSize: 'lg' as const,
|
||||
messageClass: 'text-sm',
|
||||
gap: 'gap-4',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* LoadingState component
|
||||
*
|
||||
* A consistent loading state pattern with spinner and optional message.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* import { LoadingState } from '@tpmjs/ui/LoadingState/LoadingState';
|
||||
*
|
||||
* function MyComponent() {
|
||||
* if (isLoading) {
|
||||
* return <LoadingState message="Loading tools..." />;
|
||||
* }
|
||||
* return <div>Content</div>;
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export const LoadingState = forwardRef<HTMLDivElement, LoadingStateProps>(
|
||||
({ className, message, size = 'md', ...props }, ref) => {
|
||||
const config = sizeConfig[size];
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex items-center justify-center',
|
||||
config.container,
|
||||
config.gap,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Spinner size={config.spinnerSize} />
|
||||
{message && (
|
||||
<span
|
||||
className={cn(
|
||||
'text-foreground-secondary font-mono tracking-wide',
|
||||
config.messageClass
|
||||
)}
|
||||
>
|
||||
{message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
LoadingState.displayName = 'LoadingState';
|
||||
86
packages/ui/src/PageHeader/PageHeader.tsx
Normal file
86
packages/ui/src/PageHeader/PageHeader.tsx
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import { cn } from '@tpmjs/utils/cn';
|
||||
import { forwardRef } from 'react';
|
||||
|
||||
export interface PageHeaderProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
/** Page title */
|
||||
title: string;
|
||||
/** Optional description text */
|
||||
description?: string;
|
||||
/** Optional action elements (buttons, links, etc.) */
|
||||
actions?: React.ReactNode;
|
||||
/** Size variant */
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
const sizeConfig = {
|
||||
sm: {
|
||||
container: 'mb-4 space-y-1',
|
||||
title: 'text-xl sm:text-2xl',
|
||||
description: 'text-sm',
|
||||
},
|
||||
md: {
|
||||
container: 'mb-6 space-y-2',
|
||||
title: 'text-2xl sm:text-3xl',
|
||||
description: 'text-base',
|
||||
},
|
||||
lg: {
|
||||
container: 'mb-8 space-y-4',
|
||||
title: 'text-2xl sm:text-3xl md:text-4xl',
|
||||
description: 'text-lg',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* PageHeader component
|
||||
*
|
||||
* A consistent page header pattern with title, description, and optional actions.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* import { PageHeader } from '@tpmjs/ui/PageHeader/PageHeader';
|
||||
* import { Button } from '@tpmjs/ui/Button/Button';
|
||||
*
|
||||
* function MyPage() {
|
||||
* return (
|
||||
* <PageHeader
|
||||
* title="Tool Registry"
|
||||
* description="Browse and discover AI agent tools"
|
||||
* actions={<Button>Add Tool</Button>}
|
||||
* />
|
||||
* );
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export const PageHeader = forwardRef<HTMLDivElement, PageHeaderProps>(
|
||||
(
|
||||
{ className, title, description, actions, size = 'md', ...props },
|
||||
ref
|
||||
) => {
|
||||
const config = sizeConfig[size];
|
||||
|
||||
return (
|
||||
<div ref={ref} className={cn(config.container, className)} {...props}>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<h1 className={cn('font-bold text-foreground', config.title)}>
|
||||
{title}
|
||||
</h1>
|
||||
{description && (
|
||||
<p
|
||||
className={cn(
|
||||
'text-foreground-secondary',
|
||||
config.description
|
||||
)}
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{actions && <div className="flex items-center gap-2">{actions}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
PageHeader.displayName = 'PageHeader';
|
||||
|
|
@ -83,7 +83,7 @@ const TableRow = forwardRef<HTMLTableRowElement, TableRowProps>(
|
|||
<tr
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'border-b border-border transition-colors',
|
||||
'border-b border-border bg-white dark:bg-zinc-900 transition-colors',
|
||||
interactive && 'cursor-pointer hover:bg-surface',
|
||||
selected && 'bg-surface',
|
||||
className
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue