@aiready/components 0.1.21 → 0.1.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/charts/ForceDirectedGraph.js +8 -8
- package/dist/charts/ForceDirectedGraph.js.map +1 -1
- package/dist/index.d.ts +98 -3
- package/dist/index.js +405 -10
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
- package/src/charts/ForceDirectedGraph.tsx +8 -8
- package/src/code-block/CodeBlock.tsx +147 -0
- package/src/code-block/index.ts +1 -0
- package/src/data-display/ScoreBar.tsx +124 -0
- package/src/data-display/index.ts +1 -0
- package/src/feedback/ErrorDisplay.tsx +86 -0
- package/src/feedback/LoadingSpinner.tsx +35 -0
- package/src/feedback/index.ts +2 -0
- package/src/index.ts +25 -0
- package/src/navigation/Breadcrumb.tsx +61 -0
- package/src/navigation/index.ts +1 -0
- package/src/theme/ThemeProvider.tsx +124 -0
- package/src/theme/index.ts +1 -0
- package/src/utils/score.ts +56 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aiready/components",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.24",
|
|
4
4
|
"description": "Unified shared components library (UI, charts, hooks, utilities) for AIReady",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -62,8 +62,8 @@
|
|
|
62
62
|
"clsx": "^2.1.1",
|
|
63
63
|
"d3": "^7.9.0",
|
|
64
64
|
"d3-force": "^3.0.0",
|
|
65
|
-
"tailwind-merge": "^
|
|
66
|
-
"@aiready/core": "0.9.
|
|
65
|
+
"tailwind-merge": "^3.0.0",
|
|
66
|
+
"@aiready/core": "0.9.25"
|
|
67
67
|
},
|
|
68
68
|
"devDependencies": {
|
|
69
69
|
"@testing-library/jest-dom": "^6.6.5",
|
|
@@ -74,7 +74,7 @@
|
|
|
74
74
|
"tailwindcss": "^4.1.14",
|
|
75
75
|
"tsup": "^8.3.5",
|
|
76
76
|
"typescript": "^5.7.2",
|
|
77
|
-
"vitest": "^
|
|
77
|
+
"vitest": "^4.0.0"
|
|
78
78
|
},
|
|
79
79
|
"scripts": {
|
|
80
80
|
"dev": "tsup --watch",
|
|
@@ -128,8 +128,8 @@ export const ForceDirectedGraph = forwardRef<ForceDirectedGraphHandle, ForceDire
|
|
|
128
128
|
const nodes = React.useMemo(() => {
|
|
129
129
|
if (!initialNodes || !initialNodes.length) return initialNodes;
|
|
130
130
|
|
|
131
|
-
const
|
|
132
|
-
const
|
|
131
|
+
const centerX = width / 2;
|
|
132
|
+
const centerY = height / 2;
|
|
133
133
|
|
|
134
134
|
// For force layout, use random positions but don't animate
|
|
135
135
|
if (layout === 'force') {
|
|
@@ -145,8 +145,8 @@ export const ForceDirectedGraph = forwardRef<ForceDirectedGraphHandle, ForceDire
|
|
|
145
145
|
const radius = Math.min(width, height) * 0.35;
|
|
146
146
|
return initialNodes.map((n: any, i: number) => ({
|
|
147
147
|
...n,
|
|
148
|
-
x:
|
|
149
|
-
y:
|
|
148
|
+
x: centerX + Math.cos((2 * Math.PI * i) / initialNodes.length) * radius,
|
|
149
|
+
y: centerY + Math.sin((2 * Math.PI * i) / initialNodes.length) * radius,
|
|
150
150
|
}));
|
|
151
151
|
}
|
|
152
152
|
|
|
@@ -188,16 +188,16 @@ export const ForceDirectedGraph = forwardRef<ForceDirectedGraphHandle, ForceDire
|
|
|
188
188
|
if (!nodes || nodes.length === 0) return;
|
|
189
189
|
|
|
190
190
|
const applyLayout = () => {
|
|
191
|
-
const
|
|
192
|
-
const
|
|
191
|
+
const centerX = width / 2;
|
|
192
|
+
const centerY = height / 2;
|
|
193
193
|
|
|
194
194
|
if (layout === 'circular') {
|
|
195
195
|
// Place all nodes in a circle
|
|
196
196
|
const radius = Math.min(width, height) * 0.35;
|
|
197
197
|
nodes.forEach((node, i) => {
|
|
198
198
|
const angle = (2 * Math.PI * i) / nodes.length;
|
|
199
|
-
node.fx =
|
|
200
|
-
node.fy =
|
|
199
|
+
node.fx = centerX + Math.cos(angle) * radius;
|
|
200
|
+
node.fy = centerY + Math.sin(angle) * radius;
|
|
201
201
|
});
|
|
202
202
|
} else if (layout === 'hierarchical') {
|
|
203
203
|
// Place packages in rows, files within packages in columns
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { useState, useCallback, useMemo } from 'react';
|
|
4
|
+
|
|
5
|
+
export interface CodeBlockProps {
|
|
6
|
+
children: React.ReactNode;
|
|
7
|
+
language?: string;
|
|
8
|
+
showCopy?: boolean;
|
|
9
|
+
showHeader?: boolean;
|
|
10
|
+
className?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Dedent helper - removes common leading indentation
|
|
14
|
+
function dedentCode(code: string): string {
|
|
15
|
+
// Normalize tabs to two spaces
|
|
16
|
+
let normalized = code.replace(/\t/g, ' ').replace(/[ \t]+$/gm, '');
|
|
17
|
+
|
|
18
|
+
const lines = normalized.split('\n');
|
|
19
|
+
if (lines.length <= 1) return normalized.trim();
|
|
20
|
+
|
|
21
|
+
// Remove leading/trailing empty lines
|
|
22
|
+
let start = 0;
|
|
23
|
+
while (start < lines.length && lines[start].trim() === '') start++;
|
|
24
|
+
let end = lines.length - 1;
|
|
25
|
+
while (end >= 0 && lines[end].trim() === '') end--;
|
|
26
|
+
|
|
27
|
+
if (start > end) return '';
|
|
28
|
+
const relevantLines = lines.slice(start, end + 1);
|
|
29
|
+
|
|
30
|
+
// Find minimum indent across non-empty lines
|
|
31
|
+
const nonEmpty = relevantLines.filter((l) => l.trim() !== '');
|
|
32
|
+
const minIndent = nonEmpty.reduce((min, line) => {
|
|
33
|
+
const m = line.match(/^\s*/)?.[0].length ?? 0;
|
|
34
|
+
return Math.min(min, m);
|
|
35
|
+
}, Infinity);
|
|
36
|
+
|
|
37
|
+
// Remove common indentation
|
|
38
|
+
const dedented = minIndent === Infinity || minIndent === 0
|
|
39
|
+
? relevantLines.join('\n')
|
|
40
|
+
: relevantLines.map((l) => (l.startsWith(' '.repeat(minIndent)) ? l.slice(minIndent) : l)).join('\n');
|
|
41
|
+
|
|
42
|
+
return dedented;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Simple Copy Button
|
|
46
|
+
function CopyButton({ code }: { code: string }) {
|
|
47
|
+
const [copied, setCopied] = useState(false);
|
|
48
|
+
|
|
49
|
+
const handleCopy = useCallback(async () => {
|
|
50
|
+
try {
|
|
51
|
+
await navigator.clipboard.writeText(code);
|
|
52
|
+
setCopied(true);
|
|
53
|
+
setTimeout(() => setCopied(false), 2000);
|
|
54
|
+
} catch {
|
|
55
|
+
// Fallback for older browsers
|
|
56
|
+
const textarea = document.createElement('textarea');
|
|
57
|
+
textarea.value = code;
|
|
58
|
+
textarea.style.position = 'fixed';
|
|
59
|
+
textarea.style.opacity = '0';
|
|
60
|
+
document.body.appendChild(textarea);
|
|
61
|
+
textarea.select();
|
|
62
|
+
document.execCommand('copy');
|
|
63
|
+
document.body.removeChild(textarea);
|
|
64
|
+
setCopied(true);
|
|
65
|
+
setTimeout(() => setCopied(false), 2000);
|
|
66
|
+
}
|
|
67
|
+
}, [code]);
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<button
|
|
71
|
+
onClick={handleCopy}
|
|
72
|
+
className="rounded-md p-1.5 text-slate-400 hover:text-slate-200 hover:bg-slate-700/50 transition-colors"
|
|
73
|
+
title={copied ? 'Copied!' : 'Copy code'}
|
|
74
|
+
>
|
|
75
|
+
{copied ? (
|
|
76
|
+
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor">
|
|
77
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
|
78
|
+
</svg>
|
|
79
|
+
) : (
|
|
80
|
+
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor">
|
|
81
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184" />
|
|
82
|
+
</svg>
|
|
83
|
+
)}
|
|
84
|
+
</button>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function CodeBlock({
|
|
89
|
+
children,
|
|
90
|
+
language = 'typescript',
|
|
91
|
+
showCopy = true,
|
|
92
|
+
showHeader = true,
|
|
93
|
+
className = '',
|
|
94
|
+
}: CodeBlockProps) {
|
|
95
|
+
// Get code string from children
|
|
96
|
+
const codeString = useMemo(() => {
|
|
97
|
+
if (typeof children === 'string') {
|
|
98
|
+
return dedentCode(children);
|
|
99
|
+
}
|
|
100
|
+
// Handle template literal children
|
|
101
|
+
try {
|
|
102
|
+
const raw = React.Children.toArray(children)
|
|
103
|
+
.map((c) => (typeof c === 'string' ? c : typeof c === 'number' ? String(c) : ''))
|
|
104
|
+
.join('');
|
|
105
|
+
return dedentCode(raw);
|
|
106
|
+
} catch {
|
|
107
|
+
return '';
|
|
108
|
+
}
|
|
109
|
+
}, [children]);
|
|
110
|
+
|
|
111
|
+
return (
|
|
112
|
+
<div className={`group relative my-4 overflow-hidden rounded-xl border border-slate-700 bg-slate-900 shadow-lg ${className}`}>
|
|
113
|
+
{/* Header bar */}
|
|
114
|
+
{showHeader && (
|
|
115
|
+
<div className="flex items-center justify-between border-b border-slate-700 bg-slate-800/50 px-4 py-2">
|
|
116
|
+
<div className="flex items-center gap-2">
|
|
117
|
+
<div className="flex gap-1.5">
|
|
118
|
+
<div className="h-3 w-3 rounded-full bg-red-500/50" />
|
|
119
|
+
<div className="h-3 w-3 rounded-full bg-amber-500/50" />
|
|
120
|
+
<div className="h-3 w-3 rounded-full bg-emerald-500/50" />
|
|
121
|
+
</div>
|
|
122
|
+
<span className="ml-2 text-xs font-semibold uppercase tracking-wider text-slate-500 font-mono">
|
|
123
|
+
{language}
|
|
124
|
+
</span>
|
|
125
|
+
</div>
|
|
126
|
+
{showCopy && <CopyButton code={codeString} />}
|
|
127
|
+
</div>
|
|
128
|
+
)}
|
|
129
|
+
|
|
130
|
+
{/* Code body */}
|
|
131
|
+
<pre className="overflow-x-auto p-4 text-sm leading-relaxed">
|
|
132
|
+
<code className="font-mono block whitespace-pre text-slate-300">
|
|
133
|
+
{codeString}
|
|
134
|
+
</code>
|
|
135
|
+
</pre>
|
|
136
|
+
</div>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Inline code component
|
|
141
|
+
export function InlineCode({ children, className = '' }: { children: React.ReactNode; className?: string }) {
|
|
142
|
+
return (
|
|
143
|
+
<code className={`rounded-md bg-slate-100 px-1.5 py-0.5 text-sm font-mono text-slate-800 ${className}`}>
|
|
144
|
+
{children}
|
|
145
|
+
</code>
|
|
146
|
+
);
|
|
147
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { CodeBlock, InlineCode, type CodeBlockProps } from './CodeBlock';
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { getRating as getCoreRating } from '@aiready/core/client';
|
|
4
|
+
import { cn } from '../utils/cn';
|
|
5
|
+
|
|
6
|
+
export type ScoreRating = 'excellent' | 'good' | 'fair' | 'needs-work' | 'critical';
|
|
7
|
+
|
|
8
|
+
export interface ScoreBarProps {
|
|
9
|
+
score: number;
|
|
10
|
+
maxScore?: number;
|
|
11
|
+
label: string;
|
|
12
|
+
showScore?: boolean;
|
|
13
|
+
size?: 'sm' | 'md' | 'lg';
|
|
14
|
+
className?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const ratingConfig: Record<ScoreRating, { color: string; bgColor: string; label: string }> = {
|
|
18
|
+
excellent: { color: 'bg-green-500', bgColor: 'bg-green-100', label: 'Excellent' },
|
|
19
|
+
good: { color: 'bg-emerald-500', bgColor: 'bg-emerald-100', label: 'Good' },
|
|
20
|
+
fair: { color: 'bg-amber-500', bgColor: 'bg-amber-100', label: 'Fair' },
|
|
21
|
+
'needs-work': { color: 'bg-orange-500', bgColor: 'bg-orange-100', label: 'Needs Work' },
|
|
22
|
+
critical: { color: 'bg-red-500', bgColor: 'bg-red-100', label: 'Critical' },
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// Convert Title Case rating from core to lowercase for component use
|
|
26
|
+
function getRating(score: number): ScoreRating {
|
|
27
|
+
const coreRating = getCoreRating(score);
|
|
28
|
+
const ratingMap: Record<string, ScoreRating> = {
|
|
29
|
+
'Excellent': 'excellent',
|
|
30
|
+
'Good': 'good',
|
|
31
|
+
'Fair': 'fair',
|
|
32
|
+
'Needs Work': 'needs-work',
|
|
33
|
+
'Critical': 'critical',
|
|
34
|
+
};
|
|
35
|
+
return ratingMap[coreRating] || 'critical';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const sizeConfig = {
|
|
39
|
+
sm: { height: 'h-1.5', text: 'text-xs', score: 'text-sm' },
|
|
40
|
+
md: { height: 'h-2', text: 'text-sm', score: 'text-base' },
|
|
41
|
+
lg: { height: 'h-3', text: 'text-base', score: 'text-lg' },
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export function ScoreBar({
|
|
45
|
+
score,
|
|
46
|
+
maxScore = 100,
|
|
47
|
+
label,
|
|
48
|
+
showScore = true,
|
|
49
|
+
size = 'md',
|
|
50
|
+
className,
|
|
51
|
+
}: ScoreBarProps) {
|
|
52
|
+
const percentage = Math.min(100, Math.max(0, (score / maxScore) * 100));
|
|
53
|
+
const rating = getRating(percentage);
|
|
54
|
+
const config = ratingConfig[rating];
|
|
55
|
+
const sizes = sizeConfig[size];
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<div className={cn('space-y-1', className)}>
|
|
59
|
+
<div className="flex items-center justify-between">
|
|
60
|
+
<span className={cn('text-slate-700', sizes.text)}>{label}</span>
|
|
61
|
+
{showScore && (
|
|
62
|
+
<span className={cn('font-bold text-slate-900', sizes.score)}>
|
|
63
|
+
{score}/{maxScore}
|
|
64
|
+
</span>
|
|
65
|
+
)}
|
|
66
|
+
</div>
|
|
67
|
+
<div className={cn('w-full rounded-full bg-slate-200', sizes.height)}>
|
|
68
|
+
<div
|
|
69
|
+
className={cn('rounded-full transition-all duration-500', config.color, sizes.height)}
|
|
70
|
+
style={{ width: `${percentage}%` }}
|
|
71
|
+
/>
|
|
72
|
+
</div>
|
|
73
|
+
</div>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface ScoreCardProps {
|
|
78
|
+
score: number;
|
|
79
|
+
title?: string;
|
|
80
|
+
breakdown?: Array<{
|
|
81
|
+
label: string;
|
|
82
|
+
score: number;
|
|
83
|
+
weight?: number;
|
|
84
|
+
}>;
|
|
85
|
+
className?: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function ScoreCard({ score, title, breakdown, className }: ScoreCardProps) {
|
|
89
|
+
const rating = getRating(score);
|
|
90
|
+
const config = ratingConfig[rating];
|
|
91
|
+
|
|
92
|
+
return (
|
|
93
|
+
<div className={cn('rounded-xl border-2 border-slate-200 bg-white p-6 shadow-lg', className)}>
|
|
94
|
+
<div className="mb-4">
|
|
95
|
+
<div className="text-4xl font-black text-slate-900">{score}/100</div>
|
|
96
|
+
<div className={cn('text-lg font-bold', `text-${rating === 'excellent' ? 'green' : rating === 'good' ? 'emerald' : rating === 'fair' ? 'amber' : rating === 'needs-work' ? 'orange' : 'red'}-600`)}>
|
|
97
|
+
{config.label} Rating
|
|
98
|
+
</div>
|
|
99
|
+
{title && <p className="text-sm text-slate-600 mt-1">{title}</p>}
|
|
100
|
+
</div>
|
|
101
|
+
|
|
102
|
+
{breakdown && breakdown.length > 0 && (
|
|
103
|
+
<div className="space-y-3">
|
|
104
|
+
{breakdown.map((item, index) => (
|
|
105
|
+
<ScoreBar
|
|
106
|
+
key={index}
|
|
107
|
+
score={item.score}
|
|
108
|
+
label={item.label}
|
|
109
|
+
size="sm"
|
|
110
|
+
/>
|
|
111
|
+
))}
|
|
112
|
+
</div>
|
|
113
|
+
)}
|
|
114
|
+
|
|
115
|
+
{breakdown && breakdown.length > 0 && (
|
|
116
|
+
<div className="mt-4 text-xs text-slate-600 bg-slate-50 p-3 rounded-lg">
|
|
117
|
+
<strong>Formula:</strong> {breakdown.map(item =>
|
|
118
|
+
`${item.score}×${item.weight || 1}`
|
|
119
|
+
).join(' + ')} / 100 = {score}
|
|
120
|
+
</div>
|
|
121
|
+
)}
|
|
122
|
+
</div>
|
|
123
|
+
);
|
|
124
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { ScoreBar, ScoreCard, type ScoreBarProps, type ScoreCardProps, type ScoreRating } from './ScoreBar';
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
|
|
5
|
+
export interface ErrorDisplayProps {
|
|
6
|
+
title?: string;
|
|
7
|
+
message: string;
|
|
8
|
+
retry?: () => void;
|
|
9
|
+
retryLabel?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function ErrorDisplay({
|
|
13
|
+
title = 'Something went wrong',
|
|
14
|
+
message,
|
|
15
|
+
retry,
|
|
16
|
+
retryLabel = 'Try again',
|
|
17
|
+
}: ErrorDisplayProps) {
|
|
18
|
+
return (
|
|
19
|
+
<div className="flex flex-col items-center justify-center min-h-[200px] gap-4 p-8">
|
|
20
|
+
<div className="rounded-full bg-red-100 p-3">
|
|
21
|
+
<svg
|
|
22
|
+
className="h-6 w-6 text-red-600"
|
|
23
|
+
fill="none"
|
|
24
|
+
viewBox="0 0 24 24"
|
|
25
|
+
strokeWidth="1.5"
|
|
26
|
+
stroke="currentColor"
|
|
27
|
+
>
|
|
28
|
+
<path
|
|
29
|
+
strokeLinecap="round"
|
|
30
|
+
strokeLinejoin="round"
|
|
31
|
+
d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z"
|
|
32
|
+
/>
|
|
33
|
+
</svg>
|
|
34
|
+
</div>
|
|
35
|
+
<div className="text-center">
|
|
36
|
+
<h3 className="text-lg font-semibold text-slate-900">{title}</h3>
|
|
37
|
+
<p className="mt-2 text-sm text-slate-500">{message}</p>
|
|
38
|
+
</div>
|
|
39
|
+
{retry && (
|
|
40
|
+
<button
|
|
41
|
+
onClick={retry}
|
|
42
|
+
className="mt-2 inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 transition-colors"
|
|
43
|
+
>
|
|
44
|
+
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor">
|
|
45
|
+
<path
|
|
46
|
+
strokeLinecap="round"
|
|
47
|
+
strokeLinejoin="round"
|
|
48
|
+
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
|
|
49
|
+
/>
|
|
50
|
+
</svg>
|
|
51
|
+
{retryLabel}
|
|
52
|
+
</button>
|
|
53
|
+
)}
|
|
54
|
+
</div>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface EmptyStateProps {
|
|
59
|
+
title: string;
|
|
60
|
+
description?: string;
|
|
61
|
+
icon?: React.ReactNode;
|
|
62
|
+
action?: {
|
|
63
|
+
label: string;
|
|
64
|
+
onClick: () => void;
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function EmptyState({ title, description, icon, action }: EmptyStateProps) {
|
|
69
|
+
return (
|
|
70
|
+
<div className="flex flex-col items-center justify-center min-h-[200px] gap-4 p-8">
|
|
71
|
+
{icon && <div className="rounded-full bg-slate-100 p-3">{icon}</div>}
|
|
72
|
+
<div className="text-center">
|
|
73
|
+
<h3 className="text-lg font-semibold text-slate-900">{title}</h3>
|
|
74
|
+
{description && <p className="mt-2 text-sm text-slate-500">{description}</p>}
|
|
75
|
+
</div>
|
|
76
|
+
{action && (
|
|
77
|
+
<button
|
|
78
|
+
onClick={action.onClick}
|
|
79
|
+
className="mt-2 inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 transition-colors"
|
|
80
|
+
>
|
|
81
|
+
{action.label}
|
|
82
|
+
</button>
|
|
83
|
+
)}
|
|
84
|
+
</div>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
export interface LoadingSpinnerProps {
|
|
4
|
+
size?: 'sm' | 'md' | 'lg';
|
|
5
|
+
className?: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const sizeClasses = {
|
|
9
|
+
sm: 'h-4 w-4',
|
|
10
|
+
md: 'h-8 w-8',
|
|
11
|
+
lg: 'h-12 w-12',
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export function LoadingSpinner({ size = 'md', className = '' }: LoadingSpinnerProps) {
|
|
15
|
+
return (
|
|
16
|
+
<div className={`flex items-center justify-center ${className}`}>
|
|
17
|
+
<div
|
|
18
|
+
className={`${sizeClasses[size]} animate-spin rounded-full border-2 border-slate-300 border-t-blue-600`}
|
|
19
|
+
/>
|
|
20
|
+
</div>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface LoadingOverlayProps {
|
|
25
|
+
message?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function LoadingOverlay({ message = 'Loading...' }: LoadingOverlayProps) {
|
|
29
|
+
return (
|
|
30
|
+
<div className="flex flex-col items-center justify-center min-h-[200px] gap-4">
|
|
31
|
+
<LoadingSpinner size="lg" />
|
|
32
|
+
<p className="text-sm text-slate-500 animate-pulse">{message}</p>
|
|
33
|
+
</div>
|
|
34
|
+
);
|
|
35
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -25,10 +25,35 @@ export { Switch, type SwitchProps } from './components/switch';
|
|
|
25
25
|
export { Textarea, type TextareaProps } from './components/textarea';
|
|
26
26
|
export { Select, type SelectProps, type SelectOption } from './components/select';
|
|
27
27
|
|
|
28
|
+
// Code Block
|
|
29
|
+
export { CodeBlock, InlineCode, type CodeBlockProps } from './code-block';
|
|
30
|
+
|
|
31
|
+
// Navigation
|
|
32
|
+
export { Breadcrumb, type BreadcrumbProps, type BreadcrumbItem } from './navigation';
|
|
33
|
+
|
|
34
|
+
// Data Display
|
|
35
|
+
export { ScoreBar, ScoreCard, type ScoreBarProps, type ScoreCardProps, type ScoreRating } from './data-display';
|
|
36
|
+
|
|
37
|
+
// Feedback
|
|
38
|
+
export {
|
|
39
|
+
LoadingSpinner,
|
|
40
|
+
LoadingOverlay,
|
|
41
|
+
ErrorDisplay,
|
|
42
|
+
EmptyState,
|
|
43
|
+
type LoadingSpinnerProps,
|
|
44
|
+
type LoadingOverlayProps,
|
|
45
|
+
type ErrorDisplayProps,
|
|
46
|
+
type EmptyStateProps,
|
|
47
|
+
} from './feedback';
|
|
48
|
+
|
|
49
|
+
// Theme
|
|
50
|
+
export { ThemeProvider, useTheme, type Theme, type EffectiveTheme } from './theme';
|
|
51
|
+
|
|
28
52
|
// Utils
|
|
29
53
|
export { cn } from './utils/cn';
|
|
30
54
|
export * from './utils/colors';
|
|
31
55
|
export * from './utils/formatters';
|
|
56
|
+
export * from './utils/score';
|
|
32
57
|
|
|
33
58
|
// Hooks
|
|
34
59
|
export { useDebounce } from './hooks/useDebounce';
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
import { cn } from '../utils/cn';
|
|
5
|
+
|
|
6
|
+
export interface BreadcrumbItem {
|
|
7
|
+
label: string;
|
|
8
|
+
href?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface BreadcrumbProps {
|
|
12
|
+
items: BreadcrumbItem[];
|
|
13
|
+
separator?: React.ReactNode;
|
|
14
|
+
className?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const DefaultSeparator = () => (
|
|
18
|
+
<svg
|
|
19
|
+
className="h-4 w-4 text-slate-400"
|
|
20
|
+
fill="none"
|
|
21
|
+
viewBox="0 0 24 24"
|
|
22
|
+
strokeWidth="1.5"
|
|
23
|
+
stroke="currentColor"
|
|
24
|
+
>
|
|
25
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
|
|
26
|
+
</svg>
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
export function Breadcrumb({ items, separator, className }: BreadcrumbProps) {
|
|
30
|
+
const Separator = separator || <DefaultSeparator />;
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<nav className={cn('flex items-center gap-1 text-sm', className)} aria-label="Breadcrumb">
|
|
34
|
+
<ol className="flex items-center gap-1">
|
|
35
|
+
{items.map((item, index) => {
|
|
36
|
+
const isLast = index === items.length - 1;
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<li key={index} className="flex items-center gap-1">
|
|
40
|
+
{index > 0 && (
|
|
41
|
+
<span className="flex-shrink-0">{Separator}</span>
|
|
42
|
+
)}
|
|
43
|
+
{item.href && !isLast ? (
|
|
44
|
+
<a
|
|
45
|
+
href={item.href}
|
|
46
|
+
className="text-slate-600 hover:text-slate-900 transition-colors"
|
|
47
|
+
>
|
|
48
|
+
{item.label}
|
|
49
|
+
</a>
|
|
50
|
+
) : (
|
|
51
|
+
<span className={isLast ? 'font-medium text-slate-900' : 'text-slate-600'}>
|
|
52
|
+
{item.label}
|
|
53
|
+
</span>
|
|
54
|
+
)}
|
|
55
|
+
</li>
|
|
56
|
+
);
|
|
57
|
+
})}
|
|
58
|
+
</ol>
|
|
59
|
+
</nav>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { Breadcrumb, type BreadcrumbProps, type BreadcrumbItem } from './Breadcrumb';
|