@aiready/components 0.1.21 → 0.1.22
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/index.d.ts +92 -3
- package/dist/index.js +358 -2
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/code-block/CodeBlock.tsx +147 -0
- package/src/code-block/index.ts +1 -0
- package/src/data-display/ScoreBar.tsx +118 -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 +24 -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/package.json
CHANGED
|
@@ -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,118 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { cn } from '../utils/cn';
|
|
4
|
+
|
|
5
|
+
export type ScoreRating = 'excellent' | 'good' | 'fair' | 'needs-work' | 'critical';
|
|
6
|
+
|
|
7
|
+
export interface ScoreBarProps {
|
|
8
|
+
score: number;
|
|
9
|
+
maxScore?: number;
|
|
10
|
+
label: string;
|
|
11
|
+
showScore?: boolean;
|
|
12
|
+
size?: 'sm' | 'md' | 'lg';
|
|
13
|
+
className?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const ratingConfig: Record<ScoreRating, { color: string; bgColor: string; label: string }> = {
|
|
17
|
+
excellent: { color: 'bg-green-500', bgColor: 'bg-green-100', label: 'Excellent' },
|
|
18
|
+
good: { color: 'bg-emerald-500', bgColor: 'bg-emerald-100', label: 'Good' },
|
|
19
|
+
fair: { color: 'bg-amber-500', bgColor: 'bg-amber-100', label: 'Fair' },
|
|
20
|
+
'needs-work': { color: 'bg-orange-500', bgColor: 'bg-orange-100', label: 'Needs Work' },
|
|
21
|
+
critical: { color: 'bg-red-500', bgColor: 'bg-red-100', label: 'Critical' },
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
function getRating(score: number): ScoreRating {
|
|
25
|
+
if (score >= 90) return 'excellent';
|
|
26
|
+
if (score >= 75) return 'good';
|
|
27
|
+
if (score >= 60) return 'fair';
|
|
28
|
+
if (score >= 40) return 'needs-work';
|
|
29
|
+
return 'critical';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const sizeConfig = {
|
|
33
|
+
sm: { height: 'h-1.5', text: 'text-xs', score: 'text-sm' },
|
|
34
|
+
md: { height: 'h-2', text: 'text-sm', score: 'text-base' },
|
|
35
|
+
lg: { height: 'h-3', text: 'text-base', score: 'text-lg' },
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export function ScoreBar({
|
|
39
|
+
score,
|
|
40
|
+
maxScore = 100,
|
|
41
|
+
label,
|
|
42
|
+
showScore = true,
|
|
43
|
+
size = 'md',
|
|
44
|
+
className,
|
|
45
|
+
}: ScoreBarProps) {
|
|
46
|
+
const percentage = Math.min(100, Math.max(0, (score / maxScore) * 100));
|
|
47
|
+
const rating = getRating(percentage);
|
|
48
|
+
const config = ratingConfig[rating];
|
|
49
|
+
const sizes = sizeConfig[size];
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<div className={cn('space-y-1', className)}>
|
|
53
|
+
<div className="flex items-center justify-between">
|
|
54
|
+
<span className={cn('text-slate-700', sizes.text)}>{label}</span>
|
|
55
|
+
{showScore && (
|
|
56
|
+
<span className={cn('font-bold text-slate-900', sizes.score)}>
|
|
57
|
+
{score}/{maxScore}
|
|
58
|
+
</span>
|
|
59
|
+
)}
|
|
60
|
+
</div>
|
|
61
|
+
<div className={cn('w-full rounded-full bg-slate-200', sizes.height)}>
|
|
62
|
+
<div
|
|
63
|
+
className={cn('rounded-full transition-all duration-500', config.color, sizes.height)}
|
|
64
|
+
style={{ width: `${percentage}%` }}
|
|
65
|
+
/>
|
|
66
|
+
</div>
|
|
67
|
+
</div>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface ScoreCardProps {
|
|
72
|
+
score: number;
|
|
73
|
+
title?: string;
|
|
74
|
+
breakdown?: Array<{
|
|
75
|
+
label: string;
|
|
76
|
+
score: number;
|
|
77
|
+
weight?: number;
|
|
78
|
+
}>;
|
|
79
|
+
className?: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function ScoreCard({ score, title, breakdown, className }: ScoreCardProps) {
|
|
83
|
+
const rating = getRating(score);
|
|
84
|
+
const config = ratingConfig[rating];
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
<div className={cn('rounded-xl border-2 border-slate-200 bg-white p-6 shadow-lg', className)}>
|
|
88
|
+
<div className="mb-4">
|
|
89
|
+
<div className="text-4xl font-black text-slate-900">{score}/100</div>
|
|
90
|
+
<div className={cn('text-lg font-bold', `text-${rating === 'excellent' ? 'green' : rating === 'good' ? 'emerald' : rating === 'fair' ? 'amber' : rating === 'needs-work' ? 'orange' : 'red'}-600`)}>
|
|
91
|
+
{config.label} Rating
|
|
92
|
+
</div>
|
|
93
|
+
{title && <p className="text-sm text-slate-600 mt-1">{title}</p>}
|
|
94
|
+
</div>
|
|
95
|
+
|
|
96
|
+
{breakdown && breakdown.length > 0 && (
|
|
97
|
+
<div className="space-y-3">
|
|
98
|
+
{breakdown.map((item, index) => (
|
|
99
|
+
<ScoreBar
|
|
100
|
+
key={index}
|
|
101
|
+
score={item.score}
|
|
102
|
+
label={item.label}
|
|
103
|
+
size="sm"
|
|
104
|
+
/>
|
|
105
|
+
))}
|
|
106
|
+
</div>
|
|
107
|
+
)}
|
|
108
|
+
|
|
109
|
+
{breakdown && breakdown.length > 0 && (
|
|
110
|
+
<div className="mt-4 text-xs text-slate-600 bg-slate-50 p-3 rounded-lg">
|
|
111
|
+
<strong>Formula:</strong> {breakdown.map(item =>
|
|
112
|
+
`${item.score}×${item.weight || 1}`
|
|
113
|
+
).join(' + ')} / 100 = {score}
|
|
114
|
+
</div>
|
|
115
|
+
)}
|
|
116
|
+
</div>
|
|
117
|
+
);
|
|
118
|
+
}
|
|
@@ -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,6 +25,30 @@ 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';
|
|
@@ -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';
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { createContext, useContext, useEffect, useState } from 'react';
|
|
4
|
+
|
|
5
|
+
export type Theme = 'dark' | 'light' | 'system';
|
|
6
|
+
export type EffectiveTheme = 'dark' | 'light';
|
|
7
|
+
|
|
8
|
+
interface ThemeContextValue {
|
|
9
|
+
theme: Theme;
|
|
10
|
+
setTheme: (theme: Theme) => void;
|
|
11
|
+
effectiveTheme: EffectiveTheme;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);
|
|
15
|
+
|
|
16
|
+
const STORAGE_KEY = 'aiready-theme';
|
|
17
|
+
|
|
18
|
+
function getSystemTheme(): EffectiveTheme {
|
|
19
|
+
if (typeof window === 'undefined') return 'light';
|
|
20
|
+
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function getStoredTheme(): Theme {
|
|
24
|
+
if (typeof window === 'undefined') return 'system';
|
|
25
|
+
try {
|
|
26
|
+
const stored = localStorage.getItem(STORAGE_KEY);
|
|
27
|
+
if (stored === 'dark' || stored === 'light' || stored === 'system') {
|
|
28
|
+
return stored;
|
|
29
|
+
}
|
|
30
|
+
} catch {
|
|
31
|
+
// localStorage not available
|
|
32
|
+
}
|
|
33
|
+
return 'system';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface ThemeProviderProps {
|
|
37
|
+
children: React.ReactNode;
|
|
38
|
+
defaultTheme?: Theme;
|
|
39
|
+
storageKey?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function ThemeProvider({
|
|
43
|
+
children,
|
|
44
|
+
defaultTheme = 'system',
|
|
45
|
+
storageKey = STORAGE_KEY,
|
|
46
|
+
}: ThemeProviderProps) {
|
|
47
|
+
const [theme, setThemeState] = useState<Theme>(defaultTheme);
|
|
48
|
+
const [effectiveTheme, setEffectiveTheme] = useState<EffectiveTheme>('light');
|
|
49
|
+
const [mounted, setMounted] = useState(false);
|
|
50
|
+
|
|
51
|
+
// Initialize theme from storage on mount
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
const storedTheme = getStoredTheme();
|
|
54
|
+
setThemeState(storedTheme);
|
|
55
|
+
setMounted(true);
|
|
56
|
+
}, []);
|
|
57
|
+
|
|
58
|
+
// Update effective theme when theme or system preference changes
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
if (!mounted) return;
|
|
61
|
+
|
|
62
|
+
const updateEffectiveTheme = () => {
|
|
63
|
+
if (theme === 'system') {
|
|
64
|
+
setEffectiveTheme(getSystemTheme());
|
|
65
|
+
} else {
|
|
66
|
+
setEffectiveTheme(theme);
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
updateEffectiveTheme();
|
|
71
|
+
|
|
72
|
+
// Listen for system theme changes
|
|
73
|
+
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
|
74
|
+
const handleChange = () => {
|
|
75
|
+
if (theme === 'system') {
|
|
76
|
+
setEffectiveTheme(getSystemTheme());
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
mediaQuery.addEventListener('change', handleChange);
|
|
81
|
+
return () => mediaQuery.removeEventListener('change', handleChange);
|
|
82
|
+
}, [theme, mounted]);
|
|
83
|
+
|
|
84
|
+
// Apply theme class to document
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
if (!mounted) return;
|
|
87
|
+
|
|
88
|
+
const root = document.documentElement;
|
|
89
|
+
root.classList.remove('light', 'dark');
|
|
90
|
+
root.classList.add(effectiveTheme);
|
|
91
|
+
}, [effectiveTheme, mounted]);
|
|
92
|
+
|
|
93
|
+
const setTheme = (newTheme: Theme) => {
|
|
94
|
+
setThemeState(newTheme);
|
|
95
|
+
try {
|
|
96
|
+
localStorage.setItem(storageKey, newTheme);
|
|
97
|
+
} catch {
|
|
98
|
+
// localStorage not available
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
// Prevent hydration mismatch
|
|
103
|
+
if (!mounted) {
|
|
104
|
+
return (
|
|
105
|
+
<ThemeContext.Provider value={{ theme: defaultTheme, setTheme: () => {}, effectiveTheme: 'light' }}>
|
|
106
|
+
{children}
|
|
107
|
+
</ThemeContext.Provider>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return (
|
|
112
|
+
<ThemeContext.Provider value={{ theme, setTheme, effectiveTheme }}>
|
|
113
|
+
{children}
|
|
114
|
+
</ThemeContext.Provider>
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function useTheme(): ThemeContextValue {
|
|
119
|
+
const context = useContext(ThemeContext);
|
|
120
|
+
if (context === undefined) {
|
|
121
|
+
throw new Error('useTheme must be used within a ThemeProvider');
|
|
122
|
+
}
|
|
123
|
+
return context;
|
|
124
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { ThemeProvider, useTheme, type Theme, type EffectiveTheme } from './ThemeProvider';
|