@nextsparkjs/theme-productivity 0.1.0-beta.1

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 (57) hide show
  1. package/README.md +76 -0
  2. package/about.md +123 -0
  3. package/components/CardDetailModal.tsx +318 -0
  4. package/components/KanbanBoard.tsx +612 -0
  5. package/components/KanbanCard.tsx +218 -0
  6. package/components/KanbanColumn.tsx +264 -0
  7. package/components/SortableList.tsx +46 -0
  8. package/components/index.ts +4 -0
  9. package/config/app.config.ts +172 -0
  10. package/config/billing.config.ts +187 -0
  11. package/config/dashboard.config.ts +357 -0
  12. package/config/dev.config.ts +55 -0
  13. package/config/features.config.ts +256 -0
  14. package/config/flows.config.ts +484 -0
  15. package/config/permissions.config.ts +167 -0
  16. package/config/theme.config.ts +106 -0
  17. package/entities/boards/boards.config.ts +61 -0
  18. package/entities/boards/boards.fields.ts +154 -0
  19. package/entities/boards/boards.service.ts +256 -0
  20. package/entities/boards/boards.types.ts +57 -0
  21. package/entities/boards/messages/en.json +80 -0
  22. package/entities/boards/messages/es.json +80 -0
  23. package/entities/boards/migrations/001_boards_table.sql +83 -0
  24. package/entities/cards/cards.config.ts +61 -0
  25. package/entities/cards/cards.fields.ts +242 -0
  26. package/entities/cards/cards.service.ts +336 -0
  27. package/entities/cards/cards.types.ts +79 -0
  28. package/entities/cards/messages/en.json +114 -0
  29. package/entities/cards/messages/es.json +114 -0
  30. package/entities/cards/migrations/020_cards_table.sql +92 -0
  31. package/entities/lists/lists.config.ts +61 -0
  32. package/entities/lists/lists.fields.ts +105 -0
  33. package/entities/lists/lists.service.ts +252 -0
  34. package/entities/lists/lists.types.ts +55 -0
  35. package/entities/lists/messages/en.json +60 -0
  36. package/entities/lists/messages/es.json +60 -0
  37. package/entities/lists/migrations/010_lists_table.sql +79 -0
  38. package/lib/selectors.ts +206 -0
  39. package/messages/en.json +79 -0
  40. package/messages/es.json +79 -0
  41. package/migrations/999_theme_sample_data.sql +922 -0
  42. package/migrations/999a_initial_sample_data.sql +377 -0
  43. package/migrations/999b_abundant_sample_data.sql +346 -0
  44. package/package.json +17 -0
  45. package/permissions-matrix.md +122 -0
  46. package/styles/components.css +460 -0
  47. package/styles/globals.css +560 -0
  48. package/templates/dashboard/(main)/boards/[id]/[cardId]/page.tsx +238 -0
  49. package/templates/dashboard/(main)/boards/[id]/edit/page.tsx +390 -0
  50. package/templates/dashboard/(main)/boards/[id]/page.tsx +236 -0
  51. package/templates/dashboard/(main)/boards/create/page.tsx +236 -0
  52. package/templates/dashboard/(main)/boards/page.tsx +335 -0
  53. package/templates/dashboard/(main)/layout.tsx +32 -0
  54. package/templates/dashboard/(main)/page.tsx +592 -0
  55. package/templates/shared/ProductivityMobileNav.tsx +410 -0
  56. package/templates/shared/ProductivitySidebar.tsx +538 -0
  57. package/templates/shared/ProductivityTopBar.tsx +317 -0
