@castlekit/castle 0.0.1 → 0.1.0

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.
Files changed (63) hide show
  1. package/README.md +38 -1
  2. package/bin/castle.js +94 -0
  3. package/install.sh +722 -0
  4. package/next.config.ts +7 -0
  5. package/package.json +54 -5
  6. package/postcss.config.mjs +7 -0
  7. package/src/app/api/avatars/[id]/route.ts +75 -0
  8. package/src/app/api/openclaw/agents/route.ts +107 -0
  9. package/src/app/api/openclaw/config/route.ts +94 -0
  10. package/src/app/api/openclaw/events/route.ts +96 -0
  11. package/src/app/api/openclaw/logs/route.ts +59 -0
  12. package/src/app/api/openclaw/ping/route.ts +68 -0
  13. package/src/app/api/openclaw/restart/route.ts +65 -0
  14. package/src/app/api/openclaw/sessions/route.ts +62 -0
  15. package/src/app/globals.css +286 -0
  16. package/src/app/icon.png +0 -0
  17. package/src/app/layout.tsx +42 -0
  18. package/src/app/page.tsx +269 -0
  19. package/src/app/ui-kit/page.tsx +684 -0
  20. package/src/cli/onboarding.ts +576 -0
  21. package/src/components/dashboard/agent-status.tsx +107 -0
  22. package/src/components/dashboard/glass-card.tsx +28 -0
  23. package/src/components/dashboard/goal-widget.tsx +174 -0
  24. package/src/components/dashboard/greeting-widget.tsx +78 -0
  25. package/src/components/dashboard/index.ts +7 -0
  26. package/src/components/dashboard/stat-widget.tsx +61 -0
  27. package/src/components/dashboard/stock-widget.tsx +164 -0
  28. package/src/components/dashboard/weather-widget.tsx +68 -0
  29. package/src/components/icons/castle-icon.tsx +21 -0
  30. package/src/components/kanban/index.ts +3 -0
  31. package/src/components/kanban/kanban-board.tsx +391 -0
  32. package/src/components/kanban/kanban-card.tsx +137 -0
  33. package/src/components/kanban/kanban-column.tsx +98 -0
  34. package/src/components/layout/index.ts +4 -0
  35. package/src/components/layout/page-header.tsx +20 -0
  36. package/src/components/layout/sidebar.tsx +128 -0
  37. package/src/components/layout/theme-toggle.tsx +59 -0
  38. package/src/components/layout/user-menu.tsx +72 -0
  39. package/src/components/ui/alert.tsx +72 -0
  40. package/src/components/ui/avatar.tsx +87 -0
  41. package/src/components/ui/badge.tsx +39 -0
  42. package/src/components/ui/button.tsx +43 -0
  43. package/src/components/ui/card.tsx +107 -0
  44. package/src/components/ui/checkbox.tsx +56 -0
  45. package/src/components/ui/clock.tsx +171 -0
  46. package/src/components/ui/dialog.tsx +105 -0
  47. package/src/components/ui/index.ts +34 -0
  48. package/src/components/ui/input.tsx +112 -0
  49. package/src/components/ui/option-card.tsx +151 -0
  50. package/src/components/ui/progress.tsx +103 -0
  51. package/src/components/ui/radio.tsx +109 -0
  52. package/src/components/ui/select.tsx +46 -0
  53. package/src/components/ui/slider.tsx +62 -0
  54. package/src/components/ui/tabs.tsx +132 -0
  55. package/src/components/ui/toggle-group.tsx +85 -0
  56. package/src/components/ui/toggle.tsx +78 -0
  57. package/src/components/ui/tooltip.tsx +145 -0
  58. package/src/components/ui/uptime.tsx +106 -0
  59. package/src/lib/config.ts +195 -0
  60. package/src/lib/gateway-connection.ts +391 -0
  61. package/src/lib/hooks/use-openclaw.ts +163 -0
  62. package/src/lib/utils.ts +6 -0
  63. package/tsconfig.json +34 -0
