deepspace 0.3.9 → 0.3.11

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 (120) hide show
  1. package/dist/cli.js +96 -18
  2. package/dist/cli.js.map +1 -1
  3. package/dist/index.d.ts +160 -1
  4. package/dist/index.js +283 -126
  5. package/dist/index.js.map +1 -1
  6. package/dist/server.d.ts +172 -2
  7. package/dist/server.js +581 -1
  8. package/dist/server.js.map +1 -1
  9. package/dist/worker.d.ts +252 -2
  10. package/dist/worker.js +588 -1
  11. package/dist/worker.js.map +1 -1
  12. package/features/admin-page/feature.json +29 -0
  13. package/features/admin-page/src/AdminPage.tsx +292 -0
  14. package/features/admin-page/src/admin-schema.ts +20 -0
  15. package/features/ai-chat/feature.json +41 -0
  16. package/features/ai-chat/src/AiChatPage.tsx +692 -0
  17. package/features/ai-chat/src/ChatPanel.tsx +1098 -0
  18. package/features/ai-chat/src/ai-chat-schema.ts +11 -0
  19. package/features/canvas/feature.json +45 -0
  20. package/features/canvas/src/CanvasEditorPage.tsx +87 -0
  21. package/features/canvas/src/CanvasListPage.tsx +202 -0
  22. package/features/canvas/src/canvas-schema.ts +33 -0
  23. package/features/canvas/src/components/CanvasToolbar.tsx +170 -0
  24. package/features/canvas/src/components/CanvasView.tsx +369 -0
  25. package/features/canvas/src/components/ShapeRenderer.tsx +114 -0
  26. package/features/cron/feature.json +33 -0
  27. package/features/cron/src/CronLogPage.tsx +100 -0
  28. package/features/cron/src/cron.ts +25 -0
  29. package/features/docs/feature.json +96 -0
  30. package/features/docs/src/DocsEditorPage.tsx +1012 -0
  31. package/features/docs/src/DocsListPage.tsx +951 -0
  32. package/features/docs/src/DocsOutlinePanel.tsx +68 -0
  33. package/features/docs/src/DocsPresence.tsx +138 -0
  34. package/features/docs/src/InviteDialog.tsx +336 -0
  35. package/features/docs/src/LibrarySidebar.tsx +464 -0
  36. package/features/docs/src/docs-favorites.ts +18 -0
  37. package/features/docs/src/docs-library-types.ts +15 -0
  38. package/features/docs/src/docs-schema.ts +80 -0
  39. package/features/docs/src/docs-ui.css +309 -0
  40. package/features/docs/src/editor/DocEditorSurface.tsx +257 -0
  41. package/features/docs/src/editor/DocsTiptapToolbar.tsx +560 -0
  42. package/features/docs/src/editor/useDocEditor.ts +206 -0
  43. package/features/docs/src/use-yjs-room-with-awareness.ts +285 -0
  44. package/features/file-attachments/feature.json +42 -0
  45. package/features/file-attachments/src/AttachmentsPage.tsx +864 -0
  46. package/features/file-attachments/src/attachments-constants.ts +9 -0
  47. package/features/file-attachments/src/attachments-schema.ts +45 -0
  48. package/features/file-attachments/src/useFilePreview.ts +471 -0
  49. package/features/file-attachments/src/useMimeTypeDetection.ts +138 -0
  50. package/features/file-manager/feature.json +24 -0
  51. package/features/file-manager/src/FileManagerPage.tsx +330 -0
  52. package/features/integration-test/feature.json +18 -0
  53. package/features/integration-test/src/IntegrationTestPage.tsx +423 -0
  54. package/features/integration-test/src/integration.spec.ts +42 -0
  55. package/features/items/feature.json +33 -0
  56. package/features/items/src/ItemsPage.tsx +325 -0
  57. package/features/items/src/items-constants.ts +10 -0
  58. package/features/items/src/items-schema.ts +27 -0
  59. package/features/kanban/feature.json +18 -0
  60. package/features/kanban/src/Kanban.tsx +283 -0
  61. package/features/landing/feature.json +62 -0
  62. package/features/landing/src/LandingPage.tsx +627 -0
  63. package/features/landing/src/primitives.tsx +389 -0
  64. package/features/landing/src/sections/FAQSection.tsx +110 -0
  65. package/features/landing/src/sections/FeaturesGridSection.tsx +311 -0
  66. package/features/landing/src/sections/HowItWorksSection.tsx +62 -0
  67. package/features/landing/src/sections/LogoCloudSection.tsx +58 -0
  68. package/features/landing/src/sections/ShowcaseSection.tsx +63 -0
  69. package/features/landing/src/sections/StatsSection.tsx +31 -0
  70. package/features/landing/src/sections/TeamSection.tsx +86 -0
  71. package/features/landing/src/sections/TestimonialsSection.tsx +68 -0
  72. package/features/landing/src/sections/VideoSection.tsx +71 -0
  73. package/features/leaderboard/feature.json +33 -0
  74. package/features/leaderboard/src/LeaderboardPage.tsx +372 -0
  75. package/features/leaderboard/src/leaderboard-constants.ts +21 -0
  76. package/features/leaderboard/src/leaderboard-schema.ts +36 -0
  77. package/features/messaging/feature.json +111 -0
  78. package/features/messaging/src/ChatMultiPage.tsx +300 -0
  79. package/features/messaging/src/ChatPage.tsx +75 -0
  80. package/features/messaging/src/components/chat/ChatHeader.tsx +76 -0
  81. package/features/messaging/src/components/chat/MessageActionSheet.tsx +168 -0
  82. package/features/messaging/src/components/chat/MessageInput.tsx +65 -0
  83. package/features/messaging/src/components/chat/MessageItem.tsx +352 -0
  84. package/features/messaging/src/components/chat/MessageList.tsx +248 -0
  85. package/features/messaging/src/components/chat/ReactionPicker.tsx +2 -0
  86. package/features/messaging/src/components/chat/ThreadPanel.tsx +107 -0
  87. package/features/messaging/src/components/chat/UserProfilePopover.tsx +158 -0
  88. package/features/messaging/src/components/chat-multi/AddMemberModal.tsx +188 -0
  89. package/features/messaging/src/components/chat-multi/BrowseGroupsModal.tsx +163 -0
  90. package/features/messaging/src/components/chat-multi/ChannelListItem.tsx +147 -0
  91. package/features/messaging/src/components/chat-multi/ChannelSettingsPanel.tsx +344 -0
  92. package/features/messaging/src/components/chat-multi/ChannelSidebar.tsx +460 -0
  93. package/features/messaging/src/components/chat-multi/CreateGroupModal.tsx +190 -0
  94. package/features/messaging/src/components/chat-multi/InvitationList.tsx +149 -0
  95. package/features/messaging/src/components/chat-multi/NewMessageModal.tsx +159 -0
  96. package/features/messaging/src/hooks/useChatChannel.ts +64 -0
  97. package/features/messaging/src/hooks/useLongPress.ts +74 -0
  98. package/features/messaging/src/hooks/useMultiChannel.ts +142 -0
  99. package/features/messaging/src/messaging-schema.ts +25 -0
  100. package/features/presence-test/feature.json +21 -0
  101. package/features/presence-test/src/PresenceTestPage.tsx +120 -0
  102. package/features/sidebar/feature.json +19 -0
  103. package/features/sidebar/src/AppSidebar.tsx +558 -0
  104. package/features/tasks/feature.json +33 -0
  105. package/features/tasks/src/TasksPage.tsx +598 -0
  106. package/features/tasks/src/tasks-constants.ts +43 -0
  107. package/features/tasks/src/tasks-schema.ts +56 -0
  108. package/features/testing/feature.json +49 -0
  109. package/features/testing/src/TestPage.tsx +145 -0
  110. package/features/testing/src/cpu-burn-action.ts +51 -0
  111. package/features/testing/src/subrequest-burn-action.ts +53 -0
  112. package/features/testing/src/test-schema.ts +23 -0
  113. package/features/topbar/feature.json +18 -0
  114. package/features/topbar/src/Topbar.tsx +353 -0
  115. package/features/tree/feature.json +26 -0
  116. package/features/tree/src/TreeSidebar.tsx +607 -0
  117. package/features/tree/src/tree-constants.ts +68 -0
  118. package/features/tree/src/tree-hooks.ts +258 -0
  119. package/package.json +4 -2
  120. package/scripts/add-feature.cjs +654 -0
