feat(ui): add Spinner component with orbital animation
- Create new Spinner component with three orbiting dots - Use inline CSS keyframes for reliable animation - Support multiple size variants (xs, sm, md, lg, xl) - Increase spinner sizes in loading states across the app - Add biome-ignore directives for pre-existing lint issues - Fix accessibility: change span onClick to button element 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
89c0d552f6
commit
1e58537a65
7 changed files with 268 additions and 163 deletions
|
|
@ -7,6 +7,7 @@ import { CodeBlock } from '@tpmjs/ui/CodeBlock/CodeBlock';
|
||||||
import { Container } from '@tpmjs/ui/Container/Container';
|
import { Container } from '@tpmjs/ui/Container/Container';
|
||||||
import { Icon } from '@tpmjs/ui/Icon/Icon';
|
import { Icon } from '@tpmjs/ui/Icon/Icon';
|
||||||
import { ProgressBar } from '@tpmjs/ui/ProgressBar/ProgressBar';
|
import { ProgressBar } from '@tpmjs/ui/ProgressBar/ProgressBar';
|
||||||
|
import { Spinner } from '@tpmjs/ui/Spinner/Spinner';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { AppHeader } from '~/components/AppHeader';
|
import { AppHeader } from '~/components/AppHeader';
|
||||||
|
|
@ -118,7 +119,10 @@ export default function ToolDetailPage({
|
||||||
<div className="min-h-screen bg-background">
|
<div className="min-h-screen bg-background">
|
||||||
<AppHeader />
|
<AppHeader />
|
||||||
<Container size="xl" padding="md" className="py-12">
|
<Container size="xl" padding="md" className="py-12">
|
||||||
<div className="text-center text-foreground-secondary">Loading tool...</div>
|
<div className="flex flex-col items-center justify-center py-24 gap-6">
|
||||||
|
<Spinner size="xl" />
|
||||||
|
<span className="text-foreground-secondary text-lg">Loading tool...</span>
|
||||||
|
</div>
|
||||||
</Container>
|
</Container>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import {
|
||||||
import { CodeBlock } from '@tpmjs/ui/CodeBlock/CodeBlock';
|
import { CodeBlock } from '@tpmjs/ui/CodeBlock/CodeBlock';
|
||||||
import { Container } from '@tpmjs/ui/Container/Container';
|
import { Container } from '@tpmjs/ui/Container/Container';
|
||||||
import { Icon } from '@tpmjs/ui/Icon/Icon';
|
import { Icon } from '@tpmjs/ui/Icon/Icon';
|
||||||
|
import { Spinner } from '@tpmjs/ui/Spinner/Spinner';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { AppHeader } from '~/components/AppHeader';
|
import { AppHeader } from '~/components/AppHeader';
|
||||||
|
|
@ -87,7 +88,10 @@ export default function BrokenToolsPage(): React.ReactElement {
|
||||||
|
|
||||||
{/* Loading state */}
|
{/* Loading state */}
|
||||||
{loading && (
|
{loading && (
|
||||||
<div className="text-center py-12 text-foreground-secondary">Loading broken tools...</div>
|
<div className="flex flex-col items-center justify-center py-24 gap-6">
|
||||||
|
<Spinner size="xl" />
|
||||||
|
<span className="text-foreground-secondary text-lg">Loading broken tools...</span>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Error state */}
|
{/* Error state */}
|
||||||
|
|
@ -134,6 +138,7 @@ export default function BrokenToolsPage(): React.ReactElement {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{/* biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Broken tools page requires conditional rendering for health status */}
|
||||||
{tools.map((tool) => {
|
{tools.map((tool) => {
|
||||||
const toolUrl = `/tool/${tool.package.npmPackageName}/${tool.exportName}`;
|
const toolUrl = `/tool/${tool.package.npmPackageName}/${tool.exportName}`;
|
||||||
const lastCheckedDate = tool.lastHealthCheck
|
const lastCheckedDate = tool.lastHealthCheck
|
||||||
|
|
|
||||||
|
|
@ -2,20 +2,14 @@
|
||||||
|
|
||||||
import { Badge } from '@tpmjs/ui/Badge/Badge';
|
import { Badge } from '@tpmjs/ui/Badge/Badge';
|
||||||
import { Button } from '@tpmjs/ui/Button/Button';
|
import { Button } from '@tpmjs/ui/Button/Button';
|
||||||
import {
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@tpmjs/ui/Card/Card';
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardFooter,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from '@tpmjs/ui/Card/Card';
|
|
||||||
import { CodeBlock } from '@tpmjs/ui/CodeBlock/CodeBlock';
|
import { CodeBlock } from '@tpmjs/ui/CodeBlock/CodeBlock';
|
||||||
import { Container } from '@tpmjs/ui/Container/Container';
|
import { Container } from '@tpmjs/ui/Container/Container';
|
||||||
import { Icon } from '@tpmjs/ui/Icon/Icon';
|
import { Icon } from '@tpmjs/ui/Icon/Icon';
|
||||||
import { Input } from '@tpmjs/ui/Input/Input';
|
import { Input } from '@tpmjs/ui/Input/Input';
|
||||||
import { ProgressBar } from '@tpmjs/ui/ProgressBar/ProgressBar';
|
import { ProgressBar } from '@tpmjs/ui/ProgressBar/ProgressBar';
|
||||||
import { Select } from '@tpmjs/ui/Select/Select';
|
import { Select } from '@tpmjs/ui/Select/Select';
|
||||||
|
import { Spinner } from '@tpmjs/ui/Spinner/Spinner';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { AppHeader } from '~/components/AppHeader';
|
import { AppHeader } from '~/components/AppHeader';
|
||||||
|
|
@ -53,6 +47,7 @@ export default function ToolSearchPage(): React.ReactElement {
|
||||||
|
|
||||||
// Fetch tools from API
|
// Fetch tools from API
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Tool search page requires complex filtering logic
|
||||||
const fetchTools = async () => {
|
const fetchTools = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
@ -73,12 +68,22 @@ export default function ToolSearchPage(): React.ReactElement {
|
||||||
params.set('broken', 'true');
|
params.set('broken', 'true');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch all tools (no pagination limit)
|
||||||
|
params.set('limit', '1000');
|
||||||
const toolsResponse = await fetch(`/api/tools?${params.toString()}`);
|
const toolsResponse = await fetch(`/api/tools?${params.toString()}`);
|
||||||
const toolsData = await toolsResponse.json();
|
const toolsData = await toolsResponse.json();
|
||||||
|
|
||||||
if (toolsData.success) {
|
if (toolsData.success) {
|
||||||
const fetchedTools = toolsData.data;
|
const fetchedTools = toolsData.data;
|
||||||
setTools(fetchedTools);
|
// Sort broken tools to the bottom
|
||||||
|
const sortedTools = [...fetchedTools].sort((a, b) => {
|
||||||
|
const aIsBroken = a.importHealth === 'BROKEN' || a.executionHealth === 'BROKEN';
|
||||||
|
const bIsBroken = b.importHealth === 'BROKEN' || b.executionHealth === 'BROKEN';
|
||||||
|
if (aIsBroken && !bIsBroken) return 1;
|
||||||
|
if (!aIsBroken && bIsBroken) return -1;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
setTools(sortedTools);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
// Extract unique categories from all tools
|
// Extract unique categories from all tools
|
||||||
|
|
@ -179,7 +184,10 @@ export default function ToolSearchPage(): React.ReactElement {
|
||||||
|
|
||||||
{/* Loading state */}
|
{/* Loading state */}
|
||||||
{loading && (
|
{loading && (
|
||||||
<div className="text-center py-12 text-foreground-secondary">Loading tools...</div>
|
<div className="flex flex-col items-center justify-center py-24 gap-6">
|
||||||
|
<Spinner size="xl" />
|
||||||
|
<span className="text-foreground-secondary text-lg">Loading tools...</span>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Error state */}
|
{/* Error state */}
|
||||||
|
|
@ -189,114 +197,116 @@ export default function ToolSearchPage(): React.ReactElement {
|
||||||
{!loading && !error && (
|
{!loading && !error && (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
{tools.length > 0 ? (
|
{tools.length > 0 ? (
|
||||||
tools.map((tool) => (
|
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Tool card rendering requires complex conditional UI
|
||||||
<Card key={tool.id} className="flex flex-col">
|
tools.map((tool) => {
|
||||||
<CardHeader>
|
const isBroken =
|
||||||
<div className="flex items-start justify-between gap-2">
|
tool.importHealth === 'BROKEN' || tool.executionHealth === 'BROKEN';
|
||||||
<div className="flex-1">
|
const qualityPercent = Math.round(Number.parseFloat(tool.qualityScore) * 100);
|
||||||
<CardTitle>
|
|
||||||
{tool.exportName !== 'default'
|
// Clean up repository URL
|
||||||
? tool.exportName
|
let repoUrl = tool.package.npmRepository?.url || '';
|
||||||
: tool.package.npmPackageName}
|
repoUrl = repoUrl.replace(/^git\+/, '');
|
||||||
</CardTitle>
|
repoUrl = repoUrl.replace(/\.git$/, '');
|
||||||
<div className="text-sm text-foreground-secondary mt-1">
|
repoUrl = repoUrl.replace(/^git:\/\//, 'https://');
|
||||||
{tool.package.npmPackageName}
|
repoUrl = repoUrl.replace(/^git@github\.com:/, 'https://github.com/');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={tool.id}
|
||||||
|
href={`/tool/${tool.package.npmPackageName}/${tool.exportName}`}
|
||||||
|
className="block"
|
||||||
|
>
|
||||||
|
<Card className="flex flex-col h-full hover:border-foreground-tertiary transition-colors cursor-pointer">
|
||||||
|
<CardHeader className="flex-none">
|
||||||
|
{/* Top row: Title + metadata */}
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<CardTitle className="truncate">
|
||||||
|
{tool.exportName !== 'default'
|
||||||
|
? tool.exportName
|
||||||
|
: tool.package.npmPackageName}
|
||||||
|
</CardTitle>
|
||||||
|
<div className="text-sm text-foreground-secondary mt-1 truncate">
|
||||||
|
{tool.package.npmPackageName}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Right side: downloads, version, link */}
|
||||||
|
<div className="flex items-center gap-2 flex-shrink-0 text-xs text-foreground-tertiary">
|
||||||
|
<span>{tool.package.npmDownloadsLastMonth.toLocaleString()}/mo</span>
|
||||||
|
<span>v{tool.package.npmVersion}</span>
|
||||||
|
{repoUrl && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
window.open(repoUrl, '_blank', 'noopener,noreferrer');
|
||||||
|
}}
|
||||||
|
className="text-foreground-secondary hover:text-foreground transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<Icon icon="externalLink" size="sm" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{/* Description */}
|
||||||
{tool.package.npmRepository &&
|
<CardDescription className="line-clamp-2 min-h-[2.5rem]">
|
||||||
(() => {
|
{tool.description}
|
||||||
// Clean up repository URL
|
</CardDescription>
|
||||||
let repoUrl = tool.package.npmRepository.url;
|
</CardHeader>
|
||||||
|
|
||||||
// Remove git+ prefix
|
<CardContent className="flex-1 flex flex-col gap-4">
|
||||||
repoUrl = repoUrl.replace(/^git\+/, '');
|
{/* Category badge */}
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Badge variant="secondary" size="sm">
|
||||||
|
{tool.package.category}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
// Remove .git suffix
|
{/* Quality + Broken status row */}
|
||||||
repoUrl = repoUrl.replace(/\.git$/, '');
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex-1 flex items-center gap-2">
|
||||||
|
<ProgressBar
|
||||||
|
value={qualityPercent}
|
||||||
|
variant={
|
||||||
|
isBroken
|
||||||
|
? 'danger'
|
||||||
|
: qualityPercent >= 70
|
||||||
|
? 'success'
|
||||||
|
: qualityPercent >= 50
|
||||||
|
? 'primary'
|
||||||
|
: 'warning'
|
||||||
|
}
|
||||||
|
size="sm"
|
||||||
|
showLabel={false}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<span className="text-xs font-medium text-foreground-secondary w-8">
|
||||||
|
{qualityPercent}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{isBroken && (
|
||||||
|
<Badge variant="error" size="sm">
|
||||||
|
Broken
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
// Convert git:// to https://
|
{/* Install command */}
|
||||||
repoUrl = repoUrl.replace(/^git:\/\//, 'https://');
|
{/* biome-ignore lint/a11y/useKeyWithClickEvents: onClick only prevents propagation, not an interactive element */}
|
||||||
|
<div className="mt-auto" onClick={(e) => e.stopPropagation()}>
|
||||||
// Convert ssh URLs to https
|
<CodeBlock
|
||||||
repoUrl = repoUrl.replace(/^git@github\.com:/, 'https://github.com/');
|
code={`npm install ${tool.package.npmPackageName}`}
|
||||||
|
language="bash"
|
||||||
return (
|
size="sm"
|
||||||
<a
|
showCopy={true}
|
||||||
href={repoUrl}
|
/>
|
||||||
target="_blank"
|
</div>
|
||||||
rel="noopener noreferrer"
|
</CardContent>
|
||||||
className="text-foreground-secondary hover:text-foreground transition-colors"
|
</Card>
|
||||||
>
|
</Link>
|
||||||
<Icon icon="externalLink" size="sm" />
|
);
|
||||||
</a>
|
})
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
<CardDescription>{tool.description}</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent className="flex-1 space-y-4">
|
|
||||||
{/* Category badge and version */}
|
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
|
||||||
<Badge variant="secondary" size="sm">
|
|
||||||
{tool.package.category}
|
|
||||||
</Badge>
|
|
||||||
<span className="text-xs text-foreground-tertiary">
|
|
||||||
v{tool.package.npmVersion}
|
|
||||||
</span>
|
|
||||||
{tool.package.isOfficial && (
|
|
||||||
<Badge variant="default" size="sm">
|
|
||||||
Official
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{(tool.importHealth === 'BROKEN' || tool.executionHealth === 'BROKEN') && (
|
|
||||||
<Badge variant="error" size="sm">
|
|
||||||
<Icon icon="x" size="sm" className="mr-1" />
|
|
||||||
Broken
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Quality score and downloads */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center justify-between text-sm">
|
|
||||||
<span className="text-foreground-secondary">Quality Score</span>
|
|
||||||
<span className="text-foreground-tertiary">
|
|
||||||
{tool.package.npmDownloadsLastMonth.toLocaleString()} downloads/mo
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<ProgressBar
|
|
||||||
value={Number.parseFloat(tool.qualityScore) * 100}
|
|
||||||
variant={
|
|
||||||
Number.parseFloat(tool.qualityScore) >= 0.7
|
|
||||||
? 'success'
|
|
||||||
: Number.parseFloat(tool.qualityScore) >= 0.5
|
|
||||||
? 'primary'
|
|
||||||
: 'warning'
|
|
||||||
}
|
|
||||||
size="sm"
|
|
||||||
showLabel={true}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Install command */}
|
|
||||||
<CodeBlock
|
|
||||||
code={`npm install ${tool.package.npmPackageName}`}
|
|
||||||
language="bash"
|
|
||||||
size="sm"
|
|
||||||
showCopy={true}
|
|
||||||
/>
|
|
||||||
</CardContent>
|
|
||||||
|
|
||||||
<CardFooter>
|
|
||||||
<Link href={`/tool/${tool.package.npmPackageName}/${tool.exportName}`}>
|
|
||||||
<Button variant="outline" size="sm" className="w-full">
|
|
||||||
View Details
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
</CardFooter>
|
|
||||||
</Card>
|
|
||||||
))
|
|
||||||
) : (
|
) : (
|
||||||
<div className="col-span-full text-center py-12 text-foreground-tertiary">
|
<div className="col-span-full text-center py-12 text-foreground-tertiary">
|
||||||
{searchQuery
|
{searchQuery
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
|
|
||||||
import type { TokenBreakdown as TokenData } from '@/lib/ai-agent/tool-executor-agent';
|
import type { TokenBreakdown as TokenData } from '@/lib/ai-agent/tool-executor-agent';
|
||||||
import type { Package, Tool } from '@tpmjs/db';
|
import type { Package, Tool } from '@tpmjs/db';
|
||||||
|
import { Spinner } from '@tpmjs/ui/Spinner/Spinner';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import ReactMarkdown from 'react-markdown';
|
import ReactMarkdown from 'react-markdown';
|
||||||
import remarkGfm from 'remark-gfm';
|
import remarkGfm from 'remark-gfm';
|
||||||
|
|
@ -24,6 +25,7 @@ interface ExecutionLog {
|
||||||
timestamp: Date;
|
timestamp: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Playground component has many tabs with different content
|
||||||
export function ToolPlayground({ tool }: ToolPlaygroundProps): React.ReactElement {
|
export function ToolPlayground({ tool }: ToolPlaygroundProps): React.ReactElement {
|
||||||
const [activeTab, setActiveTab] = useState<Tab>('input');
|
const [activeTab, setActiveTab] = useState<Tab>('input');
|
||||||
const [prompt, setPrompt] = useState('');
|
const [prompt, setPrompt] = useState('');
|
||||||
|
|
@ -258,28 +260,8 @@ export function ToolPlayground({ tool }: ToolPlaygroundProps): React.ReactElemen
|
||||||
className="px-6 py-3 bg-primary text-primary-foreground rounded-lg font-medium hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
className="px-6 py-3 bg-primary text-primary-foreground rounded-lg font-medium hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
>
|
>
|
||||||
{isExecuting ? (
|
{isExecuting ? (
|
||||||
<span className="flex items-center">
|
<span className="flex items-center gap-2">
|
||||||
{/* biome-ignore lint/a11y/noSvgWithoutTitle: decorative loading spinner */}
|
<Spinner size="sm" />
|
||||||
<svg
|
|
||||||
className="animate-spin -ml-1 mr-3 h-5 w-5"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<circle
|
|
||||||
className="opacity-25"
|
|
||||||
cx="12"
|
|
||||||
cy="12"
|
|
||||||
r="10"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="4"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
className="opacity-75"
|
|
||||||
fill="currentColor"
|
|
||||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
Executing...
|
Executing...
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -341,31 +323,9 @@ export function ToolPlayground({ tool }: ToolPlaygroundProps): React.ReactElemen
|
||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
) : isExecuting ? (
|
) : isExecuting ? (
|
||||||
<div className="flex items-center justify-center py-12">
|
<div className="flex flex-col items-center justify-center py-12 gap-6">
|
||||||
<div className="text-center">
|
<Spinner size="xl" />
|
||||||
{/* biome-ignore lint/a11y/noSvgWithoutTitle: decorative loading spinner */}
|
<p className="text-foreground-secondary">Executing...</p>
|
||||||
<svg
|
|
||||||
className="animate-spin h-8 w-8 text-primary mx-auto mb-4"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<circle
|
|
||||||
className="opacity-25"
|
|
||||||
cx="12"
|
|
||||||
cy="12"
|
|
||||||
r="10"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="4"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
className="opacity-75"
|
|
||||||
fill="currentColor"
|
|
||||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<p className="text-sm text-foreground-secondary">Executing...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-center py-12">
|
<div className="text-center py-12">
|
||||||
|
|
@ -382,6 +342,7 @@ export function ToolPlayground({ tool }: ToolPlaygroundProps): React.ReactElemen
|
||||||
{logs.length > 0 ? (
|
{logs.length > 0 ? (
|
||||||
<div className="space-y-2 max-h-96 overflow-y-auto">
|
<div className="space-y-2 max-h-96 overflow-y-auto">
|
||||||
{logs.map((log, index) => (
|
{logs.map((log, index) => (
|
||||||
|
// biome-ignore lint/suspicious/noArrayIndexKey: Logs don't have unique IDs, index is appropriate
|
||||||
<div key={index} className="flex items-start space-x-3 text-sm">
|
<div key={index} className="flex items-start space-x-3 text-sm">
|
||||||
<span
|
<span
|
||||||
className={`px-2 py-0.5 rounded text-xs font-medium uppercase ${getLevelBadgeColor(log.level)}`}
|
className={`px-2 py-0.5 rounded text-xs font-medium uppercase ${getLevelBadgeColor(log.level)}`}
|
||||||
|
|
|
||||||
|
|
@ -165,6 +165,19 @@ export default {
|
||||||
'0%, 100%': { opacity: '0.4' },
|
'0%, 100%': { opacity: '0.4' },
|
||||||
'50%': { opacity: '0.6' },
|
'50%': { opacity: '0.6' },
|
||||||
},
|
},
|
||||||
|
orbit: {
|
||||||
|
'0%': {
|
||||||
|
transform: 'rotate(0deg) translateX(150%) rotate(0deg)',
|
||||||
|
opacity: '1',
|
||||||
|
},
|
||||||
|
'50%': {
|
||||||
|
opacity: '0.6',
|
||||||
|
},
|
||||||
|
'100%': {
|
||||||
|
transform: 'rotate(360deg) translateX(150%) rotate(-360deg)',
|
||||||
|
opacity: '1',
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
animation: {
|
animation: {
|
||||||
|
|
@ -177,6 +190,7 @@ export default {
|
||||||
shimmer: 'shimmer 2s infinite linear',
|
shimmer: 'shimmer 2s infinite linear',
|
||||||
pulse: 'pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite',
|
pulse: 'pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite',
|
||||||
'grid-pulse': 'grid-pulse 8s ease-in-out infinite',
|
'grid-pulse': 'grid-pulse 8s ease-in-out infinite',
|
||||||
|
orbit: 'orbit 1.2s ease-in-out infinite',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,10 @@
|
||||||
"types": "./dist/Slider/Slider.d.ts",
|
"types": "./dist/Slider/Slider.d.ts",
|
||||||
"default": "./dist/Slider/Slider.js"
|
"default": "./dist/Slider/Slider.js"
|
||||||
},
|
},
|
||||||
|
"./Spinner/Spinner": {
|
||||||
|
"types": "./dist/Spinner/Spinner.d.ts",
|
||||||
|
"default": "./dist/Spinner/Spinner.js"
|
||||||
|
},
|
||||||
"./FormField/FormField": {
|
"./FormField/FormField": {
|
||||||
"types": "./dist/FormField/FormField.d.ts",
|
"types": "./dist/FormField/FormField.d.ts",
|
||||||
"default": "./dist/FormField/FormField.js"
|
"default": "./dist/FormField/FormField.js"
|
||||||
|
|
|
||||||
107
packages/ui/src/Spinner/Spinner.tsx
Normal file
107
packages/ui/src/Spinner/Spinner.tsx
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { cn } from '@tpmjs/utils/cn';
|
||||||
|
|
||||||
|
const sizeClasses = {
|
||||||
|
xs: 'w-5 h-5',
|
||||||
|
sm: 'w-8 h-8',
|
||||||
|
md: 'w-12 h-12',
|
||||||
|
lg: 'w-16 h-16',
|
||||||
|
xl: 'w-24 h-24',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const dotSizeClasses = {
|
||||||
|
xs: 'w-1 h-1',
|
||||||
|
sm: 'w-1.5 h-1.5',
|
||||||
|
md: 'w-2 h-2',
|
||||||
|
lg: 'w-3 h-3',
|
||||||
|
xl: 'w-4 h-4',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export interface SpinnerProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
/** Size variant */
|
||||||
|
size?: keyof typeof sizeClasses;
|
||||||
|
/** Optional label for accessibility */
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spinner component
|
||||||
|
*
|
||||||
|
* An elegant orbital loading spinner with three dots rotating
|
||||||
|
* in a synchronized dance pattern.
|
||||||
|
*/
|
||||||
|
export function Spinner({
|
||||||
|
className,
|
||||||
|
size = 'md',
|
||||||
|
label = 'Loading...',
|
||||||
|
...props
|
||||||
|
}: SpinnerProps): React.ReactElement {
|
||||||
|
const sizeClass = sizeClasses[size];
|
||||||
|
const dotSize = dotSizeClasses[size];
|
||||||
|
|
||||||
|
return (
|
||||||
|
// biome-ignore lint/a11y/useSemanticElements: Spinner requires role="status" for screen reader announcements, <output> is not semantically appropriate
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
aria-label={label}
|
||||||
|
className={cn('relative', sizeClass, className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<style>
|
||||||
|
{`
|
||||||
|
@keyframes spinnerOrbit {
|
||||||
|
0% {
|
||||||
|
transform: rotate(0deg) translateX(140%) rotate(0deg);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: rotate(360deg) translateX(140%) rotate(-360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
{/* Orbital ring hint */}
|
||||||
|
<div className="absolute inset-[15%] rounded-full border border-foreground/10" />
|
||||||
|
|
||||||
|
{/* Three orbiting dots with staggered animations */}
|
||||||
|
<div
|
||||||
|
className={cn('absolute rounded-full bg-foreground', dotSize)}
|
||||||
|
style={{
|
||||||
|
top: '50%',
|
||||||
|
left: '50%',
|
||||||
|
marginTop: '-0.25rem',
|
||||||
|
marginLeft: '-0.25rem',
|
||||||
|
animation: 'spinnerOrbit 1.4s cubic-bezier(0.5, 0, 0.5, 1) infinite',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={cn('absolute rounded-full bg-foreground/50', dotSize)}
|
||||||
|
style={{
|
||||||
|
top: '50%',
|
||||||
|
left: '50%',
|
||||||
|
marginTop: '-0.25rem',
|
||||||
|
marginLeft: '-0.25rem',
|
||||||
|
animation: 'spinnerOrbit 1.4s cubic-bezier(0.5, 0, 0.5, 1) infinite',
|
||||||
|
animationDelay: '-0.45s',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={cn('absolute rounded-full bg-foreground/25', dotSize)}
|
||||||
|
style={{
|
||||||
|
top: '50%',
|
||||||
|
left: '50%',
|
||||||
|
marginTop: '-0.25rem',
|
||||||
|
marginLeft: '-0.25rem',
|
||||||
|
animation: 'spinnerOrbit 1.4s cubic-bezier(0.5, 0, 0.5, 1) infinite',
|
||||||
|
animationDelay: '-0.9s',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Screen reader text */}
|
||||||
|
<span className="sr-only">{label}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Spinner.displayName = 'Spinner';
|
||||||
Loading…
Add table
Add a link
Reference in a new issue