@@ -0,0 +1,391 @@
1
+ "use client";
2
+
3
+ import { useState, useRef, useEffect } from "react";
4
+ import { Plus, MoreHorizontal } from "lucide-react";
5
+ import {
6
+ DndContext,
7
+ DragOverlay,
8
+ rectIntersection,
9
+ KeyboardSensor,
10
+ PointerSensor,
11
+ useSensor,
12
+ useSensors,
13
+ type DragStartEvent,
14
+ type DragEndEvent,
15
+ type DragOverEvent,
16
+ } from "@dnd-kit/core";
17
+ import { arrayMove, sortableKeyboardCoordinates } from "@dnd-kit/sortable";
18
+ import { cn } from "@/lib/utils";
19
+ import { KanbanColumn } from "./kanban-column";
20
+ import { KanbanCard, type KanbanCardProps } from "./kanban-card";
21
+
22
+ export interface ColumnData {
23
+ id: string;
24
+ title: string;
25
+ color?: string;
26
+ cards: KanbanCardProps[];
27
+ }
28
+
29
+ export interface KanbanBoardProps {
30
+ columns?: ColumnData[];
31
+ className?: string;
32
+ onCardMove?: (cardId: string, fromColumn: string, toColumn: string, newIndex: number) => void;
33
+ }
34
+
35
+ const defaultColumns: ColumnData[] = [
36
+ {
37
+ id: "captured",
38
+ title: "Captured",
39
+ color: "#a3a3a3",
40
+ cards: [
41
+ {
42
+ id: "1",
43
+ title: "Add voice commands to agents",
44
+ description: "Explore options for voice input/output with agents",
45
+ labels: ["feature"],
46
+ priority: "low",
47
+ },
48
+ {
49
+ id: "2",
50
+ title: "Mobile app concept",
51
+ description: "Think about mobile experience for Castle",
52
+ labels: ["idea"],
53
+ },
54
+ {
55
+ id: "3",
56
+ title: "Agent memory persistence",
57
+ labels: ["technical"],
58
+ },
59
+ ],
60
+ },
61
+ {
62
+ id: "vibing",
63
+ title: "Vibing",
64
+ color: "#8b5cf6",
65
+ cards: [
66
+ {
67
+ id: "4",
68
+ title: "Chess coach agent",
69
+ description: "Agent that helps improve chess game through analysis",
70
+ labels: ["agent", "learning"],
71
+ priority: "medium",
72
+ assignee: { name: "Sage" },
73
+ },
74
+ {
75
+ id: "5",
76
+ title: "Crypto portfolio tracker",
77
+ description: "Track Bitcoin and other crypto holdings",
78
+ labels: ["app"],
79
+ commentCount: 3,
80
+ },
81
+ ],
82
+ },
83
+ {
84
+ id: "scoped",
85
+ title: "Scoped",
86
+ color: "#3b82f6",
87
+ cards: [
88
+ {
89
+ id: "6",
90
+ title: "The Armory - System Dashboard",
91
+ description: "Dashboard showing API usage, Mac Mini health, agent stats",
92
+ labels: ["castle", "priority"],
93
+ priority: "high",
94
+ assignee: { name: "Max" },
95
+ commentCount: 5,
96
+ attachmentCount: 2,
97
+ },
98
+ ],
99
+ },
100
+ {
101
+ id: "in_development",
102
+ title: "In Development",
103
+ color: "#f59e0b",
104
+ cards: [
105
+ {
106
+ id: "7",
107
+ title: "Projects - Kanban Board",
108
+ description: "Project management kanban for Castle app",
109
+ labels: ["castle", "active"],
110
+ priority: "urgent",
111
+ assignee: { name: "Mason" },
112
+ commentCount: 8,
113
+ },
114
+ ],
115
+ },
116
+ {
117
+ id: "review",
118
+ title: "Review",
119
+ color: "#ec4899",
120
+ cards: [],
121
+ },
122
+ {
123
+ id: "live",
124
+ title: "Live",
125
+ color: "#22c55e",
126
+ cards: [
127
+ {
128
+ id: "8",
129
+ title: "Castle App Bootstrap",
130
+ description: "Initial Next.js setup with Tailwind",
131
+ labels: ["castle"],
132
+ assignee: { name: "Mason" },
133
+ },
134
+ {
135
+ id: "9",
136
+ title: "Agent SOUL Architecture",
137
+ description: "Modular identity system for agents",
138
+ labels: ["documentation"],
139
+ },
140
+ ],
141
+ },
142
+ ];
143
+
144
+ function KanbanBoard({ columns: initialColumns = defaultColumns, className, onCardMove }: KanbanBoardProps) {
145
+ const [columns, setColumns] = useState<ColumnData[]>(initialColumns);
146
+ const [activeCard, setActiveCard] = useState<KanbanCardProps | null>(null);
147
+ const [isMounted, setIsMounted] = useState(false);
148
+ const [overColumnId, setOverColumnId] = useState<string | null>(null);
149
+
150
+ const columnsRef = useRef(columns);
151
+ useEffect(() => {
152
+ columnsRef.current = columns;
153
+ }, [columns]);
154
+
155
+ const isProcessing = useRef(false);
156
+
157
+ useEffect(() => {
158
+ setIsMounted(true);
159
+ }, []);
160
+
161
+ const sensors = useSensors(
162
+ useSensor(PointerSensor, {
163
+ activationConstraint: {
164
+ distance: 8,
165
+ },
166
+ }),
167
+ useSensor(KeyboardSensor, {
168
+ coordinateGetter: sortableKeyboardCoordinates,
169
+ })
170
+ );
171
+
172
+ function handleDragStart(event: DragStartEvent) {
173
+ const { active } = event;
174
+ for (const col of columnsRef.current) {
175
+ const card = col.cards.find((c) => c.id === active.id);
176
+ if (card) {
177
+ setActiveCard(card);
178
+ break;
179
+ }
180
+ }
181
+ }
182
+
183
+ function handleDragOver(event: DragOverEvent) {
184
+ const { active, over } = event;
185
+
186
+ if (!over) {
187
+ setOverColumnId(null);
188
+ return;
189
+ }
190
+
191
+ const activeId = active.id as string;
192
+ const overId = over.id as string;
193
+
194
+ const currentColumns = columnsRef.current;
195
+
196
+ const findColByCard = (cardId: string) =>
197
+ currentColumns.find((col) => col.cards.some((card) => card.id === cardId));
198
+ const findColById = (colId: string) =>
199
+ currentColumns.find((col) => col.id === colId);
200
+
201
+ const activeCol = findColByCard(activeId);
202
+ const overCol = findColByCard(overId) || findColById(overId);
203
+
204
+ if (overCol) {
205
+ setOverColumnId(overCol.id);
206
+ }
207
+
208
+ if (!activeCol || !overCol) return;
209
+ if (activeId === overId) return;
210
+
211
+ if (isProcessing.current) return;
212
+
213
+ if (activeCol.id === overCol.id) return;
214
+
215
+ const draggedCard = activeCol.cards.find((c) => c.id === activeId);
216
+ if (!draggedCard) return;
217
+
218
+ const overCardIndex = overCol.cards.findIndex((c) => c.id === overId);
219
+ const insertIndex = overCardIndex >= 0 ? overCardIndex : overCol.cards.length;
220
+
221
+ isProcessing.current = true;
222
+
223
+ setColumns((prev) => {
224
+ return prev.map((col) => {
225
+ if (col.id === activeCol.id) {
226
+ return { ...col, cards: col.cards.filter((c) => c.id !== activeId) };
227
+ }
228
+ if (col.id === overCol.id) {
229
+ const newCards = [...col.cards];
230
+ newCards.splice(insertIndex, 0, draggedCard);
231
+ return { ...col, cards: newCards };
232
+ }
233
+ return col;
234
+ });
235
+ });
236
+
237
+ requestAnimationFrame(() => {
238
+ isProcessing.current = false;
239
+ });
240
+ }
241
+
242
+ function handleDragEnd(event: DragEndEvent) {
243
+ const { active, over } = event;
244
+
245
+ setActiveCard(null);
246
+ setOverColumnId(null);
247
+ isProcessing.current = false;
248
+
249
+ if (!over) return;
250
+
251
+ const activeId = active.id as string;
252
+ const overId = over.id as string;
253
+ if (activeId === overId) return;
254
+
255
+ setColumns((prev) => {
256
+ const findColByCard = (cardId: string) =>
257
+ prev.find((col) => col.cards.some((card) => card.id === cardId));
258
+
259
+ const activeCol = findColByCard(activeId);
260
+ const overCol = findColByCard(overId);
261
+
262
+ if (!activeCol || !overCol || activeCol.id !== overCol.id) return prev;
263
+
264
+ const oldIndex = activeCol.cards.findIndex((c) => c.id === activeId);
265
+ const newIndex = activeCol.cards.findIndex((c) => c.id === overId);
266
+
267
+ if (oldIndex === -1 || newIndex === -1 || oldIndex === newIndex) return prev;
268
+
269
+ return prev.map((col) =>
270
+ col.id === activeCol.id
271
+ ? { ...col, cards: arrayMove(col.cards, oldIndex, newIndex) }
272
+ : col
273
+ );
274
+ });
275
+
276
+ if (onCardMove) {
277
+ const currentColumns = columnsRef.current;
278
+ for (const col of currentColumns) {
279
+ const idx = col.cards.findIndex((c) => c.id === activeId);
280
+ if (idx >= 0) {
281
+ onCardMove(activeId, "", col.id, idx);
282
+ break;
283
+ }
284
+ }
285
+ }
286
+ }
287
+
288
+ if (!isMounted) {
289
+ return (
290
+ <div
291
+ className={cn(
292
+ "flex items-stretch gap-4 overflow-x-auto pb-4",
293
+ className
294
+ )}
295
+ >
296
+ {columns.map((column) => (
297
+ <div
298
+ key={column.id}
299
+ className="flex flex-col min-w-[280px] max-w-[320px] bg-background-secondary rounded-[var(--radius-lg)] p-3 transition-colors"
300
+ >
301
+ <div className="flex items-center justify-between mb-3 px-1">
302
+ <div className="flex items-center gap-2">
303
+ {column.color && (
304
+ <div
305
+ className="h-3 w-3 rounded-[var(--radius-full)]"
306
+ style={{ backgroundColor: column.color }}
307
+ />
308
+ )}
309
+ <h3 className="text-sm font-medium text-foreground">{column.title}</h3>
310
+ <span className="text-xs text-foreground-muted bg-surface px-1.5 py-0.5 rounded-[var(--radius-sm)]">
311
+ {column.cards.length}
312
+ </span>
313
+ </div>
314
+ <div className="flex items-center gap-1">
315
+ <button
316
+ className="p-1 rounded-[var(--radius-sm)] hover:bg-surface transition-colors cursor-pointer"
317
+ disabled
318
+ >
319
+ <Plus className="h-4 w-4 text-foreground-muted" />
320
+ </button>
321
+ <button
322
+ className="p-1 rounded-[var(--radius-sm)] hover:bg-surface transition-colors cursor-pointer"
323
+ disabled
324
+ >
325
+ <MoreHorizontal className="h-4 w-4 text-foreground-muted" />
326
+ </button>
327
+ </div>
328
+ </div>
329
+
330
+ <div className="flex flex-col gap-2 flex-1 overflow-y-auto rounded-[var(--radius-md)] p-2 -m-1 border border-dashed border-transparent transition-colors">
331
+ {column.cards.map((card) => (
332
+ <KanbanCard key={card.id} {...card} />
333
+ ))}
334
+ {column.cards.length === 0 && (
335
+ <div className="flex items-center justify-center h-20 text-sm text-foreground-muted">
336
+ No tasks
337
+ </div>
338
+ )}
339
+ </div>
340
+
341
+ <button
342
+ className="flex items-center justify-center gap-2 mt-3 p-2 text-sm text-foreground-secondary hover:text-foreground hover:bg-surface rounded-[var(--radius-md)] transition-colors cursor-pointer"
343
+ >
344
+ <Plus className="h-4 w-4" />
345
+ Add task
346
+ </button>
347
+ </div>
348
+ ))}
349
+ </div>
350
+ );
351
+ }
352
+
353
+ return (
354
+ <DndContext
355
+ sensors={sensors}
356
+ collisionDetection={rectIntersection}
357
+ onDragStart={handleDragStart}
358
+ onDragOver={handleDragOver}
359
+ onDragEnd={handleDragEnd}
360
+ >
361
+ <div
362
+ className={cn(
363
+ "flex items-stretch gap-4 overflow-x-auto pb-4",
364
+ className
365
+ )}
366
+ >
367
+ {columns.map((column) => (
368
+ <KanbanColumn
369
+ key={column.id}
370
+ id={column.id}
371
+ title={column.title}
372
+ color={column.color}
373
+ cards={column.cards}
374
+ count={column.cards.length}
375
+ isDropTarget={overColumnId === column.id}
376
+ />
377
+ ))}
378
+ </div>
379
+
380
+ <DragOverlay dropAnimation={null}>
381
+ {activeCard ? (
382
+ <div className="shadow-xl rotate-2">
383
+ <KanbanCard {...activeCard} />
384
+ </div>
385
+ ) : null}
386
+ </DragOverlay>
387
+ </DndContext>
388
+ );
389
+ }
390
+
391
+ export { KanbanBoard };
@@ -0,0 +1,137 @@
1
+ "use client";
2
+
3
+ import { useSortable } from "@dnd-kit/sortable";
4
+ import { CSS } from "@dnd-kit/utilities";
5
+ import { MoreHorizontal, MessageSquare, Paperclip } from "lucide-react";
6
+ import { cn } from "@/lib/utils";
7
+ import { Badge } from "@/components/ui/badge";
8
+
9
+ export type TaskPriority = "low" | "medium" | "high" | "urgent";
10
+ export type TaskStatus =
11
+ | "captured"
12
+ | "vibing"
13
+ | "scoped"
14
+ | "ready"
15
+ | "in_development"
16
+ | "review"
17
+ | "live";
18
+
19
+ export interface KanbanCardProps {
20
+ id: string;
21
+ title: string;
22
+ description?: string;
23
+ priority?: TaskPriority;
24
+ assignee?: {
25
+ name: string;
26
+ avatar?: string;
27
+ };
28
+ labels?: string[];
29
+ commentCount?: number;
30
+ attachmentCount?: number;
31
+ className?: string;
32
+ onClick?: () => void;
33
+ }
34
+
35
+ function KanbanCard({
36
+ title,
37
+ description,
38
+ assignee,
39
+ labels = [],
40
+ commentCount = 0,
41
+ attachmentCount = 0,
42
+ className,
43
+ onClick,
44
+ }: KanbanCardProps) {
45
+ return (
46
+ <div
47
+ className={cn(
48
+ "group relative rounded-[var(--radius-md)] bg-surface border border-border p-4 transition-shadow cursor-pointer",
49
+ "hover:border-border-hover hover:shadow-md hover:shadow-black/5 dark:hover:shadow-black/20",
50
+ className
51
+ )}
52
+ onClick={onClick}
53
+ >
54
+ <button
55
+ className="absolute right-2 top-2 p-1 rounded-[var(--radius-sm)] opacity-0 group-hover:opacity-100 hover:bg-surface-hover transition-opacity"
56
+ onClick={(e) => {
57
+ e.stopPropagation();
58
+ }}
59
+ >
60
+ <MoreHorizontal className="h-4 w-4 text-foreground-muted" />
61
+ </button>
62
+
63
+ {labels.length > 0 && (
64
+ <div className="flex flex-wrap gap-1 mb-3">
65
+ {labels.map((label) => (
66
+ <Badge key={label} variant="outline" size="sm">
67
+ {label}
68
+ </Badge>
69
+ ))}
70
+ </div>
71
+ )}
72
+
73
+ <h4 className="text-sm font-medium text-foreground pr-6 mb-1">{title}</h4>
74
+
75
+ {description && (
76
+ <p className="text-xs text-foreground-secondary line-clamp-2 mb-3">
77
+ {description}
78
+ </p>
79
+ )}
80
+
81
+ <div className="flex items-center justify-between mt-3 pt-3 border-t border-border">
82
+ <div className="flex items-center gap-3">
83
+ {commentCount > 0 && (
84
+ <div className="flex items-center gap-1 text-foreground-muted">
85
+ <MessageSquare className="h-3.5 w-3.5" />
86
+ <span className="text-xs">{commentCount}</span>
87
+ </div>
88
+ )}
89
+ {attachmentCount > 0 && (
90
+ <div className="flex items-center gap-1 text-foreground-muted">
91
+ <Paperclip className="h-3.5 w-3.5" />
92
+ <span className="text-xs">{attachmentCount}</span>
93
+ </div>
94
+ )}
95
+ </div>
96
+
97
+ {assignee && (
98
+ <div className="h-6 w-6 rounded-[var(--radius-full)] bg-surface-hover flex items-center justify-center text-xs font-medium text-foreground-secondary border border-border">
99
+ {assignee.name[0]}
100
+ </div>
101
+ )}
102
+ </div>
103
+ </div>
104
+ );
105
+ }
106
+
107
+ function SortableCard(props: KanbanCardProps) {
108
+ const {
109
+ attributes,
110
+ listeners,
111
+ setNodeRef,
112
+ transform,
113
+ isDragging,
114
+ } = useSortable({ id: props.id });
115
+
116
+ const style = {
117
+ transform: CSS.Transform.toString(transform),
118
+ opacity: isDragging ? 0.5 : 1,
119
+ };
120
+
121
+ return (
122
+ <div
123
+ ref={setNodeRef}
124
+ style={style}
125
+ {...attributes}
126
+ {...listeners}
127
+ className={cn(
128
+ "touch-none",
129
+ isDragging && "z-50"
130
+ )}
131
+ >
132
+ <KanbanCard {...props} />
133
+ </div>
134
+ );
135
+ }
136
+
137
+ export { KanbanCard, SortableCard };
@@ -0,0 +1,98 @@
1
+ "use client";
2
+
3
+ import { useDroppable } from "@dnd-kit/core";
4
+ import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
5
+ import { Plus, MoreHorizontal } from "lucide-react";
6
+ import { cn } from "@/lib/utils";
7
+ import { SortableCard, type KanbanCardProps } from "./kanban-card";
8
+
9
+ export interface KanbanColumnProps {
10
+ id: string;
11
+ title: string;
12
+ count?: number;
13
+ color?: string;
14
+ cards?: KanbanCardProps[];
15
+ className?: string;
16
+ onAddCard?: () => void;
17
+ isDropTarget?: boolean;
18
+ }
19
+
20
+ function KanbanColumn({
21
+ id,
22
+ title,
23
+ count = 0,
24
+ color,
25
+ cards = [],
26
+ className,
27
+ onAddCard,
28
+ isDropTarget = false,
29
+ }: KanbanColumnProps) {
30
+ const { setNodeRef } = useDroppable({
31
+ id,
32
+ });
33
+
34
+ return (
35
+ <div
36
+ className={cn(
37
+ "flex flex-col min-w-[280px] max-w-[320px] bg-background-secondary rounded-[var(--radius-lg)] p-3 transition-colors",
38
+ className
39
+ )}
40
+ >
41
+ <div className="flex items-center justify-between mb-3 px-1">
42
+ <div className="flex items-center gap-2">
43
+ {color && (
44
+ <div
45
+ className="h-3 w-3 rounded-[var(--radius-full)]"
46
+ style={{ backgroundColor: color }}
47
+ />
48
+ )}
49
+ <h3 className="text-sm font-medium text-foreground">{title}</h3>
50
+ <span className="text-xs text-foreground-muted bg-surface px-1.5 py-0.5 rounded-[var(--radius-sm)]">
51
+ {count}
52
+ </span>
53
+ </div>
54
+ <div className="flex items-center gap-1">
55
+ <button
56
+ className="p-1 rounded-[var(--radius-sm)] hover:bg-surface transition-colors cursor-pointer"
57
+ onClick={onAddCard}
58
+ >
59
+ <Plus className="h-4 w-4 text-foreground-muted" />
60
+ </button>
61
+ <button className="p-1 rounded-[var(--radius-sm)] hover:bg-surface transition-colors cursor-pointer">
62
+ <MoreHorizontal className="h-4 w-4 text-foreground-muted" />
63
+ </button>
64
+ </div>
65
+ </div>
66
+
67
+ <div
68
+ ref={setNodeRef}
69
+ className={cn(
70
+ "flex flex-col gap-2 flex-1 overflow-y-auto rounded-[var(--radius-md)] p-2 -m-1 border border-dashed border-transparent transition-colors",
71
+ isDropTarget && "border-accent/50 bg-accent/10"
72
+ )}
73
+ >
74
+ <SortableContext items={cards.map((c) => c.id)} strategy={verticalListSortingStrategy}>
75
+ {cards.map((card) => (
76
+ <SortableCard key={card.id} {...card} />
77
+ ))}
78
+ </SortableContext>
79
+
80
+ {cards.length === 0 && (
81
+ <div className="flex items-center justify-center h-20 text-sm text-foreground-muted">
82
+ No tasks
83
+ </div>
84
+ )}
85
+ </div>
86
+
87
+ <button
88
+ className="flex items-center justify-center gap-2 mt-3 p-2 text-sm text-foreground-secondary hover:text-foreground hover:bg-surface rounded-[var(--radius-md)] transition-colors cursor-pointer"
89
+ onClick={onAddCard}
90
+ >
91
+ <Plus className="h-4 w-4" />
92
+ Add task
93
+ </button>
94
+ </div>
95
+ );
96
+ }
97
+
98
+ export { KanbanColumn };
@@ -0,0 +1,4 @@
1
+ export { Sidebar, type SidebarProps } from "./sidebar";
2
+ export { ThemeToggle, type ThemeToggleProps } from "./theme-toggle";
3
+ export { UserMenu, type UserMenuProps } from "./user-menu";
4
+ export { PageHeader, type PageHeaderProps } from "./page-header";
@@ -0,0 +1,20 @@
1
+ import { cn } from "@/lib/utils";
2
+
3
+ export interface PageHeaderProps {
4
+ title: string;
5
+ subtitle?: string;
6
+ className?: string;
7
+ }
8
+
9
+ function PageHeader({ title, subtitle, className }: PageHeaderProps) {
10
+ return (
11
+ <div className={cn("space-y-2", className)}>
12
+ <h1 className="text-2xl font-semibold text-foreground">{title}</h1>
13
+ {subtitle ? (
14
+ <p className="text-sm text-foreground-secondary">{subtitle}</p>
15
+ ) : null}
16
+ </div>
17
+ );
18
+ }
19
+
20
+ export { PageHeader };