@@ -0,0 +1,325 @@
1
+ /**
2
+ * Items Page
3
+ *
4
+ * Demonstrates:
5
+ * - useQuery for fetching records
6
+ * - useMutations for CRUD operations
7
+ * - useUser for current user
8
+ * - Role-based UI elements
9
+ */
10
+
11
+ import { useState, useMemo } from 'react'
12
+ import { useUser } from 'deepspace'
13
+ import { useQuery } from 'deepspace'
14
+ import { useMutations } from 'deepspace'
15
+ import { Button, Modal, EmptyState, Badge } from 'deepspace'
16
+ import { ROLES, type Role } from 'deepspace'
17
+ import { ITEM_STATUS } from '../components/items/items-constants'
18
+
19
+ // ============================================================================
20
+ // Types
21
+ // ============================================================================
22
+
23
+ interface Item {
24
+ title: string
25
+ description?: string
26
+ status: string
27
+ ownerId: string
28
+ }
29
+
30
+ interface ItemsPageProps {
31
+ className?: string
32
+ }
33
+
34
+ // ============================================================================
35
+ // Create Item Modal
36
+ // ============================================================================
37
+
38
+ interface CreateItemModalProps {
39
+ isOpen: boolean
40
+ onClose: () => void
41
+ onCreate: (title: string, description: string) => void
42
+ }
43
+
44
+ function CreateItemModal({ isOpen, onClose, onCreate }: CreateItemModalProps) {
45
+ const [title, setTitle] = useState('')
46
+ const [description, setDescription] = useState('')
47
+
48
+ const handleSubmit = () => {
49
+ if (title.trim()) {
50
+ onCreate(title.trim(), description.trim())
51
+ setTitle('')
52
+ setDescription('')
53
+ onClose()
54
+ }
55
+ }
56
+
57
+ return (
58
+ <Modal open={isOpen} onClose={onClose} size="sm">
59
+ <Modal.Header onClose={onClose}>
60
+ <Modal.Title>Create Item</Modal.Title>
61
+ </Modal.Header>
62
+ <Modal.Body>
63
+ <div className="space-y-4">
64
+ <div>
65
+ <label className="block text-sm font-medium text-muted-foreground mb-2">Title</label>
66
+ <input
67
+ type="text"
68
+ value={title}
69
+ onChange={(e) => setTitle(e.target.value)}
70
+ placeholder="Enter item title"
71
+ className="w-full px-3 py-2 bg-transparent border border-border rounded-lg text-foreground placeholder-muted-foreground focus:outline-none focus:ring-ring"
72
+ autoFocus
73
+ />
74
+ </div>
75
+ <div>
76
+ <label className="block text-sm font-medium text-muted-foreground mb-2">Description</label>
77
+ <textarea
78
+ value={description}
79
+ onChange={(e) => setDescription(e.target.value)}
80
+ placeholder="Enter description (optional)"
81
+ rows={3}
82
+ className="w-full px-3 py-2 bg-transparent border border-border rounded-lg text-foreground placeholder-muted-foreground focus:outline-none focus:ring-ring resize-none"
83
+ />
84
+ </div>
85
+ </div>
86
+ </Modal.Body>
87
+ <Modal.Footer>
88
+ <Button variant="secondary" onClick={onClose}>Cancel</Button>
89
+ <Button onClick={handleSubmit} disabled={!title.trim()}>Create</Button>
90
+ </Modal.Footer>
91
+ </Modal>
92
+ )
93
+ }
94
+
95
+ // ============================================================================
96
+ // Item Card Component
97
+ // ============================================================================
98
+
99
+ interface ItemCardProps {
100
+ item: { recordId: string; data: Item; createdAt: string }
101
+ isOwner: boolean
102
+ onToggleStatus: () => void
103
+ onDelete: () => void
104
+ }
105
+
106
+ function ItemCard({ item, isOwner, onToggleStatus, onDelete }: ItemCardProps) {
107
+ const isArchived = item.data.status === ITEM_STATUS.ARCHIVED
108
+
109
+ return (
110
+ <div className={`p-4 bg-card/60 rounded-xl border ${isArchived ? 'border-border/30 opacity-60' : 'border-border'}`}>
111
+ <div className="flex items-start justify-between gap-3">
112
+ <div className="flex-1 min-w-0">
113
+ <div className="flex items-center gap-2 mb-1">
114
+ <h3 className={`font-medium ${isArchived ? 'text-muted-foreground line-through' : 'text-foreground'}`}>
115
+ {item.data.title}
116
+ </h3>
117
+ <Badge
118
+ variant={isArchived ? 'secondary' : 'success'}
119
+ size="sm"
120
+ >
121
+ {isArchived ? 'Archived' : 'Active'}
122
+ </Badge>
123
+ </div>
124
+ {item.data.description && (
125
+ <p className="text-sm text-muted-foreground line-clamp-2">{item.data.description}</p>
126
+ )}
127
+ <p className="text-xs text-muted-foreground mt-2">
128
+ {new Date(item.createdAt).toLocaleDateString()}
129
+ </p>
130
+ </div>
131
+
132
+ {isOwner && (
133
+ <div className="flex items-center gap-1">
134
+ <button
135
+ onClick={onToggleStatus}
136
+ className="p-2 text-muted-foreground hover:text-foreground hover:bg-muted/60 rounded-lg transition-colors"
137
+ title={isArchived ? 'Restore' : 'Archive'}
138
+ >
139
+ {isArchived ? (
140
+ <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
141
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
142
+ </svg>
143
+ ) : (
144
+ <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
145
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4" />
146
+ </svg>
147
+ )}
148
+ </button>
149
+ <button
150
+ onClick={onDelete}
151
+ className="p-2 text-muted-foreground hover:text-destructive hover:bg-destructive/20 rounded-lg transition-colors"
152
+ title="Delete"
153
+ >
154
+ <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
155
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
156
+ </svg>
157
+ </button>
158
+ </div>
159
+ )}
160
+ </div>
161
+ </div>
162
+ )
163
+ }
164
+
165
+ // ============================================================================
166
+ // Main Page
167
+ // ============================================================================
168
+
169
+ export default function ItemsPage({ className }: ItemsPageProps) {
170
+ const { user } = useUser()
171
+ const userRole = (user?.role ?? ROLES.VIEWER) as Role
172
+ const canCreate = userRole === ROLES.MEMBER || userRole === ROLES.ADMIN
173
+
174
+ const [showCreateModal, setShowCreateModal] = useState(false)
175
+
176
+ // Query all items
177
+ const { records: items, status } = useQuery<Item>('items', {
178
+ orderBy: 'createdAt',
179
+ orderDir: 'desc',
180
+ })
181
+
182
+ // Mutations for items
183
+ const { create, put, remove } = useMutations<Item>('items')
184
+
185
+ // Separate own items and others
186
+ const { myItems, otherItems } = useMemo(() => {
187
+ const my: typeof items = []
188
+ const other: typeof items = []
189
+
190
+ items.forEach(item => {
191
+ if (item.data.ownerId === user?.id) {
192
+ my.push(item)
193
+ } else {
194
+ other.push(item)
195
+ }
196
+ })
197
+
198
+ return { myItems: my, otherItems: other }
199
+ }, [items, user?.id])
200
+
201
+ const handleCreate = async (title: string, description: string) => {
202
+ await create({
203
+ title,
204
+ description,
205
+ status: ITEM_STATUS.ACTIVE,
206
+ ownerId: user!.id,
207
+ })
208
+ }
209
+
210
+ const handleToggleStatus = async (itemId: string, currentStatus: string) => {
211
+ const newStatus = currentStatus === ITEM_STATUS.ACTIVE
212
+ ? ITEM_STATUS.ARCHIVED
213
+ : ITEM_STATUS.ACTIVE
214
+
215
+ const item = items.find(i => i.recordId === itemId)
216
+ if (item) {
217
+ await put(itemId, { ...item.data, status: newStatus })
218
+ }
219
+ }
220
+
221
+ const handleDelete = async (itemId: string) => {
222
+ if (confirm('Are you sure you want to delete this item?')) {
223
+ await remove(itemId)
224
+ }
225
+ }
226
+
227
+ const isLoading = status === 'loading'
228
+
229
+ return (
230
+ <div className={`h-full bg-background overflow-y-auto ${className ?? ''}`}>
231
+ {/* Header */}
232
+ <div className="bg-card/60 backdrop-blur-md border-b border-border sticky top-0 z-10">
233
+ <div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
234
+ <div className="flex items-center justify-between">
235
+ <div>
236
+ <h1 className="text-2xl font-bold text-foreground">Welcome, {user?.name ?? 'Guest'}</h1>
237
+ <p className="text-muted-foreground mt-1">
238
+ {canCreate
239
+ ? 'Create and manage your items below'
240
+ : 'You can view items but need member access to create'
241
+ }
242
+ </p>
243
+ </div>
244
+
245
+ {canCreate && (
246
+ <Button onClick={() => setShowCreateModal(true)}>
247
+ <svg className="w-4 h-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
248
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
249
+ </svg>
250
+ New Item
251
+ </Button>
252
+ )}
253
+ </div>
254
+ </div>
255
+ </div>
256
+
257
+ {/* Content */}
258
+ <div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
259
+ {isLoading ? (
260
+ <div className="flex items-center justify-center py-16">
261
+ <div className="w-8 h-8 border-2 border-primary/20 border-t-primary rounded-full animate-spin" />
262
+ </div>
263
+ ) : items.length === 0 ? (
264
+ <EmptyState
265
+ title="No items yet"
266
+ description={canCreate
267
+ ? "Create your first item to get started"
268
+ : "No items have been created yet"
269
+ }
270
+ icon={
271
+ <svg className="w-12 h-12" fill="none" viewBox="0 0 24 24" stroke="currentColor">
272
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" />
273
+ </svg>
274
+ }
275
+ />
276
+ ) : (
277
+ <div className="space-y-8">
278
+ {/* My Items */}
279
+ {myItems.length > 0 && (
280
+ <section>
281
+ <h2 className="text-lg font-semibold text-foreground mb-4">My Items</h2>
282
+ <div className="grid gap-4 sm:grid-cols-2">
283
+ {myItems.map(item => (
284
+ <ItemCard
285
+ key={item.recordId}
286
+ item={item}
287
+ isOwner={true}
288
+ onToggleStatus={() => handleToggleStatus(item.recordId, item.data.status)}
289
+ onDelete={() => handleDelete(item.recordId)}
290
+ />
291
+ ))}
292
+ </div>
293
+ </section>
294
+ )}
295
+
296
+ {/* Other Items */}
297
+ {otherItems.length > 0 && (
298
+ <section>
299
+ <h2 className="text-lg font-semibold text-foreground mb-4">All Items</h2>
300
+ <div className="grid gap-4 sm:grid-cols-2">
301
+ {otherItems.map(item => (
302
+ <ItemCard
303
+ key={item.recordId}
304
+ item={item}
305
+ isOwner={false}
306
+ onToggleStatus={() => {}}
307
+ onDelete={() => {}}
308
+ />
309
+ ))}
310
+ </div>
311
+ </section>
312
+ )}
313
+ </div>
314
+ )}
315
+ </div>
316
+
317
+ {/* Create Modal */}
318
+ <CreateItemModal
319
+ isOpen={showCreateModal}
320
+ onClose={() => setShowCreateModal(false)}
321
+ onCreate={handleCreate}
322
+ />
323
+ </div>
324
+ )
325
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Items Feature - Constants
3
+ */
4
+
5
+ export const ITEM_STATUS = {
6
+ ACTIVE: 'active',
7
+ ARCHIVED: 'archived',
8
+ } as const
9
+
10
+ export type ItemStatus = typeof ITEM_STATUS[keyof typeof ITEM_STATUS]
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Items Feature - Schema
3
+ *
4
+ * A simple collection with ownership demonstrating:
5
+ * - ownerField for 'own' permission checks
6
+ * - userBound fields that auto-populate with current user ID
7
+ * - immutable fields that cannot change after creation
8
+ * - Basic CRUD with role-based permissions
9
+ */
10
+
11
+ import type { CollectionSchema } from 'deepspace/worker'
12
+
13
+ export const itemsSchema: CollectionSchema = {
14
+ name: 'items',
15
+ columns: [
16
+ { name: 'title', storage: 'text', interpretation: 'plain', required: true },
17
+ { name: 'description', storage: 'text', interpretation: 'plain' },
18
+ { name: 'status', storage: 'text', interpretation: { kind: 'select', options: ['active', 'archived'] }, default: 'active' },
19
+ { name: 'ownerId', storage: 'text', interpretation: 'plain', required: true, userBound: true, immutable: true },
20
+ ],
21
+ ownerField: 'ownerId',
22
+ permissions: {
23
+ viewer: { read: true, create: false, update: false, delete: false },
24
+ member: { read: true, create: true, update: 'own', delete: 'own' },
25
+ admin: { read: true, create: true, update: true, delete: true },
26
+ },
27
+ }
@@ -0,0 +1,18 @@
1
+ {
2
+ "id": "kanban",
3
+ "name": "Kanban Board",
4
+ "category": "display",
5
+ "description": "Drag-and-drop kanban board component with columns and cards",
6
+ "details": "Generic KanbanBoard component with drag-and-drop between columns, customizable card rendering, and a useKanban hook for state management. No schema needed -- it is a pure UI component that works with any data source.",
7
+ "files": [
8
+ {
9
+ "src": "src/Kanban.tsx",
10
+ "dest": "src/components/kanban/Kanban.tsx"
11
+ }
12
+ ],
13
+ "requires": [],
14
+ "instructions": [
15
+ "Import KanbanBoard and useKanban from 'src/components/kanban/Kanban' (adjust the relative path to match where you use it)"
16
+ ],
17
+ "patterns": []
18
+ }
@@ -0,0 +1,283 @@
1
+ import React, { useState, useCallback, ReactNode, DragEvent } from 'react'
2
+ import { MoreHorizontal, Plus, GripVertical } from 'lucide-react'
3
+
4
+ // ============================================================================
5
+ // Types
6
+ // ============================================================================
7
+
8
+ export interface KanbanItem {
9
+ id: string
10
+ title: string
11
+ description?: string
12
+ columnId: string
13
+ [key: string]: unknown
14
+ }
15
+
16
+ export interface KanbanColumn {
17
+ id: string
18
+ title: string
19
+ color?: string
20
+ }
21
+
22
+ // ============================================================================
23
+ // KanbanBoard - Main container
24
+ // ============================================================================
25
+
26
+ interface KanbanBoardProps<T extends KanbanItem> {
27
+ columns: KanbanColumn[]
28
+ items: T[]
29
+ onMoveItem: (itemId: string, toColumnId: string) => void
30
+ onAddItem?: (columnId: string) => void
31
+ renderItem?: (item: T) => ReactNode
32
+ className?: string
33
+ }
34
+
35
+ export function KanbanBoard<T extends KanbanItem>({
36
+ columns,
37
+ items,
38
+ onMoveItem,
39
+ onAddItem,
40
+ renderItem,
41
+ className = '',
42
+ }: KanbanBoardProps<T>) {
43
+ const [draggedItem, setDraggedItem] = useState<string | null>(null)
44
+ const [dragOverColumn, setDragOverColumn] = useState<string | null>(null)
45
+
46
+ const handleDragStart = useCallback((itemId: string) => {
47
+ setDraggedItem(itemId)
48
+ }, [])
49
+
50
+ const handleDragEnd = useCallback(() => {
51
+ setDraggedItem(null)
52
+ setDragOverColumn(null)
53
+ }, [])
54
+
55
+ const handleDragOver = useCallback((e: DragEvent, columnId: string) => {
56
+ e.preventDefault()
57
+ setDragOverColumn(columnId)
58
+ }, [])
59
+
60
+ const handleDrop = useCallback((e: DragEvent, columnId: string) => {
61
+ e.preventDefault()
62
+ if (draggedItem) {
63
+ onMoveItem(draggedItem, columnId)
64
+ }
65
+ setDraggedItem(null)
66
+ setDragOverColumn(null)
67
+ }, [draggedItem, onMoveItem])
68
+
69
+ return (
70
+ <div className={`flex gap-4 overflow-x-auto pb-4 ${className}`}>
71
+ {columns.map((column) => {
72
+ const columnItems = items.filter((item) => item.columnId === column.id)
73
+ const isDragOver = dragOverColumn === column.id
74
+
75
+ return (
76
+ <KanbanColumnComponent
77
+ key={column.id}
78
+ column={column}
79
+ items={columnItems}
80
+ isDragOver={isDragOver}
81
+ onDragOver={(e) => handleDragOver(e, column.id)}
82
+ onDrop={(e) => handleDrop(e, column.id)}
83
+ onDragLeave={() => setDragOverColumn(null)}
84
+ onAddItem={onAddItem ? () => onAddItem(column.id) : undefined}
85
+ >
86
+ {columnItems.map((item) => (
87
+ <KanbanCard
88
+ key={item.id}
89
+ item={item}
90
+ isDragging={draggedItem === item.id}
91
+ onDragStart={() => handleDragStart(item.id)}
92
+ onDragEnd={handleDragEnd}
93
+ renderContent={renderItem}
94
+ />
95
+ ))}
96
+ </KanbanColumnComponent>
97
+ )
98
+ })}
99
+ </div>
100
+ )
101
+ }
102
+
103
+ // ============================================================================
104
+ // KanbanColumn
105
+ // ============================================================================
106
+
107
+ interface KanbanColumnComponentProps<T extends KanbanItem> {
108
+ column: KanbanColumn
109
+ items: T[]
110
+ children: ReactNode
111
+ isDragOver: boolean
112
+ onDragOver: (e: DragEvent) => void
113
+ onDrop: (e: DragEvent) => void
114
+ onDragLeave: () => void
115
+ onAddItem?: () => void
116
+ }
117
+
118
+ function KanbanColumnComponent<T extends KanbanItem>({
119
+ column,
120
+ items,
121
+ children,
122
+ isDragOver,
123
+ onDragOver,
124
+ onDrop,
125
+ onDragLeave,
126
+ onAddItem,
127
+ }: KanbanColumnComponentProps<T>) {
128
+ return (
129
+ <div
130
+ className={`
131
+ flex flex-col w-72 min-w-72 bg-muted/50 rounded-lg
132
+ transition-colors duration-200
133
+ ${isDragOver ? 'bg-primary/10 ring-2 ring-primary/20' : ''}
134
+ `}
135
+ onDragOver={onDragOver}
136
+ onDrop={onDrop}
137
+ onDragLeave={onDragLeave}
138
+ >
139
+ {/* Header */}
140
+ <div className="flex items-center justify-between p-3 border-b border-border">
141
+ <div className="flex items-center gap-2">
142
+ {column.color && (
143
+ <span
144
+ className="w-3 h-3 rounded-full"
145
+ style={{ backgroundColor: column.color }}
146
+ />
147
+ )}
148
+ <h3 className="font-medium text-foreground">{column.title}</h3>
149
+ <span className="px-2 py-0.5 text-xs bg-muted rounded-full text-muted-foreground">
150
+ {items.length}
151
+ </span>
152
+ </div>
153
+ <button className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground">
154
+ <MoreHorizontal className="w-4 h-4" />
155
+ </button>
156
+ </div>
157
+
158
+ {/* Items */}
159
+ <div className="flex-1 p-2 space-y-2 min-h-[200px] overflow-y-auto">
160
+ {children}
161
+ </div>
162
+
163
+ {/* Add button */}
164
+ {onAddItem && (
165
+ <button
166
+ onClick={onAddItem}
167
+ className="flex items-center gap-2 p-3 text-sm text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors"
168
+ >
169
+ <Plus className="w-4 h-4" />
170
+ Add item
171
+ </button>
172
+ )}
173
+ </div>
174
+ )
175
+ }
176
+
177
+ // ============================================================================
178
+ // KanbanCard
179
+ // ============================================================================
180
+
181
+ interface KanbanCardProps<T extends KanbanItem> {
182
+ item: T
183
+ isDragging: boolean
184
+ onDragStart: () => void
185
+ onDragEnd: () => void
186
+ renderContent?: (item: T) => ReactNode
187
+ }
188
+
189
+ function KanbanCard<T extends KanbanItem>({
190
+ item,
191
+ isDragging,
192
+ onDragStart,
193
+ onDragEnd,
194
+ renderContent,
195
+ }: KanbanCardProps<T>) {
196
+ return (
197
+ <div
198
+ draggable
199
+ onDragStart={onDragStart}
200
+ onDragEnd={onDragEnd}
201
+ className={`
202
+ group bg-card border border-border rounded-lg p-3 cursor-grab
203
+ hover:border-border/80 hover:shadow-sm transition-all
204
+ ${isDragging ? 'opacity-50 rotate-2 shadow-lg' : ''}
205
+ `}
206
+ >
207
+ {renderContent ? (
208
+ renderContent(item)
209
+ ) : (
210
+ <>
211
+ <div className="flex items-start gap-2">
212
+ <GripVertical className="w-4 h-4 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity shrink-0 mt-0.5" />
213
+ <div className="flex-1 min-w-0">
214
+ <p className="font-medium text-foreground text-sm">{item.title}</p>
215
+ {item.description && (
216
+ <p className="text-xs text-muted-foreground mt-1 line-clamp-2">
217
+ {item.description}
218
+ </p>
219
+ )}
220
+ </div>
221
+ </div>
222
+ </>
223
+ )}
224
+ </div>
225
+ )
226
+ }
227
+
228
+ // ============================================================================
229
+ // useKanban - Hook for managing kanban state
230
+ // ============================================================================
231
+
232
+ interface UseKanbanOptions<T extends KanbanItem> {
233
+ initialItems: T[]
234
+ onItemMoved?: (item: T, fromColumn: string, toColumn: string) => void
235
+ }
236
+
237
+ interface UseKanbanReturn<T extends KanbanItem> {
238
+ items: T[]
239
+ moveItem: (itemId: string, toColumnId: string) => void
240
+ addItem: (item: T) => void
241
+ updateItem: (itemId: string, updates: Partial<T>) => void
242
+ removeItem: (itemId: string) => void
243
+ }
244
+
245
+ export function useKanban<T extends KanbanItem>({
246
+ initialItems,
247
+ onItemMoved,
248
+ }: UseKanbanOptions<T>): UseKanbanReturn<T> {
249
+ const [items, setItems] = useState<T[]>(initialItems)
250
+
251
+ const moveItem = useCallback((itemId: string, toColumnId: string) => {
252
+ setItems((prev) => {
253
+ const item = prev.find((i) => i.id === itemId)
254
+ if (!item || item.columnId === toColumnId) return prev
255
+
256
+ const fromColumn = item.columnId
257
+ const updated = prev.map((i) =>
258
+ i.id === itemId ? { ...i, columnId: toColumnId } : i
259
+ )
260
+
261
+ onItemMoved?.({ ...item, columnId: toColumnId } as T, fromColumn, toColumnId)
262
+ return updated
263
+ })
264
+ }, [onItemMoved])
265
+
266
+ const addItem = useCallback((item: T) => {
267
+ setItems((prev) => [...prev, item])
268
+ }, [])
269
+
270
+ const updateItem = useCallback((itemId: string, updates: Partial<T>) => {
271
+ setItems((prev) =>
272
+ prev.map((i) => (i.id === itemId ? { ...i, ...updates } : i))
273
+ )
274
+ }, [])
275
+
276
+ const removeItem = useCallback((itemId: string) => {
277
+ setItems((prev) => prev.filter((i) => i.id !== itemId))
278
+ }, [])
279
+
280
+ return { items, moveItem, addItem, updateItem, removeItem }
281
+ }
282
+
283
+ export default KanbanBoard