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:
Ajax Davis 2026-01-16 08:16:05 +10:00
parent 499a38ce1b
commit f0d271720c
15 changed files with 607 additions and 328 deletions

View file

@ -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>
);
}

View file

@ -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"
/>
),
}}

View file

@ -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"
/>
),
}}

View file

@ -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>

View file

@ -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>
)}

View file

@ -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"
>
Follow on Twitter
<Button variant="outline" size="lg">
Follow on Twitter
</Button>
</a>
</div>
</section>

View file

@ -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,59 +292,49 @@ 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>
{healthData.recentReports.map((report) => (
<tr key={report.id} className="border-b border-border last:border-0">
<td className="py-3 px-2">
<span className="text-sm text-foreground">
{formatTimestamp(report.timestamp)}
</span>
</td>
<td className="py-3 px-2">
<StatusBadge status={report.overallStatus} />
</td>
<td className="py-3 px-2">
<span className="text-sm text-foreground">
<span className="text-green-500">{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">
<span className="text-sm text-foreground-secondary">{report.source}</span>
{report.runId && (
<Link
href={`https://github.com/tpmjs/tpmjs/actions/runs/${report.runId}`}
target="_blank"
className="ml-2 text-primary hover:underline text-xs"
>
View Run
</Link>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead>Time</TableHead>
<TableHead>Status</TableHead>
<TableHead>Checks</TableHead>
<TableHead>Source</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{healthData.recentReports.map((report) => (
<TableRow key={report.id}>
<TableCell>
<span className="text-sm text-foreground">
{formatTimestamp(report.timestamp)}
</span>
</TableCell>
<TableCell>
<StatusBadge status={report.overallStatus} />
</TableCell>
<TableCell>
<span className="text-sm text-foreground">
<span className="text-success">{report.passCount}</span>
<span className="text-foreground-tertiary">/</span>
<span className="text-foreground-secondary">{report.totalChecks}</span>
</span>
</TableCell>
<TableCell>
<span className="text-sm text-foreground-secondary">{report.source}</span>
{report.runId && (
<Link
href={`https://github.com/tpmjs/tpmjs/actions/runs/${report.runId}`}
target="_blank"
className="ml-2 text-primary hover:underline text-xs"
>
View Run
</Link>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{/* Footer Links */}

View file

@ -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>
);
}

View file

@ -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 &ldquo;{searchQuery}&rdquo;
</h3>
<p className="text-foreground-secondary">
We couldn&apos;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>
}
/>
)}
</CardContent>
</Card>
)}
</Container>
</div>

View file

@ -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": [

View 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';

View 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';

View 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';

View 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';

View file

@ -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