@@ -0,0 +1,612 @@
1
+ 'use client'
2
+
3
+ import { useState, useEffect, useCallback, useRef } from 'react'
4
+ import {
5
+ DndContext,
6
+ DragEndEvent,
7
+ DragOverEvent,
8
+ DragStartEvent,
9
+ PointerSensor,
10
+ useSensor,
11
+ useSensors,
12
+ pointerWithin,
13
+ rectIntersection,
14
+ DragOverlay,
15
+ type CollisionDetection,
16
+ } from '@dnd-kit/core'
17
+ import {
18
+ arrayMove,
19
+ SortableContext,
20
+ horizontalListSortingStrategy,
21
+ } from '@dnd-kit/sortable'
22
+ import { Button } from '@nextsparkjs/core/components/ui/button'
23
+ import { Input } from '@nextsparkjs/core/components/ui/input'
24
+ import { Skeleton } from '@nextsparkjs/core/components/ui/skeleton'
25
+ import { Plus, X } from 'lucide-react'
26
+ import { KanbanColumn, type ListData } from './KanbanColumn'
27
+ import { KanbanCard, type CardData } from './KanbanCard'
28
+ import { CardDetailModal } from './CardDetailModal'
29
+ import { SortableList } from './SortableList'
30
+ import { useToast } from '@nextsparkjs/core/hooks/useToast'
31
+ import { PermissionGate } from '@nextsparkjs/core/components/permissions/PermissionGate'
32
+ import { usePermission } from '@nextsparkjs/core/lib/permissions/hooks'
33
+
34
+ /**
35
+ * Get headers with x-team-id for API calls
36
+ */
37
+ function getTeamHeaders(): HeadersInit {
38
+ const headers: HeadersInit = {
39
+ 'Content-Type': 'application/json',
40
+ }
41
+ if (typeof window !== 'undefined') {
42
+ const teamId = localStorage.getItem('activeTeamId')
43
+ if (teamId) {
44
+ headers['x-team-id'] = teamId
45
+ }
46
+ }
47
+ return headers
48
+ }
49
+
50
+ interface KanbanBoardProps {
51
+ boardId: string
52
+ /** Optional card ID to open modal on mount (for shareable URLs) */
53
+ initialCardId?: string
54
+ }
55
+
56
+ /**
57
+ * Custom collision detection that prioritizes columns over cards
58
+ * This helps with dropping cards into empty columns or at the end of columns
59
+ */
60
+ const customCollisionDetection: CollisionDetection = (args) => {
61
+ // First, check for pointer collisions (most precise)
62
+ const pointerCollisions = pointerWithin(args)
63
+
64
+ // If we have a pointer collision, prioritize columns (droppable areas)
65
+ if (pointerCollisions.length > 0) {
66
+ // Sort to prioritize columns over cards
67
+ const sortedCollisions = [...pointerCollisions].sort((a, b) => {
68
+ const aType = a.data?.droppableContainer?.data?.current?.type
69
+ const bType = b.data?.droppableContainer?.data?.current?.type
70
+
71
+ // Columns should come first
72
+ if (aType === 'column' && bType !== 'column') return -1
73
+ if (bType === 'column' && aType !== 'column') return 1
74
+ return 0
75
+ })
76
+
77
+ return sortedCollisions
78
+ }
79
+
80
+ // Fallback to rect intersection
81
+ return rectIntersection(args)
82
+ }
83
+
84
+ export function KanbanBoard({ boardId, initialCardId }: KanbanBoardProps) {
85
+ const [lists, setLists] = useState<ListData[]>([])
86
+ const [cards, setCards] = useState<CardData[]>([])
87
+ const [isLoading, setIsLoading] = useState(true)
88
+ const [activeCard, setActiveCard] = useState<CardData | null>(null)
89
+ const [activeList, setActiveList] = useState<ListData | null>(null)
90
+ const [isAddingList, setIsAddingList] = useState(false)
91
+ const [newListName, setNewListName] = useState('')
92
+ const [selectedCard, setSelectedCard] = useState<CardData | null>(null)
93
+ const [isModalOpen, setIsModalOpen] = useState(false)
94
+ const { toast } = useToast()
95
+
96
+ // Track if we've processed the initial card ID to avoid opening multiple times
97
+ const initialCardProcessed = useRef(false)
98
+
99
+ // Permission checks
100
+ const canMoveCards = usePermission('cards.move')
101
+ const canCreateLists = usePermission('lists.create')
102
+ const canReorderLists = usePermission('lists.reorder')
103
+
104
+ const sensors = useSensors(
105
+ useSensor(PointerSensor, {
106
+ activationConstraint: {
107
+ distance: 8,
108
+ },
109
+ })
110
+ )
111
+
112
+ // Fetch lists and cards
113
+ const fetchData = useCallback(async () => {
114
+ try {
115
+ setIsLoading(true)
116
+ const headers = getTeamHeaders()
117
+
118
+ // Fetch lists for this board
119
+ const listsRes = await fetch(`/api/v1/lists?boardId=${boardId}&limit=100`, { headers })
120
+ const listsData = await listsRes.json()
121
+ const fetchedLists = (listsData.data || []).sort(
122
+ (a: ListData, b: ListData) => (a.position || 0) - (b.position || 0)
123
+ )
124
+ setLists(fetchedLists)
125
+
126
+ // Fetch cards for all lists
127
+ if (fetchedLists.length > 0) {
128
+ const cardsRes = await fetch(`/api/v1/cards?boardId=${boardId}&limit=500`, { headers })
129
+ const cardsData = await cardsRes.json()
130
+ const fetchedCards = (cardsData.data || []).sort(
131
+ (a: CardData, b: CardData) => (a.position || 0) - (b.position || 0)
132
+ )
133
+ setCards(fetchedCards)
134
+ }
135
+ } catch (error) {
136
+ console.error('Error fetching board data:', error)
137
+ toast({
138
+ title: 'Error loading board',
139
+ description: 'Could not load board data.',
140
+ variant: 'destructive',
141
+ })
142
+ } finally {
143
+ setIsLoading(false)
144
+ }
145
+ // eslint-disable-next-line react-hooks/exhaustive-deps
146
+ }, [boardId])
147
+
148
+ useEffect(() => {
149
+ fetchData()
150
+ }, [fetchData])
151
+
152
+ // Open card modal when initialCardId is provided and cards are loaded
153
+ useEffect(() => {
154
+ if (
155
+ initialCardId &&
156
+ !initialCardProcessed.current &&
157
+ cards.length > 0 &&
158
+ !isLoading
159
+ ) {
160
+ const cardToOpen = cards.find((c) => c.id === initialCardId)
161
+ if (cardToOpen) {
162
+ setSelectedCard(cardToOpen)
163
+ setIsModalOpen(true)
164
+ initialCardProcessed.current = true
165
+ } else {
166
+ // Card not found - show a toast and update URL
167
+ toast({
168
+ title: 'Card not found',
169
+ description: 'The requested card could not be found.',
170
+ variant: 'destructive',
171
+ })
172
+ // Update URL without page reload
173
+ window.history.replaceState(null, '', `/dashboard/boards/${boardId}`)
174
+ initialCardProcessed.current = true
175
+ }
176
+ }
177
+ }, [initialCardId, cards, isLoading, boardId, toast])
178
+
179
+ // Get cards for a specific list
180
+ const getCardsForList = (listId: string) => {
181
+ return cards.filter((card) => card.listId === listId)
182
+ }
183
+
184
+ // Handle drag start
185
+ const handleDragStart = (event: DragStartEvent) => {
186
+ const { active } = event
187
+ const dragData = active.data.current
188
+
189
+ if (dragData?.type === 'card') {
190
+ // Don't allow card drag if user doesn't have move permission
191
+ if (!canMoveCards) return
192
+ setActiveCard(dragData.card)
193
+ } else if (dragData?.type === 'list') {
194
+ // Don't allow list drag if user doesn't have reorder permission
195
+ if (!canReorderLists) return
196
+ setActiveList(dragData.list)
197
+ }
198
+ }
199
+
200
+ // Handle drag over (for moving between columns)
201
+ const handleDragOver = (event: DragOverEvent) => {
202
+ const { active, over } = event
203
+ if (!over) return
204
+
205
+ const activeId = active.id as string
206
+ const overId = over.id as string
207
+
208
+ if (activeId === overId) return
209
+
210
+ const activeData = active.data.current
211
+ const overData = over.data.current
212
+
213
+ // Only handle card movements
214
+ if (activeData?.type !== 'card') return
215
+
216
+ const activeCard = cards.find((c) => c.id === activeId)
217
+ if (!activeCard) return
218
+
219
+ // Determine target list
220
+ let targetListId: string | null = null
221
+
222
+ // Check for column/list (handles both 'column' from useDroppable and 'list' from useSortable)
223
+ if (overData?.type === 'column' || overData?.type === 'list') {
224
+ // Dropped on a column
225
+ targetListId = overId
226
+ } else if (overData?.type === 'card') {
227
+ // Dropped on another card - get its list
228
+ const overCard = cards.find((c) => c.id === overId)
229
+ if (overCard) {
230
+ targetListId = overCard.listId
231
+ }
232
+ }
233
+
234
+ // If moving to a different list, update immediately for visual feedback
235
+ if (targetListId && activeCard.listId !== targetListId) {
236
+ setCards((prev) =>
237
+ prev.map((card) =>
238
+ card.id === activeId ? { ...card, listId: targetListId! } : card
239
+ )
240
+ )
241
+ }
242
+ }
243
+
244
+ // Handle drag end
245
+ const handleDragEnd = async (event: DragEndEvent) => {
246
+ setActiveCard(null)
247
+ setActiveList(null)
248
+
249
+ const { active, over } = event
250
+ if (!over) return
251
+
252
+ const activeId = active.id as string
253
+ const overId = over.id as string
254
+
255
+ const activeData = active.data.current
256
+ const overData = over.data.current
257
+
258
+ // Handle list reordering
259
+ if (activeData?.type === 'list') {
260
+ if (activeId !== overId) {
261
+ const oldIndex = lists.findIndex((l) => l.id === activeId)
262
+ const newIndex = lists.findIndex((l) => l.id === overId)
263
+
264
+ if (oldIndex !== -1 && newIndex !== -1) {
265
+ const reorderedLists = arrayMove(lists, oldIndex, newIndex)
266
+ setLists(reorderedLists)
267
+
268
+ // Update positions on server
269
+ try {
270
+ await fetch(`/api/v1/lists/${activeId}`, {
271
+ method: 'PATCH',
272
+ headers: getTeamHeaders(),
273
+ body: JSON.stringify({
274
+ position: newIndex,
275
+ }),
276
+ })
277
+ } catch (error) {
278
+ console.error('Error reordering list:', error)
279
+ fetchData()
280
+ }
281
+ }
282
+ }
283
+ return
284
+ }
285
+
286
+ if (activeData?.type !== 'card') return
287
+
288
+ const activeCard = cards.find((c) => c.id === activeId)
289
+ if (!activeCard) return
290
+
291
+ // Determine final list and position
292
+ let targetListId = activeCard.listId
293
+ let newPosition = activeCard.position || 0
294
+
295
+ // Check if dropped on a column/list (handles both 'column' from useDroppable and 'list' from useSortable)
296
+ if (overData?.type === 'column' || overData?.type === 'list') {
297
+ targetListId = overId
298
+ // Add to end of list
299
+ const listCards = cards.filter((c) => c.listId === overId)
300
+ newPosition = listCards.length
301
+ } else if (overData?.type === 'card') {
302
+ const overCard = cards.find((c) => c.id === overId)
303
+ if (overCard) {
304
+ targetListId = overCard.listId
305
+
306
+ // Reorder within list
307
+ const listCards = cards.filter((c) => c.listId === targetListId)
308
+ const oldIndex = listCards.findIndex((c) => c.id === activeId)
309
+ const newIndex = listCards.findIndex((c) => c.id === overId)
310
+
311
+ if (oldIndex !== -1 && newIndex !== -1 && oldIndex !== newIndex) {
312
+ const reordered = arrayMove(listCards, oldIndex, newIndex)
313
+ setCards((prev) => {
314
+ const otherCards = prev.filter((c) => c.listId !== targetListId)
315
+ return [...otherCards, ...reordered]
316
+ })
317
+ }
318
+
319
+ newPosition = newIndex
320
+ }
321
+ }
322
+
323
+ // Update on server
324
+ try {
325
+ await fetch(`/api/v1/cards/${activeId}`, {
326
+ method: 'PATCH',
327
+ headers: getTeamHeaders(),
328
+ body: JSON.stringify({
329
+ listId: targetListId,
330
+ position: newPosition,
331
+ }),
332
+ })
333
+ } catch (error) {
334
+ console.error('Error moving card:', error)
335
+ // Refetch to sync state
336
+ fetchData()
337
+ }
338
+ }
339
+
340
+ // Add new list
341
+ const handleAddList = async () => {
342
+ if (!newListName.trim()) return
343
+
344
+ try {
345
+ const response = await fetch('/api/v1/lists', {
346
+ method: 'POST',
347
+ headers: getTeamHeaders(),
348
+ body: JSON.stringify({
349
+ name: newListName.trim(),
350
+ boardId,
351
+ position: lists.length,
352
+ }),
353
+ })
354
+
355
+ if (response.ok) {
356
+ const newList = await response.json()
357
+ setLists((prev) => [...prev, newList.data || newList])
358
+ setNewListName('')
359
+ setIsAddingList(false)
360
+ }
361
+ } catch (error) {
362
+ console.error('Error creating list:', error)
363
+ toast({
364
+ title: 'Error',
365
+ description: 'Could not create list.',
366
+ variant: 'destructive',
367
+ })
368
+ }
369
+ }
370
+
371
+ // Add new card
372
+ const handleAddCard = async (listId: string, title: string) => {
373
+ try {
374
+ const listCards = getCardsForList(listId)
375
+ const response = await fetch('/api/v1/cards', {
376
+ method: 'POST',
377
+ headers: getTeamHeaders(),
378
+ body: JSON.stringify({
379
+ title,
380
+ listId,
381
+ boardId,
382
+ position: listCards.length,
383
+ }),
384
+ })
385
+
386
+ if (response.ok) {
387
+ const newCard = await response.json()
388
+ setCards((prev) => [...prev, newCard.data || newCard])
389
+ }
390
+ } catch (error) {
391
+ console.error('Error creating card:', error)
392
+ toast({
393
+ title: 'Error',
394
+ description: 'Could not create card.',
395
+ variant: 'destructive',
396
+ })
397
+ }
398
+ }
399
+
400
+ // Handle card click to open modal and update URL
401
+ const handleCardClick = (card: CardData) => {
402
+ setSelectedCard(card)
403
+ setIsModalOpen(true)
404
+ // Update URL without page reload using History API
405
+ window.history.pushState(null, '', `/dashboard/boards/${boardId}/${card.id}`)
406
+ }
407
+
408
+ // Handle modal close - update URL back to board
409
+ const handleModalClose = () => {
410
+ setIsModalOpen(false)
411
+ setSelectedCard(null)
412
+ // Update URL without page reload using History API
413
+ window.history.pushState(null, '', `/dashboard/boards/${boardId}`)
414
+ }
415
+
416
+ // Handle card update from modal
417
+ const handleCardUpdate = async (updatedCard: CardData) => {
418
+ try {
419
+ const response = await fetch(`/api/v1/cards/${updatedCard.id}`, {
420
+ method: 'PATCH',
421
+ headers: getTeamHeaders(),
422
+ body: JSON.stringify({
423
+ title: updatedCard.title,
424
+ description: updatedCard.description,
425
+ priority: updatedCard.priority,
426
+ dueDate: updatedCard.dueDate,
427
+ }),
428
+ })
429
+
430
+ if (response.ok) {
431
+ // Update local state
432
+ setCards((prev) =>
433
+ prev.map((card) => (card.id === updatedCard.id ? updatedCard : card))
434
+ )
435
+ toast({
436
+ title: 'Card updated',
437
+ description: 'Changes saved successfully.',
438
+ })
439
+ } else {
440
+ throw new Error('Failed to update card')
441
+ }
442
+ } catch (error) {
443
+ console.error('Error updating card:', error)
444
+ toast({
445
+ title: 'Error',
446
+ description: 'Could not update card.',
447
+ variant: 'destructive',
448
+ })
449
+ throw error
450
+ }
451
+ }
452
+
453
+ // Handle card delete from modal
454
+ const handleCardDelete = async (cardId: string) => {
455
+ try {
456
+ const response = await fetch(`/api/v1/cards/${cardId}`, {
457
+ method: 'DELETE',
458
+ headers: getTeamHeaders(),
459
+ })
460
+
461
+ if (response.ok) {
462
+ // Remove from local state
463
+ setCards((prev) => prev.filter((card) => card.id !== cardId))
464
+ toast({
465
+ title: 'Card deleted',
466
+ description: 'Card has been removed.',
467
+ })
468
+ } else {
469
+ throw new Error('Failed to delete card')
470
+ }
471
+ } catch (error) {
472
+ console.error('Error deleting card:', error)
473
+ toast({
474
+ title: 'Error',
475
+ description: 'Could not delete card.',
476
+ variant: 'destructive',
477
+ })
478
+ throw error
479
+ }
480
+ }
481
+
482
+ if (isLoading) {
483
+ return (
484
+ <div className="flex gap-4 p-4 overflow-x-auto">
485
+ {[1, 2, 3].map((i) => (
486
+ <div key={i} className="w-72 flex-shrink-0">
487
+ <Skeleton className="h-8 w-32 mb-3" />
488
+ <div className="space-y-2">
489
+ <Skeleton className="h-20 w-full" />
490
+ <Skeleton className="h-20 w-full" />
491
+ </div>
492
+ </div>
493
+ ))}
494
+ </div>
495
+ )
496
+ }
497
+
498
+ return (
499
+ <DndContext
500
+ sensors={sensors}
501
+ collisionDetection={customCollisionDetection}
502
+ onDragStart={handleDragStart}
503
+ onDragOver={handleDragOver}
504
+ onDragEnd={handleDragEnd}
505
+ >
506
+ <div
507
+ className="flex gap-4 p-4 overflow-x-auto min-h-[calc(100vh-200px)]"
508
+ data-cy="kanban-board"
509
+ >
510
+ {/* Columns with sortable context */}
511
+ <SortableContext
512
+ items={lists.map((l) => l.id)}
513
+ strategy={horizontalListSortingStrategy}
514
+ >
515
+ {lists.map((list) => (
516
+ <SortableList
517
+ key={list.id}
518
+ list={list}
519
+ disabled={!canReorderLists}
520
+ >
521
+ {({ dragHandleProps }) => (
522
+ <KanbanColumn
523
+ list={list}
524
+ cards={getCardsForList(list.id)}
525
+ onAddCard={handleAddCard}
526
+ onCardClick={handleCardClick}
527
+ dragHandleProps={canReorderLists ? dragHandleProps : undefined}
528
+ />
529
+ )}
530
+ </SortableList>
531
+ ))}
532
+ </SortableContext>
533
+
534
+ {/* Add List - Only shown if user has permission */}
535
+ <PermissionGate permission="lists.create">
536
+ <div className="w-72 flex-shrink-0">
537
+ {isAddingList ? (
538
+ <div className="bg-muted/50 rounded-lg p-3 space-y-2" data-cy="lists-add-form">
539
+ <Input
540
+ autoFocus
541
+ placeholder="Enter list name..."
542
+ value={newListName}
543
+ onChange={(e) => setNewListName(e.target.value)}
544
+ onKeyDown={(e) => {
545
+ if (e.key === 'Enter') handleAddList()
546
+ if (e.key === 'Escape') {
547
+ setIsAddingList(false)
548
+ setNewListName('')
549
+ }
550
+ }}
551
+ data-cy="lists-field-name"
552
+ />
553
+ <div className="flex items-center gap-2">
554
+ <Button size="sm" onClick={handleAddList} disabled={!newListName.trim()} data-cy="lists-form-submit">
555
+ Add List
556
+ </Button>
557
+ <Button
558
+ size="sm"
559
+ variant="ghost"
560
+ onClick={() => {
561
+ setIsAddingList(false)
562
+ setNewListName('')
563
+ }}
564
+ >
565
+ <X className="h-4 w-4" />
566
+ </Button>
567
+ </div>
568
+ </div>
569
+ ) : (
570
+ <Button
571
+ variant="outline"
572
+ className="w-full justify-start bg-muted/30 hover:bg-muted/50"
573
+ onClick={() => setIsAddingList(true)}
574
+ data-cy="lists-add-column"
575
+ >
576
+ <Plus className="h-4 w-4 mr-2" />
577
+ Add another list
578
+ </Button>
579
+ )}
580
+ </div>
581
+ </PermissionGate>
582
+ </div>
583
+
584
+ {/* Drag Overlay */}
585
+ <DragOverlay>
586
+ {activeCard && (
587
+ <div className="rotate-3 opacity-90">
588
+ <KanbanCard card={activeCard} />
589
+ </div>
590
+ )}
591
+ {activeList && (
592
+ <div className="rotate-2 opacity-90">
593
+ <KanbanColumn
594
+ list={activeList}
595
+ cards={getCardsForList(activeList.id)}
596
+ />
597
+ </div>
598
+ )}
599
+ </DragOverlay>
600
+
601
+ {/* Card Detail Modal */}
602
+ <CardDetailModal
603
+ card={selectedCard}
604
+ isOpen={isModalOpen}
605
+ onClose={handleModalClose}
606
+ onUpdate={handleCardUpdate}
607
+ onDelete={handleCardDelete}
608
+ />
609
+ </DndContext>
610
+ )
611
+ }
612
+