veloria-ui 0.1.2
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/CHANGELOG.md +206 -0
- package/LICENSE +21 -0
- package/README.md +253 -0
- package/dist/cli/index.js +511 -0
- package/dist/index.d.mts +1317 -0
- package/dist/index.d.ts +1317 -0
- package/dist/index.js +5373 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +5130 -0
- package/dist/index.mjs.map +1 -0
- package/dist/provider.d.mts +15 -0
- package/dist/provider.d.ts +15 -0
- package/dist/provider.js +1197 -0
- package/dist/provider.js.map +1 -0
- package/dist/provider.mjs +1161 -0
- package/dist/provider.mjs.map +1 -0
- package/dist/tailwind.d.ts +25 -0
- package/dist/tailwind.js +129 -0
- package/package.json +138 -0
- package/src/cli/index.ts +303 -0
- package/src/cli/registry.ts +139 -0
- package/src/components/advanced-forms/index.tsx +975 -0
- package/src/components/basic/Button.tsx +135 -0
- package/src/components/basic/IconButton.tsx +69 -0
- package/src/components/basic/index.tsx +446 -0
- package/src/components/data-display/index.tsx +1158 -0
- package/src/components/feedback/index.tsx +1051 -0
- package/src/components/forms/index.tsx +476 -0
- package/src/components/layout/index.tsx +296 -0
- package/src/components/media/index.tsx +437 -0
- package/src/components/navigation/index.tsx +484 -0
- package/src/components/overlay/index.tsx +473 -0
- package/src/components/utility/index.tsx +566 -0
- package/src/hooks/index.ts +602 -0
- package/src/hooks/use-toast.tsx +74 -0
- package/src/index.ts +396 -0
- package/src/provider.tsx +54 -0
- package/src/styles/atlas.css +252 -0
- package/src/tailwind.ts +124 -0
- package/src/types/index.ts +95 -0
- package/src/utils/cn.ts +66 -0
|
@@ -0,0 +1,1158 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { cva, type VariantProps } from "class-variance-authority";
|
|
3
|
+
import { cn } from "../../utils/cn";
|
|
4
|
+
|
|
5
|
+
// ─── Card ──────────────────────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
const cardVariants = cva(
|
|
8
|
+
"atlas-card rounded-xl border bg-card text-card-foreground",
|
|
9
|
+
{
|
|
10
|
+
variants: {
|
|
11
|
+
variant: {
|
|
12
|
+
default: "border-border shadow-sm",
|
|
13
|
+
outline: "border-border shadow-none",
|
|
14
|
+
elevated: "border-transparent shadow-lg",
|
|
15
|
+
ghost: "border-transparent shadow-none bg-transparent",
|
|
16
|
+
filled: "border-transparent bg-muted",
|
|
17
|
+
},
|
|
18
|
+
interactive: {
|
|
19
|
+
true: "cursor-pointer transition-shadow hover:shadow-md hover:-translate-y-0.5 active:translate-y-0 active:shadow-sm",
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
defaultVariants: { variant: "default" },
|
|
23
|
+
}
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
export interface CardProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof cardVariants> {}
|
|
27
|
+
|
|
28
|
+
const Card = React.forwardRef<HTMLDivElement, CardProps>(
|
|
29
|
+
({ className, variant, interactive, ...props }, ref) => (
|
|
30
|
+
<div ref={ref} className={cn(cardVariants({ variant, interactive, className }))} {...props} />
|
|
31
|
+
)
|
|
32
|
+
);
|
|
33
|
+
Card.displayName = "Card";
|
|
34
|
+
|
|
35
|
+
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
|
36
|
+
({ className, ...props }, ref) => (
|
|
37
|
+
<div ref={ref} className={cn("flex flex-col gap-1.5 p-6", className)} {...props} />
|
|
38
|
+
)
|
|
39
|
+
);
|
|
40
|
+
CardHeader.displayName = "CardHeader";
|
|
41
|
+
|
|
42
|
+
const CardTitle = React.forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
|
43
|
+
({ className, ...props }, ref) => (
|
|
44
|
+
<h3 ref={ref} className={cn("text-lg font-semibold leading-tight tracking-tight", className)} {...props} />
|
|
45
|
+
)
|
|
46
|
+
);
|
|
47
|
+
CardTitle.displayName = "CardTitle";
|
|
48
|
+
|
|
49
|
+
const CardDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
|
|
50
|
+
({ className, ...props }, ref) => (
|
|
51
|
+
<p ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
|
|
52
|
+
)
|
|
53
|
+
);
|
|
54
|
+
CardDescription.displayName = "CardDescription";
|
|
55
|
+
|
|
56
|
+
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
|
57
|
+
({ className, ...props }, ref) => (
|
|
58
|
+
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
|
59
|
+
)
|
|
60
|
+
);
|
|
61
|
+
CardContent.displayName = "CardContent";
|
|
62
|
+
|
|
63
|
+
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
|
64
|
+
({ className, ...props }, ref) => (
|
|
65
|
+
<div ref={ref} className={cn("flex items-center p-6 pt-0 gap-2", className)} {...props} />
|
|
66
|
+
)
|
|
67
|
+
);
|
|
68
|
+
CardFooter.displayName = "CardFooter";
|
|
69
|
+
|
|
70
|
+
// ─── Table ─────────────────────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
|
|
73
|
+
({ className, ...props }, ref) => (
|
|
74
|
+
<div className="atlas-table relative w-full overflow-auto">
|
|
75
|
+
<table ref={ref} className={cn("w-full caption-bottom text-sm", className)} {...props} />
|
|
76
|
+
</div>
|
|
77
|
+
)
|
|
78
|
+
);
|
|
79
|
+
Table.displayName = "Table";
|
|
80
|
+
|
|
81
|
+
const TableHeader = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
|
|
82
|
+
({ className, ...props }, ref) => (
|
|
83
|
+
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
|
|
84
|
+
)
|
|
85
|
+
);
|
|
86
|
+
TableHeader.displayName = "TableHeader";
|
|
87
|
+
|
|
88
|
+
const TableBody = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
|
|
89
|
+
({ className, ...props }, ref) => (
|
|
90
|
+
<tbody ref={ref} className={cn("[&_tr:last-child]:border-0", className)} {...props} />
|
|
91
|
+
)
|
|
92
|
+
);
|
|
93
|
+
TableBody.displayName = "TableBody";
|
|
94
|
+
|
|
95
|
+
const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>(
|
|
96
|
+
({ className, ...props }, ref) => (
|
|
97
|
+
<tr ref={ref} className={cn("border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted", className)} {...props} />
|
|
98
|
+
)
|
|
99
|
+
);
|
|
100
|
+
TableRow.displayName = "TableRow";
|
|
101
|
+
|
|
102
|
+
const TableHead = React.forwardRef<HTMLTableCellElement, React.ThHTMLAttributes<HTMLTableCellElement>>(
|
|
103
|
+
({ className, ...props }, ref) => (
|
|
104
|
+
<th ref={ref} className={cn("h-10 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0", className)} {...props} />
|
|
105
|
+
)
|
|
106
|
+
);
|
|
107
|
+
TableHead.displayName = "TableHead";
|
|
108
|
+
|
|
109
|
+
const TableCell = React.forwardRef<HTMLTableCellElement, React.TdHTMLAttributes<HTMLTableCellElement>>(
|
|
110
|
+
({ className, ...props }, ref) => (
|
|
111
|
+
<td ref={ref} className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)} {...props} />
|
|
112
|
+
)
|
|
113
|
+
);
|
|
114
|
+
TableCell.displayName = "TableCell";
|
|
115
|
+
|
|
116
|
+
const TableCaption = React.forwardRef<HTMLTableCaptionElement, React.HTMLAttributes<HTMLTableCaptionElement>>(
|
|
117
|
+
({ className, ...props }, ref) => (
|
|
118
|
+
<caption ref={ref} className={cn("mt-4 text-sm text-muted-foreground", className)} {...props} />
|
|
119
|
+
)
|
|
120
|
+
);
|
|
121
|
+
TableCaption.displayName = "TableCaption";
|
|
122
|
+
|
|
123
|
+
// ─── DataTable ────────────────────────────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
export interface DataTableColumn<T> {
|
|
126
|
+
key: keyof T | string;
|
|
127
|
+
header: React.ReactNode;
|
|
128
|
+
cell?: (row: T, index: number) => React.ReactNode;
|
|
129
|
+
sortable?: boolean;
|
|
130
|
+
width?: string | number;
|
|
131
|
+
align?: "left" | "center" | "right";
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export interface DataTableProps<T extends Record<string, unknown>> {
|
|
135
|
+
data: T[];
|
|
136
|
+
columns: DataTableColumn<T>[];
|
|
137
|
+
loading?: boolean;
|
|
138
|
+
emptyText?: string;
|
|
139
|
+
onSort?: (key: string, direction: "asc" | "desc") => void;
|
|
140
|
+
striped?: boolean;
|
|
141
|
+
bordered?: boolean;
|
|
142
|
+
className?: string;
|
|
143
|
+
caption?: string;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function DataTable<T extends Record<string, unknown>>({
|
|
147
|
+
data,
|
|
148
|
+
columns,
|
|
149
|
+
loading,
|
|
150
|
+
emptyText = "No data available",
|
|
151
|
+
onSort,
|
|
152
|
+
striped,
|
|
153
|
+
bordered,
|
|
154
|
+
className,
|
|
155
|
+
caption,
|
|
156
|
+
}: DataTableProps<T>) {
|
|
157
|
+
const [sortKey, setSortKey] = React.useState<string | null>(null);
|
|
158
|
+
const [sortDir, setSortDir] = React.useState<"asc" | "desc">("asc");
|
|
159
|
+
|
|
160
|
+
const handleSort = (key: string) => {
|
|
161
|
+
const newDir = sortKey === key && sortDir === "asc" ? "desc" : "asc";
|
|
162
|
+
setSortKey(key);
|
|
163
|
+
setSortDir(newDir);
|
|
164
|
+
onSort?.(key, newDir);
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
return (
|
|
168
|
+
<div className={cn("atlas-data-table relative w-full overflow-auto rounded-md", bordered && "border border-border", className)}>
|
|
169
|
+
<table className="w-full caption-bottom text-sm">
|
|
170
|
+
{caption && <TableCaption>{caption}</TableCaption>}
|
|
171
|
+
<thead className="border-b bg-muted/50">
|
|
172
|
+
<tr>
|
|
173
|
+
{columns.map((col, i) => (
|
|
174
|
+
<th
|
|
175
|
+
key={i}
|
|
176
|
+
style={{ width: col.width }}
|
|
177
|
+
className={cn(
|
|
178
|
+
"h-10 px-4 font-medium text-muted-foreground",
|
|
179
|
+
col.align === "center" && "text-center",
|
|
180
|
+
col.align === "right" && "text-right",
|
|
181
|
+
col.sortable && "cursor-pointer select-none hover:text-foreground",
|
|
182
|
+
)}
|
|
183
|
+
onClick={() => col.sortable && handleSort(String(col.key))}
|
|
184
|
+
aria-sort={
|
|
185
|
+
sortKey === col.key
|
|
186
|
+
? sortDir === "asc" ? "ascending" : "descending"
|
|
187
|
+
: col.sortable ? "none" : undefined
|
|
188
|
+
}
|
|
189
|
+
>
|
|
190
|
+
<span className="flex items-center gap-1">
|
|
191
|
+
{col.header}
|
|
192
|
+
{col.sortable && sortKey === String(col.key) && (
|
|
193
|
+
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
194
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
|
195
|
+
d={sortDir === "asc" ? "M5 15l7-7 7 7" : "M19 9l-7 7-7-7"}
|
|
196
|
+
/>
|
|
197
|
+
</svg>
|
|
198
|
+
)}
|
|
199
|
+
</span>
|
|
200
|
+
</th>
|
|
201
|
+
))}
|
|
202
|
+
</tr>
|
|
203
|
+
</thead>
|
|
204
|
+
<tbody>
|
|
205
|
+
{loading ? (
|
|
206
|
+
<tr>
|
|
207
|
+
<td colSpan={columns.length} className="h-24 text-center">
|
|
208
|
+
<svg className="mx-auto h-5 w-5 animate-spin text-muted-foreground" fill="none" viewBox="0 0 24 24">
|
|
209
|
+
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
|
210
|
+
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
|
211
|
+
</svg>
|
|
212
|
+
</td>
|
|
213
|
+
</tr>
|
|
214
|
+
) : data.length === 0 ? (
|
|
215
|
+
<tr>
|
|
216
|
+
<td colSpan={columns.length} className="h-24 text-center text-muted-foreground">{emptyText}</td>
|
|
217
|
+
</tr>
|
|
218
|
+
) : (
|
|
219
|
+
data.map((row, i) => (
|
|
220
|
+
<tr
|
|
221
|
+
key={i}
|
|
222
|
+
className={cn(
|
|
223
|
+
"border-b transition-colors hover:bg-muted/50",
|
|
224
|
+
striped && i % 2 !== 0 && "bg-muted/20"
|
|
225
|
+
)}
|
|
226
|
+
>
|
|
227
|
+
{columns.map((col, j) => (
|
|
228
|
+
<td
|
|
229
|
+
key={j}
|
|
230
|
+
className={cn(
|
|
231
|
+
"p-4 align-middle",
|
|
232
|
+
col.align === "center" && "text-center",
|
|
233
|
+
col.align === "right" && "text-right",
|
|
234
|
+
)}
|
|
235
|
+
>
|
|
236
|
+
{col.cell
|
|
237
|
+
? col.cell(row, i)
|
|
238
|
+
: String(row[col.key as keyof T] ?? "")}
|
|
239
|
+
</td>
|
|
240
|
+
))}
|
|
241
|
+
</tr>
|
|
242
|
+
))
|
|
243
|
+
)}
|
|
244
|
+
</tbody>
|
|
245
|
+
</table>
|
|
246
|
+
</div>
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
DataTable.displayName = "DataTable";
|
|
250
|
+
|
|
251
|
+
// ─── List & ListItem ─────────────────────────────────────────────────────
|
|
252
|
+
|
|
253
|
+
export interface ListProps extends Omit<React.HTMLAttributes<HTMLUListElement>, "color"> {
|
|
254
|
+
variant?: "simple" | "bordered" | "divided";
|
|
255
|
+
spacing?: "none" | "sm" | "md" | "lg";
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const List = React.forwardRef<HTMLUListElement, ListProps>(
|
|
259
|
+
({ className, variant = "simple", spacing = "none", ...props }, ref) => (
|
|
260
|
+
<ul
|
|
261
|
+
ref={ref}
|
|
262
|
+
className={cn(
|
|
263
|
+
"atlas-list w-full",
|
|
264
|
+
variant === "bordered" && "rounded-md border border-border divide-y divide-border",
|
|
265
|
+
variant === "divided" && "divide-y divide-border",
|
|
266
|
+
spacing === "sm" && "space-y-1",
|
|
267
|
+
spacing === "md" && "space-y-2",
|
|
268
|
+
spacing === "lg" && "space-y-3",
|
|
269
|
+
className
|
|
270
|
+
)}
|
|
271
|
+
{...props}
|
|
272
|
+
/>
|
|
273
|
+
)
|
|
274
|
+
);
|
|
275
|
+
List.displayName = "List";
|
|
276
|
+
|
|
277
|
+
export interface ListItemProps extends React.HTMLAttributes<HTMLLIElement> {
|
|
278
|
+
icon?: React.ReactNode;
|
|
279
|
+
extra?: React.ReactNode;
|
|
280
|
+
active?: boolean;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const ListItem = React.forwardRef<HTMLLIElement, ListItemProps>(
|
|
284
|
+
({ className, icon, extra, active, children, ...props }, ref) => (
|
|
285
|
+
<li
|
|
286
|
+
ref={ref}
|
|
287
|
+
className={cn(
|
|
288
|
+
"atlas-list-item flex items-center gap-3 px-4 py-3",
|
|
289
|
+
active && "bg-accent text-accent-foreground",
|
|
290
|
+
className
|
|
291
|
+
)}
|
|
292
|
+
{...props}
|
|
293
|
+
>
|
|
294
|
+
{icon && <span className="shrink-0 text-muted-foreground [&>svg]:h-4 [&>svg]:w-4" aria-hidden="true">{icon}</span>}
|
|
295
|
+
<span className="flex-1 min-w-0">{children}</span>
|
|
296
|
+
{extra && <span className="shrink-0">{extra}</span>}
|
|
297
|
+
</li>
|
|
298
|
+
)
|
|
299
|
+
);
|
|
300
|
+
ListItem.displayName = "ListItem";
|
|
301
|
+
|
|
302
|
+
// ─── Statistic ────────────────────────────────────────────────────────────
|
|
303
|
+
|
|
304
|
+
export interface StatisticProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "prefix"> {
|
|
305
|
+
label: React.ReactNode;
|
|
306
|
+
value: React.ReactNode;
|
|
307
|
+
prefix?: React.ReactNode;
|
|
308
|
+
suffix?: React.ReactNode;
|
|
309
|
+
trend?: { value: number; label?: string };
|
|
310
|
+
loading?: boolean;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const Statistic = React.forwardRef<HTMLDivElement, StatisticProps>(
|
|
314
|
+
({ className, label, value, prefix, suffix, trend, loading, ...props }, ref) => (
|
|
315
|
+
<div ref={ref} className={cn("atlas-statistic", className)} {...props}>
|
|
316
|
+
<p className="text-sm font-medium text-muted-foreground">{label}</p>
|
|
317
|
+
<div className="mt-1 flex items-end gap-2">
|
|
318
|
+
<span className="text-3xl font-bold tracking-tight">
|
|
319
|
+
{loading ? (
|
|
320
|
+
<span className="inline-block h-8 w-24 animate-pulse rounded bg-muted" />
|
|
321
|
+
) : (
|
|
322
|
+
<>{prefix}{value}{suffix}</>
|
|
323
|
+
)}
|
|
324
|
+
</span>
|
|
325
|
+
{trend && !loading && (
|
|
326
|
+
<span className={cn(
|
|
327
|
+
"mb-1 flex items-center gap-0.5 text-sm font-medium",
|
|
328
|
+
trend.value > 0 ? "text-success" : trend.value < 0 ? "text-destructive" : "text-muted-foreground"
|
|
329
|
+
)}>
|
|
330
|
+
{trend.value !== 0 && (
|
|
331
|
+
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
332
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
|
333
|
+
d={trend.value > 0 ? "M7 17l9-9M7 7h10v10" : "M7 7l9 9M17 7H7v10"}
|
|
334
|
+
/>
|
|
335
|
+
</svg>
|
|
336
|
+
)}
|
|
337
|
+
{Math.abs(trend.value)}%
|
|
338
|
+
{trend.label && <span className="font-normal text-muted-foreground ml-1">{trend.label}</span>}
|
|
339
|
+
</span>
|
|
340
|
+
)}
|
|
341
|
+
</div>
|
|
342
|
+
</div>
|
|
343
|
+
)
|
|
344
|
+
);
|
|
345
|
+
Statistic.displayName = "Statistic";
|
|
346
|
+
|
|
347
|
+
// ─── Timeline ─────────────────────────────────────────────────────────────
|
|
348
|
+
|
|
349
|
+
export interface TimelineEvent {
|
|
350
|
+
title: React.ReactNode;
|
|
351
|
+
description?: React.ReactNode;
|
|
352
|
+
time?: React.ReactNode;
|
|
353
|
+
icon?: React.ReactNode;
|
|
354
|
+
color?: "default" | "primary" | "success" | "warning" | "danger";
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
export interface TimelineProps extends React.HTMLAttributes<HTMLOListElement> {
|
|
358
|
+
events: TimelineEvent[];
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const Timeline = React.forwardRef<HTMLOListElement, TimelineProps>(
|
|
362
|
+
({ className, events, ...props }, ref) => (
|
|
363
|
+
<ol ref={ref} className={cn("atlas-timeline relative flex flex-col", className)} {...props}>
|
|
364
|
+
{events.map((event, i) => (
|
|
365
|
+
<li key={i} className="relative flex gap-4 pb-8 last:pb-0">
|
|
366
|
+
<div className="relative flex flex-col items-center">
|
|
367
|
+
<div className={cn(
|
|
368
|
+
"z-10 flex h-8 w-8 items-center justify-center rounded-full border-2 shrink-0",
|
|
369
|
+
"border-background shadow-sm [&>svg]:h-3.5 [&>svg]:w-3.5",
|
|
370
|
+
event.color === "primary" ? "bg-primary text-primary-foreground" :
|
|
371
|
+
event.color === "success" ? "bg-success text-success-foreground" :
|
|
372
|
+
event.color === "warning" ? "bg-warning text-warning-foreground" :
|
|
373
|
+
event.color === "danger" ? "bg-destructive text-destructive-foreground" :
|
|
374
|
+
"bg-muted text-muted-foreground"
|
|
375
|
+
)}>
|
|
376
|
+
{event.icon ?? <span className="h-2 w-2 rounded-full bg-current" />}
|
|
377
|
+
</div>
|
|
378
|
+
{i < events.length - 1 && (
|
|
379
|
+
<div className="mt-1 w-px flex-1 bg-border" aria-hidden="true" />
|
|
380
|
+
)}
|
|
381
|
+
</div>
|
|
382
|
+
<div className="flex-1 pt-0.5 pb-4 last:pb-0">
|
|
383
|
+
<div className="flex items-start justify-between gap-2">
|
|
384
|
+
<p className="font-medium text-sm">{event.title}</p>
|
|
385
|
+
{event.time && <span className="text-xs text-muted-foreground whitespace-nowrap shrink-0">{event.time}</span>}
|
|
386
|
+
</div>
|
|
387
|
+
{event.description && <p className="mt-1 text-sm text-muted-foreground">{event.description}</p>}
|
|
388
|
+
</div>
|
|
389
|
+
</li>
|
|
390
|
+
))}
|
|
391
|
+
</ol>
|
|
392
|
+
)
|
|
393
|
+
);
|
|
394
|
+
Timeline.displayName = "Timeline";
|
|
395
|
+
|
|
396
|
+
// ─── Calendar ─────────────────────────────────────────────────────────────
|
|
397
|
+
|
|
398
|
+
export interface CalendarProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "onChange"> {
|
|
399
|
+
value?: Date;
|
|
400
|
+
onChange?: (date: Date) => void;
|
|
401
|
+
minDate?: Date;
|
|
402
|
+
maxDate?: Date;
|
|
403
|
+
highlightedDates?: Date[];
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const DAYS = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"];
|
|
407
|
+
const MONTHS = ["January","February","March","April","May","June","July","August","September","October","November","December"];
|
|
408
|
+
|
|
409
|
+
const Calendar = React.forwardRef<HTMLDivElement, CalendarProps>(
|
|
410
|
+
({ className, value, onChange, minDate, maxDate, highlightedDates = [], ...props }, ref) => {
|
|
411
|
+
const today = new Date();
|
|
412
|
+
const [viewDate, setViewDate] = React.useState(value ?? today);
|
|
413
|
+
const year = viewDate.getFullYear();
|
|
414
|
+
const month = viewDate.getMonth();
|
|
415
|
+
|
|
416
|
+
const firstDay = new Date(year, month, 1).getDay();
|
|
417
|
+
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
|
418
|
+
|
|
419
|
+
const cells: (Date | null)[] = [
|
|
420
|
+
...Array.from({ length: firstDay }, (): null => null),
|
|
421
|
+
...Array.from({ length: daysInMonth }, (_, i) => new Date(year, month, i + 1)),
|
|
422
|
+
];
|
|
423
|
+
|
|
424
|
+
const isSameDay = (a: Date, b: Date) =>
|
|
425
|
+
a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
|
|
426
|
+
|
|
427
|
+
return (
|
|
428
|
+
<div ref={ref} className={cn("atlas-calendar w-fit rounded-lg border border-border bg-background p-3 shadow-sm", className)} {...props}>
|
|
429
|
+
<div className="flex items-center justify-between mb-3">
|
|
430
|
+
<button
|
|
431
|
+
type="button"
|
|
432
|
+
onClick={() => setViewDate(new Date(year, month - 1))}
|
|
433
|
+
className="h-7 w-7 flex items-center justify-center rounded-md hover:bg-accent transition-colors"
|
|
434
|
+
aria-label="Previous month"
|
|
435
|
+
>
|
|
436
|
+
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
437
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
|
438
|
+
</svg>
|
|
439
|
+
</button>
|
|
440
|
+
<span className="text-sm font-semibold">{MONTHS[month]} {year}</span>
|
|
441
|
+
<button
|
|
442
|
+
type="button"
|
|
443
|
+
onClick={() => setViewDate(new Date(year, month + 1))}
|
|
444
|
+
className="h-7 w-7 flex items-center justify-center rounded-md hover:bg-accent transition-colors"
|
|
445
|
+
aria-label="Next month"
|
|
446
|
+
>
|
|
447
|
+
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
448
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
|
449
|
+
</svg>
|
|
450
|
+
</button>
|
|
451
|
+
</div>
|
|
452
|
+
<div className="grid grid-cols-7 gap-px">
|
|
453
|
+
{DAYS.map((d) => (
|
|
454
|
+
<div key={d} className="h-8 flex items-center justify-center text-xs font-medium text-muted-foreground">{d}</div>
|
|
455
|
+
))}
|
|
456
|
+
{cells.map((date, i) => {
|
|
457
|
+
if (!date) return <div key={`empty-${i}`} />;
|
|
458
|
+
const isSelected = value ? isSameDay(date, value) : false;
|
|
459
|
+
const isToday = isSameDay(date, today);
|
|
460
|
+
const isHighlighted = highlightedDates.some((d) => isSameDay(d, date));
|
|
461
|
+
const isDisabled =
|
|
462
|
+
(minDate && date < minDate) || (maxDate && date > maxDate);
|
|
463
|
+
|
|
464
|
+
return (
|
|
465
|
+
<button
|
|
466
|
+
key={date.toISOString()}
|
|
467
|
+
type="button"
|
|
468
|
+
disabled={isDisabled}
|
|
469
|
+
onClick={() => onChange?.(date)}
|
|
470
|
+
aria-label={date.toLocaleDateString()}
|
|
471
|
+
aria-pressed={isSelected}
|
|
472
|
+
className={cn(
|
|
473
|
+
"h-8 w-8 text-xs rounded-md flex items-center justify-center transition-colors font-medium relative",
|
|
474
|
+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|
475
|
+
"disabled:pointer-events-none disabled:opacity-30",
|
|
476
|
+
isSelected && "bg-primary text-primary-foreground hover:bg-primary/90",
|
|
477
|
+
!isSelected && isToday && "border border-primary text-primary",
|
|
478
|
+
!isSelected && !isToday && "hover:bg-accent",
|
|
479
|
+
)}
|
|
480
|
+
>
|
|
481
|
+
{date.getDate()}
|
|
482
|
+
{isHighlighted && !isSelected && (
|
|
483
|
+
<span className="absolute bottom-1 left-1/2 -translate-x-1/2 h-1 w-1 rounded-full bg-primary" />
|
|
484
|
+
)}
|
|
485
|
+
</button>
|
|
486
|
+
);
|
|
487
|
+
})}
|
|
488
|
+
</div>
|
|
489
|
+
</div>
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
);
|
|
493
|
+
Calendar.displayName = "Calendar";
|
|
494
|
+
|
|
495
|
+
// ─── CodeBlock ────────────────────────────────────────────────────────────
|
|
496
|
+
|
|
497
|
+
export interface CodeBlockProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
498
|
+
code: string;
|
|
499
|
+
language?: string;
|
|
500
|
+
showLineNumbers?: boolean;
|
|
501
|
+
caption?: string;
|
|
502
|
+
onCopy?: () => void;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
const CodeBlock = React.forwardRef<HTMLDivElement, CodeBlockProps>(
|
|
506
|
+
({ className, code, language, showLineNumbers, caption, onCopy, ...props }, ref) => {
|
|
507
|
+
const [copied, setCopied] = React.useState(false);
|
|
508
|
+
|
|
509
|
+
const handleCopy = async () => {
|
|
510
|
+
await navigator.clipboard.writeText(code);
|
|
511
|
+
setCopied(true);
|
|
512
|
+
onCopy?.();
|
|
513
|
+
setTimeout(() => setCopied(false), 2000);
|
|
514
|
+
};
|
|
515
|
+
|
|
516
|
+
const lines = code.split("\n");
|
|
517
|
+
|
|
518
|
+
return (
|
|
519
|
+
<div ref={ref} className={cn("atlas-code-block relative rounded-lg border border-border bg-muted/50 overflow-hidden", className)} {...props}>
|
|
520
|
+
<div className="flex items-center justify-between px-4 py-2 border-b border-border bg-muted/80">
|
|
521
|
+
{language && <span className="text-xs font-medium text-muted-foreground">{language}</span>}
|
|
522
|
+
{caption && <span className="text-xs text-muted-foreground">{caption}</span>}
|
|
523
|
+
<button
|
|
524
|
+
type="button"
|
|
525
|
+
onClick={handleCopy}
|
|
526
|
+
aria-label={copied ? "Copied!" : "Copy code"}
|
|
527
|
+
className="ml-auto flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-muted-foreground hover:text-foreground hover:bg-background transition-colors"
|
|
528
|
+
>
|
|
529
|
+
{copied ? (
|
|
530
|
+
<>
|
|
531
|
+
<svg className="h-3.5 w-3.5 text-success" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
532
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
|
|
533
|
+
</svg>
|
|
534
|
+
Copied
|
|
535
|
+
</>
|
|
536
|
+
) : (
|
|
537
|
+
<>
|
|
538
|
+
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
539
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
|
540
|
+
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
|
|
541
|
+
/>
|
|
542
|
+
</svg>
|
|
543
|
+
Copy
|
|
544
|
+
</>
|
|
545
|
+
)}
|
|
546
|
+
</button>
|
|
547
|
+
</div>
|
|
548
|
+
<pre className="overflow-x-auto p-4 text-sm">
|
|
549
|
+
<code className={`language-${language}`}>
|
|
550
|
+
{showLineNumbers
|
|
551
|
+
? lines.map((line, i) => (
|
|
552
|
+
<span key={i} className="block">
|
|
553
|
+
<span className="mr-4 select-none text-muted-foreground/50 text-xs w-5 inline-block text-right">{i + 1}</span>
|
|
554
|
+
{line}
|
|
555
|
+
</span>
|
|
556
|
+
))
|
|
557
|
+
: code}
|
|
558
|
+
</code>
|
|
559
|
+
</pre>
|
|
560
|
+
</div>
|
|
561
|
+
);
|
|
562
|
+
}
|
|
563
|
+
);
|
|
564
|
+
CodeBlock.displayName = "CodeBlock";
|
|
565
|
+
|
|
566
|
+
// ─── Chart (placeholder - integrates with recharts/chartjs) ───────────────
|
|
567
|
+
|
|
568
|
+
export interface ChartProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "title"> {
|
|
569
|
+
title?: string;
|
|
570
|
+
description?: string;
|
|
571
|
+
loading?: boolean;
|
|
572
|
+
empty?: boolean;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
const Chart = React.forwardRef<HTMLDivElement, ChartProps>(
|
|
576
|
+
({ className, title, description, loading, empty, children, ...props }, ref) => (
|
|
577
|
+
<div ref={ref} className={cn("atlas-chart", className)} {...props}>
|
|
578
|
+
{(title || description) && (
|
|
579
|
+
<div className="mb-4">
|
|
580
|
+
{title && <h3 className="text-base font-semibold">{title}</h3>}
|
|
581
|
+
{description && <p className="text-sm text-muted-foreground">{description}</p>}
|
|
582
|
+
</div>
|
|
583
|
+
)}
|
|
584
|
+
{loading ? (
|
|
585
|
+
<div className="h-64 w-full animate-pulse rounded-lg bg-muted" />
|
|
586
|
+
) : empty ? (
|
|
587
|
+
<div className="h-64 w-full flex items-center justify-center text-muted-foreground text-sm border border-dashed border-border rounded-lg">
|
|
588
|
+
No chart data available
|
|
589
|
+
</div>
|
|
590
|
+
) : (
|
|
591
|
+
children
|
|
592
|
+
)}
|
|
593
|
+
</div>
|
|
594
|
+
)
|
|
595
|
+
);
|
|
596
|
+
Chart.displayName = "Chart";
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
// ═══════════════════════════════════════════════════════════════
|
|
600
|
+
// New in v0.1.2
|
|
601
|
+
// ═══════════════════════════════════════════════════════════════
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
// ─── StatsCard ────────────────────────────────────────────────────────────
|
|
605
|
+
|
|
606
|
+
export interface StatsCardProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "title"> {
|
|
607
|
+
title: React.ReactNode;
|
|
608
|
+
value: React.ReactNode;
|
|
609
|
+
icon?: React.ReactNode;
|
|
610
|
+
trend?: { value: number; label?: string };
|
|
611
|
+
description?: React.ReactNode;
|
|
612
|
+
loading?: boolean;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
const StatsCard = React.forwardRef<HTMLDivElement, StatsCardProps>(
|
|
616
|
+
({ className, title, value, icon, trend, description, loading, ...props }, ref) => (
|
|
617
|
+
<div
|
|
618
|
+
ref={ref}
|
|
619
|
+
className={cn(
|
|
620
|
+
"atlas-stats-card rounded-xl border border-border bg-card p-6 shadow-sm",
|
|
621
|
+
className
|
|
622
|
+
)}
|
|
623
|
+
{...props}
|
|
624
|
+
>
|
|
625
|
+
<div className="flex items-start justify-between gap-4">
|
|
626
|
+
<div className="flex-1 min-w-0">
|
|
627
|
+
<p className="text-sm font-medium text-muted-foreground truncate">{title}</p>
|
|
628
|
+
<p className="mt-1 text-3xl font-bold tracking-tight">
|
|
629
|
+
{loading ? (
|
|
630
|
+
<span className="inline-block h-8 w-28 animate-pulse rounded bg-muted" />
|
|
631
|
+
) : value}
|
|
632
|
+
</p>
|
|
633
|
+
{trend && !loading && (
|
|
634
|
+
<p className={cn(
|
|
635
|
+
"mt-1 flex items-center gap-1 text-sm font-medium",
|
|
636
|
+
trend.value > 0 ? "text-success" : trend.value < 0 ? "text-destructive" : "text-muted-foreground"
|
|
637
|
+
)}>
|
|
638
|
+
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
639
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
|
640
|
+
d={trend.value > 0 ? "M7 17l9-9M7 7h10v10" : trend.value < 0 ? "M17 7l-9 9M7 7v10h10" : "M5 12h14"}
|
|
641
|
+
/>
|
|
642
|
+
</svg>
|
|
643
|
+
{Math.abs(trend.value)}%
|
|
644
|
+
{trend.label && <span className="font-normal text-muted-foreground">{trend.label}</span>}
|
|
645
|
+
</p>
|
|
646
|
+
)}
|
|
647
|
+
{description && !loading && (
|
|
648
|
+
<p className="mt-1 text-xs text-muted-foreground">{description}</p>
|
|
649
|
+
)}
|
|
650
|
+
</div>
|
|
651
|
+
{icon && (
|
|
652
|
+
<div className={cn(
|
|
653
|
+
"flex h-12 w-12 shrink-0 items-center justify-center rounded-lg",
|
|
654
|
+
"bg-primary/10 text-primary [&>svg]:h-6 [&>svg]:w-6"
|
|
655
|
+
)}>
|
|
656
|
+
{icon}
|
|
657
|
+
</div>
|
|
658
|
+
)}
|
|
659
|
+
</div>
|
|
660
|
+
</div>
|
|
661
|
+
)
|
|
662
|
+
);
|
|
663
|
+
StatsCard.displayName = "StatsCard";
|
|
664
|
+
|
|
665
|
+
// ─── TreeView ─────────────────────────────────────────────────────────────
|
|
666
|
+
|
|
667
|
+
export interface TreeNode {
|
|
668
|
+
id: string;
|
|
669
|
+
label: React.ReactNode;
|
|
670
|
+
icon?: React.ReactNode;
|
|
671
|
+
children?: TreeNode[];
|
|
672
|
+
disabled?: boolean;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
export interface TreeViewProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "onSelect" | "size"> {
|
|
676
|
+
nodes: TreeNode[];
|
|
677
|
+
selected?: string;
|
|
678
|
+
onSelect?: (id: string) => void;
|
|
679
|
+
defaultExpanded?: string[];
|
|
680
|
+
size?: "sm" | "md";
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
interface TreeItemProps {
|
|
684
|
+
node: TreeNode;
|
|
685
|
+
depth: number;
|
|
686
|
+
selected?: string;
|
|
687
|
+
onSelect?: (id: string) => void;
|
|
688
|
+
expanded: Set<string>;
|
|
689
|
+
onToggle: (id: string) => void;
|
|
690
|
+
size: "sm" | "md";
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
const TreeItem = ({ node, depth, selected, onSelect, expanded, onToggle, size }: TreeItemProps) => {
|
|
694
|
+
const hasChildren = node.children && node.children.length > 0;
|
|
695
|
+
const isExpanded = expanded.has(node.id);
|
|
696
|
+
const isSelected = selected === node.id;
|
|
697
|
+
|
|
698
|
+
return (
|
|
699
|
+
<li role="treeitem" aria-selected={isSelected} aria-expanded={hasChildren ? isExpanded : undefined}>
|
|
700
|
+
<button
|
|
701
|
+
type="button"
|
|
702
|
+
disabled={node.disabled}
|
|
703
|
+
onClick={() => {
|
|
704
|
+
if (hasChildren) onToggle(node.id);
|
|
705
|
+
if (!node.disabled) onSelect?.(node.id);
|
|
706
|
+
}}
|
|
707
|
+
style={{ paddingLeft: `${depth * 16 + 8}px` }}
|
|
708
|
+
className={cn(
|
|
709
|
+
"flex w-full items-center gap-2 rounded-md pr-3 text-left transition-colors",
|
|
710
|
+
size === "sm" ? "py-1 text-xs" : "py-1.5 text-sm",
|
|
711
|
+
isSelected
|
|
712
|
+
? "bg-accent text-accent-foreground font-medium"
|
|
713
|
+
: "hover:bg-accent/50 text-foreground",
|
|
714
|
+
node.disabled && "opacity-50 cursor-not-allowed pointer-events-none"
|
|
715
|
+
)}
|
|
716
|
+
>
|
|
717
|
+
<span className="flex h-4 w-4 shrink-0 items-center justify-center">
|
|
718
|
+
{hasChildren ? (
|
|
719
|
+
<svg
|
|
720
|
+
className={cn("h-3.5 w-3.5 transition-transform", isExpanded && "rotate-90")}
|
|
721
|
+
fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
|
722
|
+
>
|
|
723
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
|
724
|
+
</svg>
|
|
725
|
+
) : (
|
|
726
|
+
<span className="h-1 w-1 rounded-full bg-muted-foreground/40" />
|
|
727
|
+
)}
|
|
728
|
+
</span>
|
|
729
|
+
{node.icon && (
|
|
730
|
+
<span className="shrink-0 [&>svg]:h-4 [&>svg]:w-4 text-muted-foreground" aria-hidden="true">
|
|
731
|
+
{node.icon}
|
|
732
|
+
</span>
|
|
733
|
+
)}
|
|
734
|
+
<span className="flex-1 truncate">{node.label}</span>
|
|
735
|
+
</button>
|
|
736
|
+
{hasChildren && isExpanded && (
|
|
737
|
+
<ul role="group">
|
|
738
|
+
{node.children!.map((child) => (
|
|
739
|
+
<TreeItem
|
|
740
|
+
key={child.id}
|
|
741
|
+
node={child}
|
|
742
|
+
depth={depth + 1}
|
|
743
|
+
selected={selected}
|
|
744
|
+
onSelect={onSelect}
|
|
745
|
+
expanded={expanded}
|
|
746
|
+
onToggle={onToggle}
|
|
747
|
+
size={size}
|
|
748
|
+
/>
|
|
749
|
+
))}
|
|
750
|
+
</ul>
|
|
751
|
+
)}
|
|
752
|
+
</li>
|
|
753
|
+
);
|
|
754
|
+
};
|
|
755
|
+
|
|
756
|
+
const TreeView = React.forwardRef<HTMLDivElement, TreeViewProps>(
|
|
757
|
+
({ className, nodes, selected, onSelect, defaultExpanded = [], size = "md", ...props }, ref) => {
|
|
758
|
+
const [expanded, setExpanded] = React.useState<Set<string>>(new Set(defaultExpanded));
|
|
759
|
+
|
|
760
|
+
const toggle = (id: string) => {
|
|
761
|
+
setExpanded((prev) => {
|
|
762
|
+
const next = new Set(prev);
|
|
763
|
+
if (next.has(id)) next.delete(id);
|
|
764
|
+
else next.add(id);
|
|
765
|
+
return next;
|
|
766
|
+
});
|
|
767
|
+
};
|
|
768
|
+
|
|
769
|
+
return (
|
|
770
|
+
<div ref={ref} className={cn("atlas-tree-view", className)} {...props}>
|
|
771
|
+
<ul role="tree" className="space-y-0.5">
|
|
772
|
+
{nodes.map((node) => (
|
|
773
|
+
<TreeItem
|
|
774
|
+
key={node.id}
|
|
775
|
+
node={node}
|
|
776
|
+
depth={0}
|
|
777
|
+
selected={selected}
|
|
778
|
+
onSelect={onSelect}
|
|
779
|
+
expanded={expanded}
|
|
780
|
+
onToggle={toggle}
|
|
781
|
+
size={size}
|
|
782
|
+
/>
|
|
783
|
+
))}
|
|
784
|
+
</ul>
|
|
785
|
+
</div>
|
|
786
|
+
);
|
|
787
|
+
}
|
|
788
|
+
);
|
|
789
|
+
TreeView.displayName = "TreeView";
|
|
790
|
+
|
|
791
|
+
// ─── JsonViewer ───────────────────────────────────────────────────────────
|
|
792
|
+
|
|
793
|
+
export type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };
|
|
794
|
+
|
|
795
|
+
export interface JsonViewerProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
796
|
+
data: JsonValue;
|
|
797
|
+
defaultExpanded?: boolean;
|
|
798
|
+
maxDepth?: number;
|
|
799
|
+
indent?: number;
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
interface JsonNodeProps {
|
|
803
|
+
value: JsonValue;
|
|
804
|
+
keyName?: string;
|
|
805
|
+
depth: number;
|
|
806
|
+
maxDepth: number;
|
|
807
|
+
indent: number;
|
|
808
|
+
defaultExpanded: boolean;
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
const JsonNode = ({ value, keyName, depth, maxDepth, indent, defaultExpanded }: JsonNodeProps) => {
|
|
812
|
+
const [open, setOpen] = React.useState(defaultExpanded || depth < 2);
|
|
813
|
+
const isObject = typeof value === "object" && value !== null && !Array.isArray(value);
|
|
814
|
+
const isArray = Array.isArray(value);
|
|
815
|
+
const isCollapsible = isObject || isArray;
|
|
816
|
+
|
|
817
|
+
const renderValue = () => {
|
|
818
|
+
if (value === null) return <span className="text-muted-foreground">null</span>;
|
|
819
|
+
if (typeof value === "boolean") return <span className="text-blue-500">{String(value)}</span>;
|
|
820
|
+
if (typeof value === "number") return <span className="text-amber-500">{value}</span>;
|
|
821
|
+
if (typeof value === "string") return <span className="text-success">"{value}"</span>;
|
|
822
|
+
return null;
|
|
823
|
+
};
|
|
824
|
+
|
|
825
|
+
const entries = isArray
|
|
826
|
+
? value.map((v, i) => [String(i), v] as [string, JsonValue])
|
|
827
|
+
: isObject
|
|
828
|
+
? Object.entries(value as { [key: string]: JsonValue })
|
|
829
|
+
: [];
|
|
830
|
+
|
|
831
|
+
const bracket = isArray ? ["[", "]"] : ["{", "}"];
|
|
832
|
+
const shouldTruncate = depth >= maxDepth;
|
|
833
|
+
|
|
834
|
+
return (
|
|
835
|
+
<span className="block" style={{ paddingLeft: depth > 0 ? `${indent * 12}px` : 0 }}>
|
|
836
|
+
{keyName !== undefined && (
|
|
837
|
+
<span className="text-foreground font-medium">"{keyName}": </span>
|
|
838
|
+
)}
|
|
839
|
+
{!isCollapsible && renderValue()}
|
|
840
|
+
{isCollapsible && (
|
|
841
|
+
<>
|
|
842
|
+
<button
|
|
843
|
+
type="button"
|
|
844
|
+
onClick={() => setOpen(!open)}
|
|
845
|
+
className="inline-flex items-center gap-0.5 font-mono text-foreground hover:text-primary transition-colors"
|
|
846
|
+
aria-expanded={open}
|
|
847
|
+
>
|
|
848
|
+
<svg className={cn("h-3 w-3 transition-transform", open && "rotate-90")} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
849
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
|
850
|
+
</svg>
|
|
851
|
+
<span>{bracket[0]}</span>
|
|
852
|
+
{!open && (
|
|
853
|
+
<span className="text-muted-foreground text-xs">
|
|
854
|
+
{isArray ? `${value.length} items` : `${entries.length} keys`}
|
|
855
|
+
</span>
|
|
856
|
+
)}
|
|
857
|
+
{!open && <span>{bracket[1]}</span>}
|
|
858
|
+
</button>
|
|
859
|
+
{open && !shouldTruncate && (
|
|
860
|
+
<span className="block">
|
|
861
|
+
{entries.map(([k, v]) => (
|
|
862
|
+
<JsonNode
|
|
863
|
+
key={k}
|
|
864
|
+
keyName={isObject ? k : undefined}
|
|
865
|
+
value={v}
|
|
866
|
+
depth={depth + 1}
|
|
867
|
+
maxDepth={maxDepth}
|
|
868
|
+
indent={indent}
|
|
869
|
+
defaultExpanded={defaultExpanded}
|
|
870
|
+
/>
|
|
871
|
+
))}
|
|
872
|
+
<span style={{ paddingLeft: depth > 0 ? `${(depth) * 12}px` : 0 }} className="block font-mono">
|
|
873
|
+
{bracket[1]}
|
|
874
|
+
</span>
|
|
875
|
+
</span>
|
|
876
|
+
)}
|
|
877
|
+
{open && shouldTruncate && (
|
|
878
|
+
<span className="text-muted-foreground text-xs ml-1">...</span>
|
|
879
|
+
)}
|
|
880
|
+
</>
|
|
881
|
+
)}
|
|
882
|
+
</span>
|
|
883
|
+
);
|
|
884
|
+
};
|
|
885
|
+
|
|
886
|
+
const JsonViewer = React.forwardRef<HTMLDivElement, JsonViewerProps>(
|
|
887
|
+
({ className, data, defaultExpanded = true, maxDepth = 10, indent = 2, ...props }, ref) => (
|
|
888
|
+
<div
|
|
889
|
+
ref={ref}
|
|
890
|
+
className={cn(
|
|
891
|
+
"atlas-json-viewer rounded-lg border border-border bg-muted/30 p-4 font-mono text-xs overflow-x-auto",
|
|
892
|
+
className
|
|
893
|
+
)}
|
|
894
|
+
{...props}
|
|
895
|
+
>
|
|
896
|
+
<JsonNode
|
|
897
|
+
value={data}
|
|
898
|
+
depth={0}
|
|
899
|
+
maxDepth={maxDepth}
|
|
900
|
+
indent={indent}
|
|
901
|
+
defaultExpanded={defaultExpanded}
|
|
902
|
+
/>
|
|
903
|
+
</div>
|
|
904
|
+
)
|
|
905
|
+
);
|
|
906
|
+
JsonViewer.displayName = "JsonViewer";
|
|
907
|
+
|
|
908
|
+
// ─── Heatmap ──────────────────────────────────────────────────────────────
|
|
909
|
+
|
|
910
|
+
export interface HeatmapCell {
|
|
911
|
+
date: string;
|
|
912
|
+
value: number;
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
export interface HeatmapProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "title"> {
|
|
916
|
+
data: HeatmapCell[];
|
|
917
|
+
title?: React.ReactNode;
|
|
918
|
+
colorScale?: [string, string];
|
|
919
|
+
weeks?: number;
|
|
920
|
+
showMonthLabels?: boolean;
|
|
921
|
+
tooltip?: (cell: HeatmapCell) => string;
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
const HEATMAP_DAYS = ["", "Mon", "", "Wed", "", "Fri", ""];
|
|
925
|
+
|
|
926
|
+
const Heatmap = React.forwardRef<HTMLDivElement, HeatmapProps>(
|
|
927
|
+
({
|
|
928
|
+
className,
|
|
929
|
+
data,
|
|
930
|
+
title,
|
|
931
|
+
weeks = 52,
|
|
932
|
+
showMonthLabels = true,
|
|
933
|
+
tooltip,
|
|
934
|
+
...props
|
|
935
|
+
}, ref) => {
|
|
936
|
+
const dataMap = React.useMemo(() => {
|
|
937
|
+
const map = new Map<string, number>();
|
|
938
|
+
data.forEach((d) => map.set(d.date, d.value));
|
|
939
|
+
return map;
|
|
940
|
+
}, [data]);
|
|
941
|
+
|
|
942
|
+
const maxValue = Math.max(...data.map((d) => d.value), 1);
|
|
943
|
+
|
|
944
|
+
const getColor = (value: number) => {
|
|
945
|
+
if (value === 0) return "bg-muted";
|
|
946
|
+
const intensity = value / maxValue;
|
|
947
|
+
if (intensity < 0.25) return "bg-success/25";
|
|
948
|
+
if (intensity < 0.5) return "bg-success/50";
|
|
949
|
+
if (intensity < 0.75) return "bg-success/75";
|
|
950
|
+
return "bg-success";
|
|
951
|
+
};
|
|
952
|
+
|
|
953
|
+
const today = new Date();
|
|
954
|
+
const startDate = new Date(today);
|
|
955
|
+
startDate.setDate(today.getDate() - weeks * 7);
|
|
956
|
+
|
|
957
|
+
const grid: (HeatmapCell | null)[][] = Array.from({ length: 7 }, () => []);
|
|
958
|
+
const current = new Date(startDate);
|
|
959
|
+
current.setDate(current.getDate() - current.getDay());
|
|
960
|
+
|
|
961
|
+
for (let w = 0; w < weeks; w++) {
|
|
962
|
+
for (let d = 0; d < 7; d++) {
|
|
963
|
+
const dateStr = current.toISOString().split("T")[0];
|
|
964
|
+
if (current >= startDate && current <= today) {
|
|
965
|
+
grid[d].push({ date: dateStr, value: dataMap.get(dateStr) ?? 0 });
|
|
966
|
+
} else {
|
|
967
|
+
grid[d].push(null);
|
|
968
|
+
}
|
|
969
|
+
current.setDate(current.getDate() + 1);
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
return (
|
|
974
|
+
<div ref={ref} className={cn("atlas-heatmap", className)} {...props}>
|
|
975
|
+
{title && <p className="mb-3 text-sm font-medium">{title}</p>}
|
|
976
|
+
<div className="flex gap-1">
|
|
977
|
+
<div className="flex flex-col gap-1 mr-1">
|
|
978
|
+
{HEATMAP_DAYS.map((d, i) => (
|
|
979
|
+
<div key={i} className="h-3 w-6 flex items-center">
|
|
980
|
+
<span className="text-[9px] text-muted-foreground">{d}</span>
|
|
981
|
+
</div>
|
|
982
|
+
))}
|
|
983
|
+
</div>
|
|
984
|
+
<div className="flex gap-1 overflow-x-auto">
|
|
985
|
+
{Array.from({ length: weeks }, (_, w) => (
|
|
986
|
+
<div key={w} className="flex flex-col gap-1">
|
|
987
|
+
{Array.from({ length: 7 }, (_, d) => {
|
|
988
|
+
const cell = grid[d][w];
|
|
989
|
+
if (!cell) return <div key={d} className="h-3 w-3 rounded-sm opacity-0" />;
|
|
990
|
+
return (
|
|
991
|
+
<div
|
|
992
|
+
key={d}
|
|
993
|
+
title={tooltip ? tooltip(cell) : `${cell.date}: ${cell.value}`}
|
|
994
|
+
className={cn("h-3 w-3 rounded-sm transition-opacity hover:opacity-80", getColor(cell.value))}
|
|
995
|
+
role="gridcell"
|
|
996
|
+
aria-label={`${cell.date}: ${cell.value}`}
|
|
997
|
+
/>
|
|
998
|
+
);
|
|
999
|
+
})}
|
|
1000
|
+
</div>
|
|
1001
|
+
))}
|
|
1002
|
+
</div>
|
|
1003
|
+
</div>
|
|
1004
|
+
<div className="mt-2 flex items-center gap-1 justify-end">
|
|
1005
|
+
<span className="text-[10px] text-muted-foreground mr-1">Less</span>
|
|
1006
|
+
{["bg-muted", "bg-success/25", "bg-success/50", "bg-success/75", "bg-success"].map((c, i) => (
|
|
1007
|
+
<div key={i} className={cn("h-3 w-3 rounded-sm", c)} />
|
|
1008
|
+
))}
|
|
1009
|
+
<span className="text-[10px] text-muted-foreground ml-1">More</span>
|
|
1010
|
+
</div>
|
|
1011
|
+
</div>
|
|
1012
|
+
);
|
|
1013
|
+
}
|
|
1014
|
+
);
|
|
1015
|
+
Heatmap.displayName = "Heatmap";
|
|
1016
|
+
|
|
1017
|
+
// ─── KanbanBoard ──────────────────────────────────────────────────────────
|
|
1018
|
+
|
|
1019
|
+
export interface KanbanCard {
|
|
1020
|
+
id: string;
|
|
1021
|
+
title: string;
|
|
1022
|
+
description?: string;
|
|
1023
|
+
tags?: string[];
|
|
1024
|
+
assignee?: React.ReactNode;
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
export interface KanbanColumn {
|
|
1028
|
+
id: string;
|
|
1029
|
+
title: string;
|
|
1030
|
+
cards: KanbanCard[];
|
|
1031
|
+
color?: string;
|
|
1032
|
+
limit?: number;
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
export interface KanbanBoardProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "onChange"> {
|
|
1036
|
+
columns: KanbanColumn[];
|
|
1037
|
+
onChange?: (columns: KanbanColumn[]) => void;
|
|
1038
|
+
renderCard?: (card: KanbanCard, columnId: string) => React.ReactNode;
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
const KanbanBoard = React.forwardRef<HTMLDivElement, KanbanBoardProps>(
|
|
1042
|
+
({ className, columns, onChange, renderCard, ...props }, ref) => {
|
|
1043
|
+
const [dragging, setDragging] = React.useState<{ cardId: string; fromCol: string } | null>(null);
|
|
1044
|
+
const [dragOver, setDragOver] = React.useState<string | null>(null);
|
|
1045
|
+
|
|
1046
|
+
const handleDragStart = (cardId: string, colId: string) => {
|
|
1047
|
+
setDragging({ cardId, fromCol: colId });
|
|
1048
|
+
};
|
|
1049
|
+
|
|
1050
|
+
const handleDrop = (toColId: string) => {
|
|
1051
|
+
if (!dragging || dragging.fromCol === toColId) {
|
|
1052
|
+
setDragging(null);
|
|
1053
|
+
setDragOver(null);
|
|
1054
|
+
return;
|
|
1055
|
+
}
|
|
1056
|
+
const updated = columns.map((col) => {
|
|
1057
|
+
if (col.id === dragging.fromCol) {
|
|
1058
|
+
return { ...col, cards: col.cards.filter((c) => c.id !== dragging.cardId) };
|
|
1059
|
+
}
|
|
1060
|
+
if (col.id === toColId) {
|
|
1061
|
+
const card = columns
|
|
1062
|
+
.find((c) => c.id === dragging.fromCol)
|
|
1063
|
+
?.cards.find((c) => c.id === dragging.cardId);
|
|
1064
|
+
if (card) return { ...col, cards: [...col.cards, card] };
|
|
1065
|
+
}
|
|
1066
|
+
return col;
|
|
1067
|
+
});
|
|
1068
|
+
onChange?.(updated);
|
|
1069
|
+
setDragging(null);
|
|
1070
|
+
setDragOver(null);
|
|
1071
|
+
};
|
|
1072
|
+
|
|
1073
|
+
return (
|
|
1074
|
+
<div
|
|
1075
|
+
ref={ref}
|
|
1076
|
+
className={cn("atlas-kanban flex gap-4 overflow-x-auto pb-4", className)}
|
|
1077
|
+
{...props}
|
|
1078
|
+
>
|
|
1079
|
+
{columns.map((col) => (
|
|
1080
|
+
<div
|
|
1081
|
+
key={col.id}
|
|
1082
|
+
onDragOver={(e) => { e.preventDefault(); setDragOver(col.id); }}
|
|
1083
|
+
onDrop={() => handleDrop(col.id)}
|
|
1084
|
+
onDragLeave={() => setDragOver(null)}
|
|
1085
|
+
className={cn(
|
|
1086
|
+
"flex flex-col gap-2 rounded-xl border border-border bg-muted/50 p-3 w-72 shrink-0 min-h-[200px]",
|
|
1087
|
+
"transition-colors",
|
|
1088
|
+
dragOver === col.id && "border-primary bg-primary/5"
|
|
1089
|
+
)}
|
|
1090
|
+
>
|
|
1091
|
+
<div className="flex items-center justify-between px-1 mb-1">
|
|
1092
|
+
<div className="flex items-center gap-2">
|
|
1093
|
+
{col.color && (
|
|
1094
|
+
<span className="h-2 w-2 rounded-full shrink-0" style={{ backgroundColor: col.color }} />
|
|
1095
|
+
)}
|
|
1096
|
+
<h3 className="text-sm font-semibold">{col.title}</h3>
|
|
1097
|
+
<span className="text-xs text-muted-foreground bg-background rounded-full px-1.5 py-0.5 border border-border">
|
|
1098
|
+
{col.cards.length}{col.limit ? `/${col.limit}` : ""}
|
|
1099
|
+
</span>
|
|
1100
|
+
</div>
|
|
1101
|
+
</div>
|
|
1102
|
+
<div className="flex flex-col gap-2">
|
|
1103
|
+
{col.cards.map((card) => (
|
|
1104
|
+
<div
|
|
1105
|
+
key={card.id}
|
|
1106
|
+
draggable
|
|
1107
|
+
onDragStart={() => handleDragStart(card.id, col.id)}
|
|
1108
|
+
className={cn(
|
|
1109
|
+
"rounded-lg border border-border bg-background p-3 shadow-sm cursor-grab active:cursor-grabbing",
|
|
1110
|
+
"transition-opacity hover:shadow-md",
|
|
1111
|
+
dragging?.cardId === card.id && "opacity-40"
|
|
1112
|
+
)}
|
|
1113
|
+
>
|
|
1114
|
+
{renderCard ? renderCard(card, col.id) : (
|
|
1115
|
+
<>
|
|
1116
|
+
<p className="text-sm font-medium leading-snug">{card.title}</p>
|
|
1117
|
+
{card.description && (
|
|
1118
|
+
<p className="mt-1 text-xs text-muted-foreground line-clamp-2">{card.description}</p>
|
|
1119
|
+
)}
|
|
1120
|
+
{(card.tags?.length || card.assignee) && (
|
|
1121
|
+
<div className="mt-2 flex items-center justify-between gap-2">
|
|
1122
|
+
<div className="flex flex-wrap gap-1">
|
|
1123
|
+
{card.tags?.map((tag) => (
|
|
1124
|
+
<span key={tag} className="rounded bg-secondary px-1.5 py-0.5 text-[10px] font-medium">
|
|
1125
|
+
{tag}
|
|
1126
|
+
</span>
|
|
1127
|
+
))}
|
|
1128
|
+
</div>
|
|
1129
|
+
{card.assignee && <div className="shrink-0">{card.assignee}</div>}
|
|
1130
|
+
</div>
|
|
1131
|
+
)}
|
|
1132
|
+
</>
|
|
1133
|
+
)}
|
|
1134
|
+
</div>
|
|
1135
|
+
))}
|
|
1136
|
+
</div>
|
|
1137
|
+
</div>
|
|
1138
|
+
))}
|
|
1139
|
+
</div>
|
|
1140
|
+
);
|
|
1141
|
+
}
|
|
1142
|
+
);
|
|
1143
|
+
KanbanBoard.displayName = "KanbanBoard";
|
|
1144
|
+
|
|
1145
|
+
|
|
1146
|
+
export {
|
|
1147
|
+
|
|
1148
|
+
Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter,
|
|
1149
|
+
Table, TableHeader, TableBody, TableRow, TableHead, TableCell, TableCaption,
|
|
1150
|
+
DataTable,
|
|
1151
|
+
List, ListItem,
|
|
1152
|
+
Statistic,
|
|
1153
|
+
Timeline,
|
|
1154
|
+
Calendar,
|
|
1155
|
+
CodeBlock,
|
|
1156
|
+
Chart,
|
|
1157
|
+
StatsCard, TreeView, JsonViewer, Heatmap, KanbanBoard
|
|
1158
|
+
};
|